From fad89e71daa45d9bd473a5f867b8bde493440f3a Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 14 Apr 2026 16:07:04 +0300 Subject: [PATCH 001/121] feat: add dashboard recent projects feature slice --- AGENTS.md | 16 + CLAUDE.md | 17 +- README.md | 10 + docs/FEATURE_ARCHITECTURE_STANDARD.md | 297 ++++ .../codex-dashboard-recent-projects-plan.md | 1260 +++++++++++++++++ electron.vite.config.ts | 3 + eslint.config.js | 135 +- src/features/README.md | 19 + src/features/recent-projects/contracts/api.ts | 5 + .../recent-projects/contracts/channels.ts | 2 + src/features/recent-projects/contracts/dto.ts | 19 + .../recent-projects/contracts/index.ts | 3 + .../ListDashboardRecentProjectsResponse.ts | 5 + .../core/application/ports/ClockPort.ts | 3 + .../ListDashboardRecentProjectsOutputPort.ts | 5 + .../core/application/ports/LoggerPort.ts | 5 + .../ports/RecentProjectsCachePort.ts | 4 + .../ports/RecentProjectsSourcePort.ts | 7 + .../ListDashboardRecentProjectsUseCase.ts | 150 ++ .../core/domain/models/ProviderId.ts | 1 + .../domain/models/RecentProjectAggregate.ts | 14 + .../domain/models/RecentProjectCandidate.ts | 14 + .../domain/models/RecentProjectOpenTarget.ts | 3 + .../policies/mergeRecentProjectCandidates.ts | 88 ++ .../input/http/registerRecentProjectsHttp.ts | 24 + .../input/ipc/registerRecentProjectsIpc.ts | 25 + .../DashboardRecentProjectsPresenter.ts | 21 + .../ClaudeRecentProjectsSourceAdapter.ts | 80 ++ .../CodexRecentProjectsSourceAdapter.ts | 177 +++ .../createRecentProjectsFeature.ts | 56 + src/features/recent-projects/main/index.ts | 7 + .../cache/InMemoryRecentProjectsCache.ts | 31 + .../codex/CodexAppServerClient.ts | 97 ++ .../codex/CodexBinaryResolver.ts | 71 + .../codex/JsonRpcStdioClient.ts | 211 +++ .../identity/RecentProjectIdentityResolver.ts | 20 + .../identity/normalizeIdentityPath.ts | 10 + .../preload/createRecentProjectsBridge.ts | 11 + src/features/recent-projects/preload/index.ts | 1 + .../adapters/RecentProjectsSectionAdapter.ts | 130 ++ .../renderer/hooks/useOpenRecentProject.ts | 125 ++ .../hooks/useRecentProjectsSection.ts | 178 +++ .../recent-projects/renderer/index.ts | 1 + .../renderer/ui/RecentProjectCard.tsx | 221 +++ .../renderer/ui/RecentProjectsSection.tsx | 169 +++ .../renderer/utils/navigation.ts | 51 + .../renderer/utils/projectDecorations.ts | 11 + src/main/http/index.ts | 8 + src/main/index.ts | 19 +- src/main/standalone.ts | 7 + src/preload/index.ts | 5 +- src/renderer/api/httpClient.ts | 6 +- .../components/dashboard/DashboardView.tsx | 776 +--------- .../components/dashboard/WebPreviewBanner.tsx | 27 + src/renderer/features/CLAUDE.md | 503 +------ src/shared/types/api.ts | 5 +- ...ListDashboardRecentProjectsUseCase.test.ts | 247 ++++ .../mergeRecentProjectCandidates.test.ts | 99 ++ .../RecentProjectsSectionAdapter.test.ts | 73 + .../renderer/utils/navigation.test.ts | 62 + tsconfig.json | 1 + tsconfig.node.json | 10 +- vitest.config.ts | 1 + 63 files changed, 4411 insertions(+), 1251 deletions(-) create mode 100644 AGENTS.md create mode 100644 docs/FEATURE_ARCHITECTURE_STANDARD.md create mode 100644 docs/research/codex-dashboard-recent-projects-plan.md create mode 100644 src/features/README.md create mode 100644 src/features/recent-projects/contracts/api.ts create mode 100644 src/features/recent-projects/contracts/channels.ts create mode 100644 src/features/recent-projects/contracts/dto.ts create mode 100644 src/features/recent-projects/contracts/index.ts create mode 100644 src/features/recent-projects/core/application/models/ListDashboardRecentProjectsResponse.ts create mode 100644 src/features/recent-projects/core/application/ports/ClockPort.ts create mode 100644 src/features/recent-projects/core/application/ports/ListDashboardRecentProjectsOutputPort.ts create mode 100644 src/features/recent-projects/core/application/ports/LoggerPort.ts create mode 100644 src/features/recent-projects/core/application/ports/RecentProjectsCachePort.ts create mode 100644 src/features/recent-projects/core/application/ports/RecentProjectsSourcePort.ts create mode 100644 src/features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase.ts create mode 100644 src/features/recent-projects/core/domain/models/ProviderId.ts create mode 100644 src/features/recent-projects/core/domain/models/RecentProjectAggregate.ts create mode 100644 src/features/recent-projects/core/domain/models/RecentProjectCandidate.ts create mode 100644 src/features/recent-projects/core/domain/models/RecentProjectOpenTarget.ts create mode 100644 src/features/recent-projects/core/domain/policies/mergeRecentProjectCandidates.ts create mode 100644 src/features/recent-projects/main/adapters/input/http/registerRecentProjectsHttp.ts create mode 100644 src/features/recent-projects/main/adapters/input/ipc/registerRecentProjectsIpc.ts create mode 100644 src/features/recent-projects/main/adapters/output/presenters/DashboardRecentProjectsPresenter.ts create mode 100644 src/features/recent-projects/main/adapters/output/sources/ClaudeRecentProjectsSourceAdapter.ts create mode 100644 src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts create mode 100644 src/features/recent-projects/main/composition/createRecentProjectsFeature.ts create mode 100644 src/features/recent-projects/main/index.ts create mode 100644 src/features/recent-projects/main/infrastructure/cache/InMemoryRecentProjectsCache.ts create mode 100644 src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts create mode 100644 src/features/recent-projects/main/infrastructure/codex/CodexBinaryResolver.ts create mode 100644 src/features/recent-projects/main/infrastructure/codex/JsonRpcStdioClient.ts create mode 100644 src/features/recent-projects/main/infrastructure/identity/RecentProjectIdentityResolver.ts create mode 100644 src/features/recent-projects/main/infrastructure/identity/normalizeIdentityPath.ts create mode 100644 src/features/recent-projects/preload/createRecentProjectsBridge.ts create mode 100644 src/features/recent-projects/preload/index.ts create mode 100644 src/features/recent-projects/renderer/adapters/RecentProjectsSectionAdapter.ts create mode 100644 src/features/recent-projects/renderer/hooks/useOpenRecentProject.ts create mode 100644 src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts create mode 100644 src/features/recent-projects/renderer/index.ts create mode 100644 src/features/recent-projects/renderer/ui/RecentProjectCard.tsx create mode 100644 src/features/recent-projects/renderer/ui/RecentProjectsSection.tsx create mode 100644 src/features/recent-projects/renderer/utils/navigation.ts create mode 100644 src/features/recent-projects/renderer/utils/projectDecorations.ts create mode 100644 src/renderer/components/dashboard/WebPreviewBanner.tsx create mode 100644 test/features/recent-projects/core/application/ListDashboardRecentProjectsUseCase.test.ts create mode 100644 test/features/recent-projects/core/domain/mergeRecentProjectCandidates.test.ts create mode 100644 test/features/recent-projects/renderer/adapters/RecentProjectsSectionAdapter.test.ts create mode 100644 test/features/recent-projects/renderer/utils/navigation.test.ts 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 871f52b0..b8f55f57 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,7 @@ No prerequisites - the app can detect supported runtimes/providers and guide set - [Comparison](#comparison) - [Quick start](#quick-start) - [FAQ](#faq) +- [Developer architecture docs](#developer-architecture-docs) - [Development](#development) - [Tech stack](#tech-stack) - [Build for distribution](#build-for-distribution) @@ -118,6 +119,15 @@ A local orchestration layer for AI agent teams across Claude and Codex. - **Task-specific logs and messages** — clearly see agent/runtime logs and messages in isolation for each individual task, making it easy to trace what happened for any assignment - **Live process section** — see which agents are running processes and open URLs directly in the browser - **Stay in control** — send a direct message to any agent, drop a comment on a task, or pick a quick action right on the kanban card whenever you want to clarify something or add new work + +## 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` - **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 - **Solo mode** — one-member team: a single agent that creates its own tasks and shows live progress. Saves tokens; can expand to a full team anytime diff --git a/docs/FEATURE_ARCHITECTURE_STANDARD.md b/docs/FEATURE_ARCHITECTURE_STANDARD.md new file mode 100644 index 00000000..913970ce --- /dev/null +++ b/docs/FEATURE_ARCHITECTURE_STANDARD.md @@ -0,0 +1,297 @@ +# 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 + +## 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 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/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..cbb8fac6 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, }, @@ -97,6 +97,137 @@ export default defineConfig([ }, }, + // 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.', + }, + ], + }, + ], + }, + }, + // Module boundaries - Enforce Electron three-process architecture { name: 'module-boundaries', @@ -548,7 +679,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', diff --git a/src/features/README.md b/src/features/README.md new file mode 100644 index 00000000..c6bf5609 --- /dev/null +++ b/src/features/README.md @@ -0,0 +1,19 @@ +# 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` + +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). diff --git a/src/features/recent-projects/contracts/api.ts b/src/features/recent-projects/contracts/api.ts new file mode 100644 index 00000000..285ce11e --- /dev/null +++ b/src/features/recent-projects/contracts/api.ts @@ -0,0 +1,5 @@ +import type { DashboardRecentProject } 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..253ac36e --- /dev/null +++ b/src/features/recent-projects/contracts/dto.ts @@ -0,0 +1,19 @@ +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; +} diff --git a/src/features/recent-projects/contracts/index.ts b/src/features/recent-projects/contracts/index.ts new file mode 100644 index 00000000..69f32f5a --- /dev/null +++ b/src/features/recent-projects/contracts/index.ts @@ -0,0 +1,3 @@ +export type * from './api'; +export * from './channels'; +export type * from './dto'; 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..7016a81f --- /dev/null +++ b/src/features/recent-projects/core/application/models/ListDashboardRecentProjectsResponse.ts @@ -0,0 +1,5 @@ +import type { RecentProjectAggregate } from '../../domain/models/RecentProjectAggregate'; + +export interface ListDashboardRecentProjectsResponse { + projects: RecentProjectAggregate[]; +} 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..2a76dd00 --- /dev/null +++ b/src/features/recent-projects/core/application/ports/RecentProjectsCachePort.ts @@ -0,0 +1,4 @@ +export interface RecentProjectsCachePort { + get(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..004a8d72 --- /dev/null +++ b/src/features/recent-projects/core/application/ports/RecentProjectsSourcePort.ts @@ -0,0 +1,7 @@ +import type { RecentProjectCandidate } from '../../domain/models/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..15c4295a --- /dev/null +++ b/src/features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase.ts @@ -0,0 +1,150 @@ +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 { RecentProjectsSourcePort } from '../ports/RecentProjectsSourcePort'; + +const DEFAULT_CACHE_TTL_MS = 10_000; +const DEFAULT_DEGRADED_CACHE_TTL_MS = 1_500; + +interface SourceLoadResult { + candidates: RecentProjectCandidate[]; + degraded: boolean; +} + +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; + + 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 startedAt = this.deps.clock.now(); + 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), + }; + 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( + (candidates) => + ({ + kind: 'success', + candidates, + }) 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 { candidates: result.candidates, degraded: false }; + } + + 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 { + candidates: await source.list(), + degraded: false, + }; + } 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..991cab02 --- /dev/null +++ b/src/features/recent-projects/main/adapters/input/http/registerRecentProjectsHttp.ts @@ -0,0 +1,24 @@ +import { + DASHBOARD_RECENT_PROJECTS_ROUTE, + type DashboardRecentProject, +} 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 await feature.listDashboardRecentProjects(); + } catch (error) { + logger.error('Failed to load dashboard recent projects via HTTP', error); + return []; + } + }); +} 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..906c97b5 --- /dev/null +++ b/src/features/recent-projects/main/adapters/input/ipc/registerRecentProjectsIpc.ts @@ -0,0 +1,25 @@ +import { GET_DASHBOARD_RECENT_PROJECTS } 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 await feature.listDashboardRecentProjects(); + } catch (error) { + logger.error('Failed to load dashboard recent projects via IPC', error); + return []; + } + }); +} + +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..638c662e --- /dev/null +++ b/src/features/recent-projects/main/adapters/output/presenters/DashboardRecentProjectsPresenter.ts @@ -0,0 +1,21 @@ +import type { DashboardRecentProject } 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< + DashboardRecentProject[] +> { + present(response: ListDashboardRecentProjectsResponse): DashboardRecentProject[] { + return response.projects.map((aggregate) => ({ + 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..2d5aa3c5 --- /dev/null +++ b/src/features/recent-projects/main/adapters/output/sources/ClaudeRecentProjectsSourceAdapter.ts @@ -0,0 +1,80 @@ +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 } 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; + } + + 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..4430b8bb --- /dev/null +++ b/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts @@ -0,0 +1,177 @@ +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 } from '@features/recent-projects/core/application/ports/RecentProjectsSourcePort'; +import type { RecentProjectCandidate } from '@features/recent-projects/core/domain/models/RecentProjectCandidate'; +import type { + CodexAppServerClient, + 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_LIVE_FETCH_TIMEOUT_MS = 1_200; +const CODEX_ARCHIVED_FETCH_TIMEOUT_MS = 1_800; +const CODEX_REQUEST_TIMEOUT_MS = 1_800; +const CODEX_SOURCE_TIMEOUT_MS = 1_500; +const FAST_ARCHIVED_MERGE_TIMEOUT_MS = 150; + +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; +} + +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 []; + } + + const binaryPath = await this.deps.resolveBinary(); + if (!binaryPath) { + this.deps.logger.info('codex recent-projects source skipped - binary unavailable'); + return []; + } + + const liveThreads = await this.#listThreadsSegmentSafe(binaryPath, 'live', { + archived: false, + totalTimeoutMs: CODEX_LIVE_FETCH_TIMEOUT_MS, + }); + const archivedPromise = this.#listThreadsSegmentSafe(binaryPath, 'archived', { + archived: true, + totalTimeoutMs: CODEX_ARCHIVED_FETCH_TIMEOUT_MS, + }); + const archivedThreads = + liveThreads.length > 0 + ? await this.#awaitWithTimeout(archivedPromise, FAST_ARCHIVED_MERGE_TIMEOUT_MS) + : await archivedPromise; + + 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, + }); + + return candidates; + } + + async #listThreadsSegment( + binaryPath: string, + segment: 'live' | 'archived', + options: { + archived: boolean; + totalTimeoutMs: number; + } + ): Promise { + const result = await this.deps.appServerClient.listThreads(binaryPath, { + archived: options.archived, + limit: CODEX_THREAD_LIMIT, + requestTimeoutMs: CODEX_REQUEST_TIMEOUT_MS, + totalTimeoutMs: options.totalTimeoutMs, + }); + + this.deps.logger.info('codex recent-projects thread list loaded', { + segment, + count: result.length, + }); + return result; + } + + async #awaitWithTimeout( + promise: Promise, + timeoutMs: number + ): Promise { + let timer: ReturnType | null = null; + try { + return await Promise.race([ + promise, + new Promise((resolve) => { + timer = setTimeout(() => resolve([]), timeoutMs); + }), + ]); + } finally { + if (timer) { + clearTimeout(timer); + } + } + } + + #unwrapThreadListError(error: unknown, segment: 'live' | 'archived'): CodexThreadSummary[] { + this.deps.logger.warn('codex recent-projects thread list failed', { + segment, + error: error instanceof Error ? error.message : String(error), + }); + return []; + } + + async #listThreadsSegmentSafe( + binaryPath: string, + segment: 'live' | 'archived', + options: { + archived: boolean; + totalTimeoutMs: number; + } + ): Promise { + try { + return await this.#listThreadsSegment(binaryPath, segment, options); + } catch (error) { + return this.#unwrapThreadListError(error, segment); + } + } + + 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..effb5a8c --- /dev/null +++ b/src/features/recent-projects/main/composition/createRecentProjectsFeature.ts @@ -0,0 +1,56 @@ +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 { DashboardRecentProject } from '@features/recent-projects/contracts'; +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: () => { + const activeContext = deps.getActiveContext(); + return useCase.execute(`dashboard-recent-projects:${activeContext.id}`); + }, + }; +} 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..ff0a0a39 --- /dev/null +++ b/src/features/recent-projects/main/infrastructure/cache/InMemoryRecentProjectsCache.ts @@ -0,0 +1,31 @@ +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()) { + this.#entries.delete(key); + return null; + } + + return entry.value; + } + + 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..a7cab3e2 --- /dev/null +++ b/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts @@ -0,0 +1,97 @@ +import type { JsonRpcStdioClient } from './JsonRpcStdioClient'; + +const DEFAULT_REQUEST_TIMEOUT_MS = 3_000; +const DEFAULT_TOTAL_TIMEOUT_MS = 8_000; +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 class CodexAppServerClient { + constructor(private readonly rpcClient: JsonRpcStdioClient) {} + + async listThreads( + binaryPath: string, + options: { + archived: boolean; + limit: number; + requestTimeoutMs?: number; + totalTimeoutMs?: number; + } + ): Promise { + const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + const totalTimeoutMs = options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS; + + return this.rpcClient.withSession( + { + binaryPath, + args: ['app-server'], + requestTimeoutMs, + totalTimeoutMs, + label: 'codex app-server thread/list', + }, + 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, + }, + }, + requestTimeoutMs + ); + + await session.notify('initialized'); + + const response = await session.request( + 'thread/list', + { + archived: options.archived, + limit: options.limit, + sortKey: 'updated_at', + }, + requestTimeoutMs + ); + + return response.data ?? []; + } + ); + } +} 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..7239af90 --- /dev/null +++ b/src/features/recent-projects/main/infrastructure/codex/CodexBinaryResolver.ts @@ -0,0 +1,71 @@ +import { execCli } from '@main/utils/childProcess'; + +const CACHE_VERIFY_TTL_MS = 30_000; + +let cachedBinaryPath: string | null | undefined; +let cacheVerifiedAt = 0; +let resolveInFlight: Promise | null = null; + +async function verifyBinary(candidate: string): Promise { + try { + await execCli(candidate, ['--version'], { timeout: 2_000, windowsHide: true }); + return candidate; + } catch { + 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..e1904882 --- /dev/null +++ b/src/features/recent-projects/renderer/hooks/useOpenRecentProject.ts @@ -0,0 +1,125 @@ +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'; + +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); + } 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]); + } 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..8b4af403 --- /dev/null +++ b/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts @@ -0,0 +1,178 @@ +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 { 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; + +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, globalTasksLoading, fetchAllTasks, teams } = useStore( + useShallow((state) => ({ + globalTasks: state.globalTasks, + globalTasksLoading: state.globalTasksLoading, + fetchAllTasks: state.fetchAllTasks, + teams: state.teams, + })) + ); + const { openRecentProject, openProjectPath, selectProjectFolder } = useOpenRecentProject(); + const [recentProjects, setRecentProjects] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [visibleProjects, setVisibleProjects] = useState(maxProjects); + const [aliveTeams, setAliveTeams] = useState([]); + const hasFetchedTasksRef = useRef(false); + + const reload = useCallback(async (): Promise => { + setLoading(true); + setError(null); + try { + const projects = await api.getDashboardRecentProjects(); + setRecentProjects(projects); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : 'Failed to load recent projects'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void reload(); + }, [reload]); + + useEffect(() => { + if (recentProjects.length === 0 || hasFetchedTasksRef.current) { + return; + } + + hasFetchedTasksRef.current = true; + void fetchAllTasks(); + }, [fetchAllTasks, 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]); + + 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: recentProjects, + taskCountsByProject, + activeTeamsByProject, + tasksLoading: globalTasksLoading, + }), + [activeTeamsByProject, globalTasksLoading, 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..325e169a --- /dev/null +++ b/src/features/recent-projects/renderer/index.ts @@ -0,0 +1 @@ +export { RecentProjectsSection } from './ui/RecentProjectsSection'; 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/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/index.ts b/src/main/index.ts index 832baa3e..fb6f1855 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,7 +60,7 @@ 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'; @@ -102,8 +108,8 @@ import { } from './utils/safeWebContentsSend'; import { syncTelemetryFlag } from './sentry'; import { - BoardTaskActivityRecordSource, BoardTaskActivityDetailService, + BoardTaskActivityRecordSource, BoardTaskActivityService, BoardTaskExactLogDetailService, BoardTaskExactLogsService, @@ -399,6 +405,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; @@ -927,6 +934,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 +992,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 +1041,7 @@ async function startHttpServer( subagentResolver: activeContext.subagentResolver, chunkBuilder: activeContext.chunkBuilder, dataCache: activeContext.dataCache, + recentProjectsFeature, updaterService, sshConnectionManager, teamProvisioningService, @@ -1119,6 +1133,7 @@ function shutdownServices(): void { // Remove IPC handlers removeIpcHandlers(); + removeRecentProjectsIpc(ipcMain); // Dispose backup service timers teamBackupService?.dispose(); 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/index.ts b/src/preload/index.ts index 79c12a8d..bab93351 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,3 +1,4 @@ +import { createRecentProjectsBridge } from '@features/recent-projects/preload'; import { WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL } from '@shared/constants'; import { contextBridge, ipcRenderer, webUtils } from 'electron'; @@ -243,6 +244,7 @@ import type { ClaudeRootInfo, CliInstallationStatus, CliInstallerProgress, + CliProviderId, ConflictCheckResult, ContextInfo, CreateScheduleInput, @@ -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), @@ -1408,7 +1411,7 @@ 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); }, install: async (): Promise => { diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index b205a4f6..8320bc5b 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 { DashboardRecentProject } from '@features/recent-projects/contracts'; import type { AppConfig, AttachmentFileData, @@ -214,6 +215,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 => @@ -1218,7 +1222,7 @@ export class HttpAPIClient implements ElectronAPI { }, }; - schedules = { + schedules: ElectronAPI['schedules'] = { list: async () => { console.warn('Schedules not available in browser mode'); return [] as Schedule[]; 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/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/features/CLAUDE.md b/src/renderer/features/CLAUDE.md index 50d59279..ae859ae1 100644 --- a/src/renderer/features/CLAUDE.md +++ b/src/renderer/features/CLAUDE.md @@ -1,494 +1,19 @@ -# Features Directory — Architecture Guide +# Renderer Features - Legacy Note -All new renderer features live here. Each feature is a self-contained module following **Clean Architecture**, **SOLID**, and **class-based** patterns. +This directory contains older renderer-local slices and integrations. ---- +For new medium and large features, use the canonical standard instead: +- [Feature Architecture Standard](../../docs/FEATURE_ARCHITECTURE_STANDARD.md) +- [Canonical feature root](../README.md) +- [Feature-local guidance](../CLAUDE.md) -## Quick Start +Default location for new feature work: +- `src/features//` -```bash -mkdir -p src/renderer/features//{ports,adapters,domain,ui,hooks,__tests__} -``` +Reference implementation: +- `src/features/recent-projects` ---- - -## Directory Structure - -### Full Feature - -``` -src/renderer/features// - ├── ports/ # Interfaces (contracts) — NO implementations - │ ├── DataPort.ts # What data the feature needs (input) - │ ├── EventPort.ts # Callbacks the feature fires (output) - │ ├── ConfigPort.ts# Configuration / theme overrides - │ └── types.ts # Domain value types for this feature - │ - ├── adapters/ # Bridge between project infrastructure and feature - │ └── Adapter.ts # Zustand store → DataPort (ONLY place that imports store) - │ - ├── domain/ # Business logic — pure TS, no React, no UI - │ ├── models/ # Domain entities and value objects (classes) - │ └── services/ # Domain services and use cases (classes) - │ - ├── ui/ # React components — presentation only - │ ├── View.tsx # Main component (orchestrator, entry point) - │ ├── Overlay.tsx # Full-screen overlay variant (if applicable) - │ └── Tab.tsx # Tab wrapper variant (if applicable) - │ - ├── hooks/ # React hooks — thin bridges to domain classes - │ └── use.ts # Instantiates domain services, subscribes to store - │ - ├── __tests__/ # Tests colocated with feature - │ ├── adapters.test.ts # Adapter mapping correctness - │ ├── domain.test.ts # Domain logic unit tests - │ └── ports.test.ts # Port type validation - │ - └── index.ts # Public API barrel — exports ONLY from ui/ and ports/ -``` - -### Minimal Feature (no domain layer) - -Small features that don't need business logic: - -``` -src/renderer/features// - ├── Adapter.ts # Zustand → feature data - ├── View.tsx # Main component - └── index.ts # Public API -``` - -### When to Extract a Workspace Package - -Some features benefit from a separate `packages//` workspace package: - -| Keep in `features/` | Extract to `packages/` | -|---------------------|----------------------| -| Tightly coupled to our UI | Reusable in other projects | -| Uses our Zustand store | Framework-agnostic (only React peer dep) | -| Small (<500 LOC) | Large (>1000 LOC of core logic) | -| No external deps | Has its own dependencies (d3-force, etc.) | - -Example: `agent-graph` has BOTH: -- `packages/agent-graph/` — Canvas rendering, d3-force simulation (reusable, no project coupling) -- `features/agent-graph/` — Adapter + overlay + tab (thin integration, imports from store) - ---- - -## Real-World Example: agent-graph - -``` -features/agent-graph/ ← Integration layer (3 files) - ├── useTeamGraphAdapter.ts ← Adapter: TeamData → GraphDataPort - ├── TeamGraphOverlay.tsx ← UI: full-screen overlay - └── TeamGraphTab.tsx ← UI: tab wrapper - -packages/agent-graph/ ← Isolated package (34 files) - ├── src/ports/ ← GraphDataPort, GraphEventPort, types - ├── src/canvas/ ← Canvas 2D renderers - ├── src/strategies/ ← Strategy pattern per node kind - ├── src/hooks/ ← Simulation, camera, interaction - └── src/components/ ← GraphView, GraphCanvas, Controls -``` - -The adapter (`useTeamGraphAdapter.ts`) is the **only file** that imports from `@renderer/store`. Everything else depends only on port interfaces. - ---- - -## SOLID Principles - -### S — Single Responsibility - -Each layer has exactly one reason to change: - -| Layer | Changes when... | Does NOT change when... | -|-------|----------------|------------------------| -| `ports/` | Feature contract changes | Store structure changes | -| `adapters/` | Store data model changes | Canvas rendering changes | -| `domain/` | Business rules change | React version updates | -| `ui/` | UX/layout changes | Data mapping changes | - -### O — Open-Closed - -Extend via new classes, never modify existing ones: - -```typescript -// ✅ New node kind = new class, zero changes to existing code -class ReviewNodeRenderer implements NodeRenderer { ... } - -// Register it — the registry and canvas loop don't change -NodeRendererRegistry.register(new ReviewNodeRenderer()); -``` - -### L — Liskov Substitution - -Any implementation of a port can replace another without breaking the feature: - -```typescript -// Both adapters satisfy GraphDataPort — feature works with either -class LiveTeamAdapter implements GraphDataPort { ... } // Real-time Zustand data -class MockTeamAdapter implements GraphDataPort { ... } // Static test data -class ReplayTeamAdapter implements GraphDataPort { ... } // Recorded session playback - -// Feature doesn't know or care which one it gets -const view = ; -``` - -### I — Interface Segregation - -Split ports by consumer. Each consumer depends only on what it needs: - -```typescript -// ✅ Three small ports -interface GraphDataPort { nodes: GraphNode[]; edges: GraphEdge[]; } -interface GraphEventPort { onNodeClick?(ref: DomainRef): void; } -interface GraphConfigPort { bloomIntensity?: number; showTasks?: boolean; } - -// ❌ One massive interface — forces every consumer to know about everything -interface GraphPort { - nodes: GraphNode[]; edges: GraphEdge[]; - onNodeClick?(ref: DomainRef): void; - bloomIntensity?: number; showTasks?: boolean; -} -``` - -### D — Dependency Inversion - -High-level modules (feature UI) depend on abstractions (ports), not on low-level modules (Zustand store). - -``` -UI → depends on → Port interface ← implemented by ← Adapter → depends on → Store - -Feature code never touches the store. The adapter translates in both directions. -``` - ---- - -## Class-Based Patterns - -Prefer **classes** over functions for domain logic, services, adapters, and stateful code. Use the **latest ECMAScript class features** (ES2024+). - -### Modern Class Syntax - -```typescript -class TeamGraphAdapter implements GraphDataPort { - // ─── ES private fields (NOT TypeScript `private`) ───────────── - readonly #store: StoreApi; - #cachedNodes: GraphNode[] = []; - #lastTeamName = ''; - - // ─── Static factory (prefer for complex initialization) ─────── - static create(store: StoreApi): TeamGraphAdapter { - return new TeamGraphAdapter(store); - } - - // ─── Constructor with DI ────────────────────────────────────── - constructor(store: StoreApi) { - this.#store = store; - } - - // ─── Accessors (get/set) ────────────────────────────────────── - get nodes(): readonly GraphNode[] { - return this.#cachedNodes; - } - - // ─── Public method (port contract) ──────────────────────────── - adapt(teamData: TeamData): GraphDataPort { - if (teamData.teamName === this.#lastTeamName) return this; - this.#lastTeamName = teamData.teamName; - this.#cachedNodes = this.#buildNodes(teamData); - return this; - } - - // ─── ES private method ──────────────────────────────────────── - #buildNodes(data: TeamData): GraphNode[] { - return data.members.map(m => ({ id: m.name, kind: 'member', ... })); - } - - // ─── Disposable (cleanup) ───────────────────────────────────── - [Symbol.dispose](): void { - this.#cachedNodes = []; - } -} -``` - -### Key Rules - -| Rule | Do | Don't | -|------|-----|-------| -| Private fields | `#field` (ES private) | `private field` (TS keyword) | -| Private methods | `#method()` | `private method()` | -| Readonly fields | `readonly #field` | Mutable when immutability intended | -| Static factory | `static create()` | Complex constructor logic | -| Disposal | `[Symbol.dispose]()` or `dispose()` | Forgetting cleanup | -| Type narrowing | `instanceof` checks | `as` casts | - -### When to Use Classes vs Functions - -| Use Case | Pattern | Why | -|----------|---------|-----| -| Domain models with state | **Class** | Encapsulation, lifecycle | -| Adapters (data mapping) | **Class** with caching | State for memoization | -| Services (business logic) | **Class** with DI | Testable, injectable | -| Canvas renderers | **Class** implementing strategy | Polymorphism | -| React components | **Function component** | React requires it | -| React hooks | **Function** | React requires it | -| Pure stateless utilities | **Function** | Simpler, no overhead | -| Constants | `as const` object | Immutable | - -### Dependency Injection - -Always inject dependencies through the constructor: - -```typescript -class FeatureService { - readonly #data: FeatureDataPort; - readonly #events: FeatureEventPort; - - constructor(data: FeatureDataPort, events: FeatureEventPort) { - this.#data = data; - this.#events = events; - } - - execute(): void { - const result = this.#data.getNodes(); - this.#events.onResult?.(result); - } -} - -// Wiring in a hook: -function useFeature(): FeatureService { - const adapter = useMemo(() => FeatureAdapter.create(store), [store]); - return useMemo(() => new FeatureService(adapter, eventHandler), [adapter]); -} -``` - -### Strategy Pattern - -```typescript -interface NodeRenderer { - readonly kind: string; - draw(ctx: CanvasRenderingContext2D, node: Node): void; - hitTest(node: Node, x: number, y: number): boolean; -} - -class MemberNodeRenderer implements NodeRenderer { - readonly kind = 'member'; - draw(ctx: CanvasRenderingContext2D, node: Node): void { /* ... */ } - hitTest(node: Node, x: number, y: number): boolean { /* ... */ } -} - -class NodeRendererRegistry { - readonly #renderers = new Map(); - - register(renderer: NodeRenderer): this { - this.#renderers.set(renderer.kind, renderer); - return this; - } - - get(kind: string): NodeRenderer | undefined { - return this.#renderers.get(kind); - } -} - -// Usage: -const registry = new NodeRendererRegistry() - .register(new MemberNodeRenderer()) - .register(new TaskNodeRenderer()); -``` - ---- - -## Error Handling - -```typescript -// Domain errors — typed, not string messages -class FeatureError extends Error { - constructor( - readonly code: 'INVALID_DATA' | 'RENDER_FAILED' | 'ADAPTER_ERROR', - message: string, - readonly cause?: unknown, - ) { - super(message); - this.name = 'FeatureError'; - } -} - -// In adapters — catch and wrap external errors -class FeatureAdapter { - adapt(data: unknown): FeatureDataPort { - try { - return this.#transform(data); - } catch (err) { - throw new FeatureError('ADAPTER_ERROR', 'Failed to adapt data', err); - } - } -} - -// In UI — catch at boundary, show fallback -function FeatureView({ data }: Props) { - // React error boundary or try/catch in event handlers - // Never let feature errors crash the host app -} -``` - ---- - -## Inter-Feature Communication - -Features MUST NOT import from each other directly. If two features need to share data: - -``` -Feature A → emits event → Host app (TeamDetailView) → passes data → Feature B -``` - -Pattern: use `CustomEvent` on `window` (same as keyboard shortcuts): - -```typescript -// Feature A fires: -window.dispatchEvent(new CustomEvent('feature-a:data-ready', { detail: { ... } })); - -// Host app listens and passes to Feature B via props/ports -``` - ---- - -## Testing - -Tests live in `__tests__/` inside the feature directory. - -```typescript -// __tests__/adapters.test.ts — test data mapping -describe('FeatureAdapter', () => { - it('maps TeamData members to GraphNodes', () => { - const adapter = new FeatureAdapter(); - const result = adapter.adapt(mockTeamData); - expect(result.nodes).toHaveLength(3); - expect(result.nodes[0].kind).toBe('lead'); - }); -}); - -// __tests__/domain.test.ts — test business logic -describe('SimulationService', () => { - it('applies orbit force to task nodes', () => { - const service = new SimulationService(mockConfig); - service.tick(0.016); - expect(service.nodes[0].x).toBeDefined(); - }); -}); -``` - -Run: `pnpm test -- --testPathPattern=features/` - ---- - -## Integration with Main App - -Features connect through minimal **registration points** in shared files: - -### Tab Registration (3 files) - -```typescript -// 1. src/renderer/types/tabs.ts — add to union -type: '...' | ''; - -// 2. src/renderer/components/layout/PaneContent.tsx — add route -{tab.type === '' && ( - - - -)} - -// 3. src/renderer/components/layout/SortableTab.tsx — add icon -: SomeIcon, -``` - -### Overlay Registration (1 file) - -```typescript -// In host component (e.g., TeamDetailView.tsx): -const FeatureOverlay = lazy(() => - import('@renderer/features//ui/FeatureOverlay') - .then(m => ({ default: m.FeatureOverlay })) -); -``` - -### Keyboard Shortcut (1 file) - -```typescript -// In useKeyboardShortcuts.ts: -if (key === '' && event.shiftKey && !event.altKey) { - window.dispatchEvent(new CustomEvent('toggle-', { detail })); -} -``` - ---- - -## Naming Conventions - -| Entity | Convention | Example | -|--------|-----------|---------| -| Feature directory | `kebab-case` | `agent-graph/` | -| Port interfaces | `PascalCase` + `Port` suffix | `GraphDataPort` | -| Domain classes | `PascalCase` | `SimulationService` | -| Adapter classes | `PascalCase` + `Adapter` suffix | `TeamGraphAdapter` | -| UI components | `PascalCase` | `GraphView`, `GraphOverlay` | -| Hooks | `camelCase` + `use` prefix | `useTeamGraphAdapter` | -| Test files | `.test.ts` | `adapters.test.ts` | -| Type files | `camelCase` or `types.ts` | `types.ts` | -| Barrel | `index.ts` | `index.ts` | - ---- - -## Existing Features - -| Feature | Path | Companion Package | Description | -|---------|------|-------------------|-------------| -| `agent-graph` | `features/agent-graph/` | `packages/agent-graph/` | Force-directed graph visualization | - ---- - -## Anti-Patterns - -```typescript -// ❌ Feature imports from another feature -import { X } from '@renderer/features/other-feature/X'; - -// ❌ UI component imports store directly (only adapters may) -import { useStore } from '@renderer/store'; - -// ❌ Feature imports from @renderer/components/* -import { KanbanBoard } from '@renderer/components/team/kanban/KanbanBoard'; - -// ❌ TypeScript `private` instead of ES #private -class Bad { private field = 1; } // Use: #field = 1; - -// ❌ Mutable global state -let globalCache = {}; - -// ❌ `any` or `as any` -const data = response as any; - -// ❌ God-class with mixed responsibilities -class FeatureManager { - fetchData() { ... } - renderUI() { ... } - handleClick() { ... } - saveToStorage() { ... } -} -``` - ---- - -## Checklist for New Feature PR - -- [ ] Feature lives in `src/renderer/features//` -- [ ] Port interfaces defined (`DataPort`, `EventPort` at minimum) -- [ ] Adapter is the ONLY file importing from `@renderer/store` -- [ ] No cross-feature imports -- [ ] Classes use ES `#private` fields, not TypeScript `private` -- [ ] `index.ts` exports only public API (ui components + port types) -- [ ] Integration points documented (which shared files were modified) -- [ ] Tests in `__tests__/` for adapter and domain logic -- [ ] Typecheck passes: `pnpm typecheck` -- [ ] Build passes: `pnpm build` +Keep `src/renderer/features/*` for: +- existing legacy slices +- renderer-only thin integrations +- work that does not introduce a new use case, transport boundary, or cross-process architecture diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 938e3bfc..afc2aae7 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -39,8 +39,8 @@ import type { import type { AddMemberRequest, AddTaskCommentRequest, - BoardTaskActivityDetailResult, AttachmentFileData, + BoardTaskActivityDetailResult, BoardTaskActivityEntry, BoardTaskExactLogDetailResult, BoardTaskExactLogSummariesResponse, @@ -89,6 +89,7 @@ import type { import type { TerminalAPI } from './terminal'; import type { TmuxAPI } from './tmux'; import type { WaterfallData } from './visualization'; +import type { RecentProjectsElectronApi } from '@features/recent-projects/contracts'; import type { ConversationGroup, FileChangeEvent, @@ -721,7 +722,7 @@ export interface ReviewAPI { /** * Complete Electron API exposed to the renderer process via preload script. */ -export interface ElectronAPI { +export interface ElectronAPI extends RecentProjectsElectronApi { getAppVersion: () => Promise; getProjects: () => Promise; getSessions: (projectId: string) => Promise; diff --git a/test/features/recent-projects/core/application/ListDashboardRecentProjectsUseCase.test.ts b/test/features/recent-projects/core/application/ListDashboardRecentProjectsUseCase.test.ts new file mode 100644 index 00000000..11273746 --- /dev/null +++ b/test/features/recent-projects/core/application/ListDashboardRecentProjectsUseCase.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { ListDashboardRecentProjectsUseCase } from '@features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase'; + +import type { ListDashboardRecentProjectsResponse } from '@features/recent-projects/core/application/models/ListDashboardRecentProjectsResponse'; +import type { ListDashboardRecentProjectsOutputPort } from '@features/recent-projects/core/application/ports/ListDashboardRecentProjectsOutputPort'; +import type { LoggerPort } from '@features/recent-projects/core/application/ports/LoggerPort'; +import type { RecentProjectsCachePort } from '@features/recent-projects/core/application/ports/RecentProjectsCachePort'; +import type { RecentProjectsSourcePort } from '@features/recent-projects/core/application/ports/RecentProjectsSourcePort'; +import type { RecentProjectCandidate } from '@features/recent-projects/core/domain/models/RecentProjectCandidate'; + +interface TestViewModel { + ids: string[]; + sources: string[]; +} + +function makeCandidate(overrides: Partial = {}): RecentProjectCandidate { + return { + identity: 'repo:alpha', + displayName: 'alpha', + primaryPath: '/workspace/alpha', + associatedPaths: ['/workspace/alpha'], + lastActivityAt: 1_000, + providerIds: ['anthropic'], + sourceKind: 'claude', + openTarget: { + type: 'existing-worktree', + repositoryId: 'repo-alpha', + worktreeId: 'wt-alpha', + }, + branchName: 'main', + ...overrides, + }; +} + +function createLogger(): LoggerPort & { + info: ReturnType; + warn: ReturnType; + error: ReturnType; +} { + return { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; +} + +describe('ListDashboardRecentProjectsUseCase', () => { + it('returns cached data without calling sources or presenter', async () => { + const cached: TestViewModel = { ids: ['cached'], sources: ['cached'] }; + const cache: RecentProjectsCachePort = { + get: vi.fn().mockResolvedValue(cached), + set: vi.fn(), + }; + const output: ListDashboardRecentProjectsOutputPort = { + present: vi.fn(), + }; + const source: RecentProjectsSourcePort = { + list: vi.fn(), + }; + const logger = createLogger(); + + const useCase = new ListDashboardRecentProjectsUseCase({ + sources: [source], + cache, + output, + clock: { now: () => 1_000 }, + logger, + }); + + await expect(useCase.execute('recent-projects:cache')).resolves.toEqual(cached); + expect(source.list).not.toHaveBeenCalled(); + expect(output.present).not.toHaveBeenCalled(); + expect(cache.set).not.toHaveBeenCalled(); + }); + + it('merges successful sources, degrades failed sources, and caches presenter output', async () => { + const cache: RecentProjectsCachePort = { + get: vi.fn().mockResolvedValue(null), + set: vi.fn().mockResolvedValue(undefined), + }; + const output: ListDashboardRecentProjectsOutputPort = { + present: vi.fn((response: ListDashboardRecentProjectsResponse) => ({ + ids: response.projects.map((project) => project.identity), + sources: response.projects.map((project) => project.source), + })), + }; + const sources: RecentProjectsSourcePort[] = [ + { + list: vi.fn().mockResolvedValue([ + makeCandidate({ + identity: 'repo:alpha', + lastActivityAt: 2_000, + providerIds: ['anthropic'], + sourceKind: 'claude', + }), + ]), + }, + { + list: vi.fn().mockRejectedValue(new Error('codex unavailable')), + }, + { + list: vi.fn().mockResolvedValue([ + makeCandidate({ + identity: 'repo:alpha', + lastActivityAt: 4_000, + providerIds: ['codex'], + sourceKind: 'codex', + openTarget: { + type: 'synthetic-path', + path: '/workspace/alpha', + }, + }), + ]), + }, + ]; + const logger = createLogger(); + let now = 10_000; + + const useCase = new ListDashboardRecentProjectsUseCase({ + sources, + cache, + output, + clock: { + now: () => { + const current = now; + now += 250; + return current; + }, + }, + logger, + }); + + const result = await useCase.execute('recent-projects:fresh'); + + expect(result).toEqual({ + ids: ['repo:alpha'], + sources: ['mixed'], + }); + expect(output.present).toHaveBeenCalledWith({ + projects: [ + expect.objectContaining({ + identity: 'repo:alpha', + source: 'mixed', + providerIds: ['anthropic', 'codex'], + lastActivityAt: 4_000, + openTarget: { + type: 'existing-worktree', + repositoryId: 'repo-alpha', + worktreeId: 'wt-alpha', + }, + }), + ], + }); + expect(cache.set).toHaveBeenCalledWith('recent-projects:fresh', result, 1_500); + expect(logger.warn).toHaveBeenCalledWith('recent-projects source failed', { + sourceId: 'source-1', + sourceIndex: 1, + error: 'codex unavailable', + }); + expect(logger.info).toHaveBeenCalledWith('recent-projects loaded', { + cacheKey: 'recent-projects:fresh', + count: 1, + degradedSources: 1, + cacheTtlMs: 1_500, + durationMs: 250, + }); + }); + + it('returns fast sources without waiting for a timed out source', async () => { + vi.useFakeTimers(); + try { + const cache: RecentProjectsCachePort = { + get: vi.fn().mockResolvedValue(null), + set: vi.fn().mockResolvedValue(undefined), + }; + const output: ListDashboardRecentProjectsOutputPort = { + present: vi.fn((response: ListDashboardRecentProjectsResponse) => ({ + ids: response.projects.map((project) => project.identity), + sources: response.projects.map((project) => project.source), + })), + }; + const slowSource: RecentProjectsSourcePort = { + sourceId: 'codex', + timeoutMs: 50, + list: vi.fn( + () => + new Promise((resolve) => { + setTimeout( + () => + resolve([ + makeCandidate({ + identity: 'repo:codex-only', + providerIds: ['codex'], + sourceKind: 'codex', + openTarget: { + type: 'synthetic-path', + path: '/workspace/codex-only', + }, + }), + ]), + 500 + ); + }) + ), + }; + const fastSource: RecentProjectsSourcePort = { + sourceId: 'claude', + list: vi.fn().mockResolvedValue([ + makeCandidate({ + identity: 'repo:fast', + providerIds: ['anthropic'], + sourceKind: 'claude', + }), + ]), + }; + const logger = createLogger(); + const useCase = new ListDashboardRecentProjectsUseCase({ + sources: [fastSource, slowSource], + cache, + output, + clock: { now: () => 2_000 }, + logger, + }); + + const execution = useCase.execute('recent-projects:timeout'); + await vi.advanceTimersByTimeAsync(60); + + await expect(execution).resolves.toEqual({ + ids: ['repo:fast'], + sources: ['claude'], + }); + expect(logger.warn).toHaveBeenCalledWith('recent-projects source timed out', { + sourceId: 'codex', + sourceIndex: 1, + timeoutMs: 50, + }); + expect(cache.set).toHaveBeenCalledWith( + 'recent-projects:timeout', + { ids: ['repo:fast'], sources: ['claude'] }, + 1_500 + ); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/test/features/recent-projects/core/domain/mergeRecentProjectCandidates.test.ts b/test/features/recent-projects/core/domain/mergeRecentProjectCandidates.test.ts new file mode 100644 index 00000000..fd6e655e --- /dev/null +++ b/test/features/recent-projects/core/domain/mergeRecentProjectCandidates.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest'; + +import { mergeRecentProjectCandidates } from '@features/recent-projects/core/domain/policies/mergeRecentProjectCandidates'; + +import type { RecentProjectCandidate } from '@features/recent-projects/core/domain/models/RecentProjectCandidate'; + +function makeCandidate(overrides: Partial = {}): RecentProjectCandidate { + return { + identity: 'repo:alpha', + displayName: 'alpha', + primaryPath: '/workspace/alpha', + associatedPaths: ['/workspace/alpha'], + lastActivityAt: 1_000, + providerIds: ['anthropic'], + sourceKind: 'claude', + openTarget: { + type: 'existing-worktree', + repositoryId: 'repo-alpha', + worktreeId: 'wt-alpha', + }, + branchName: 'main', + ...overrides, + }; +} + +describe('mergeRecentProjectCandidates', () => { + it('merges providers, keeps latest activity, and prefers existing worktree targets', () => { + const result = mergeRecentProjectCandidates([ + makeCandidate({ + associatedPaths: ['/workspace/alpha', '/workspace/alpha-main'], + lastActivityAt: 2_000, + }), + makeCandidate({ + providerIds: ['codex'], + sourceKind: 'codex', + associatedPaths: ['/workspace/alpha-feature'], + lastActivityAt: 3_000, + openTarget: { + type: 'synthetic-path', + path: '/workspace/alpha', + }, + }), + ]); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + identity: 'repo:alpha', + source: 'mixed', + lastActivityAt: 3_000, + providerIds: ['anthropic', 'codex'], + openTarget: { + type: 'existing-worktree', + repositoryId: 'repo-alpha', + worktreeId: 'wt-alpha', + }, + branchName: 'main', + }); + expect(result[0].associatedPaths).toEqual([ + '/workspace/alpha', + '/workspace/alpha-main', + '/workspace/alpha-feature', + ]); + }); + + it('drops invalid candidates and clears conflicting branches', () => { + const result = mergeRecentProjectCandidates([ + makeCandidate({ + identity: '', + lastActivityAt: 1_000, + }), + makeCandidate({ + identity: 'repo:beta', + displayName: 'beta', + primaryPath: '/workspace/beta', + associatedPaths: ['/workspace/beta'], + branchName: 'main', + }), + makeCandidate({ + identity: 'repo:beta', + displayName: 'beta', + primaryPath: '/workspace/beta', + associatedPaths: ['/workspace/beta-worktree'], + branchName: 'release', + lastActivityAt: 5_000, + }), + makeCandidate({ + identity: 'repo:ignored', + displayName: 'ignored', + primaryPath: '/workspace/ignored', + associatedPaths: ['/workspace/ignored'], + lastActivityAt: 0, + }), + ]); + + expect(result).toHaveLength(1); + expect(result[0].identity).toBe('repo:beta'); + expect(result[0].branchName).toBeUndefined(); + }); +}); diff --git a/test/features/recent-projects/renderer/adapters/RecentProjectsSectionAdapter.test.ts b/test/features/recent-projects/renderer/adapters/RecentProjectsSectionAdapter.test.ts new file mode 100644 index 00000000..90e65166 --- /dev/null +++ b/test/features/recent-projects/renderer/adapters/RecentProjectsSectionAdapter.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; + +import { adaptRecentProjectsSection } from '@features/recent-projects/renderer/adapters/RecentProjectsSectionAdapter'; + +import type { DashboardRecentProject } from '@features/recent-projects/contracts'; +import type { TeamSummary } from '@shared/types'; + +describe('adaptRecentProjectsSection', () => { + it('sorts providers, aggregates decorations, and builds a path summary for merged cards', () => { + const project: DashboardRecentProject = { + id: 'repo:alpha', + name: 'alpha', + primaryPath: '/Users/test/alpha', + associatedPaths: ['/Users/test/alpha', '/Users/test/alpha-worktree'], + mostRecentActivity: Date.parse('2026-04-14T12:00:00Z'), + providerIds: ['codex', 'anthropic'], + source: 'mixed', + openTarget: { + type: 'existing-worktree', + repositoryId: 'repo-alpha', + worktreeId: 'wt-alpha', + }, + primaryBranch: 'main', + }; + + const activeTeam: TeamSummary = { + teamName: 'alpha-team', + displayName: 'Alpha Team', + description: 'Alpha team', + memberCount: 0, + taskCount: 0, + projectPath: '/Users/test/alpha-worktree', + lastActivity: null, + }; + + const cards = adaptRecentProjectsSection({ + projects: [project], + taskCountsByProject: new Map([ + ['/users/test/alpha', { pending: 1, inProgress: 2, completed: 3 }], + ['/users/test/alpha-worktree', { pending: 4, inProgress: 5, completed: 6 }], + ]), + activeTeamsByProject: new Map([ + ['/users/test/alpha', [activeTeam]], + ['/users/test/alpha-worktree', [activeTeam]], + ]), + tasksLoading: false, + }); + + expect(cards).toHaveLength(1); + expect(cards[0]).toMatchObject({ + providerIds: ['anthropic', 'codex'], + taskCounts: { pending: 5, inProgress: 7, completed: 9 }, + additionalPathCount: 1, + primaryBranch: 'main', + activeTeams: [activeTeam], + pathSummary: { + badgeLabel: '2 paths', + description: + 'This card merges recent activity from related worktrees and project paths.', + paths: [ + { + label: 'Primary path', + fullPath: '/Users/test/alpha', + }, + { + label: 'Related path 1', + fullPath: '/Users/test/alpha-worktree', + }, + ], + }, + }); + }); +}); diff --git a/test/features/recent-projects/renderer/utils/navigation.test.ts b/test/features/recent-projects/renderer/utils/navigation.test.ts new file mode 100644 index 00000000..61da4f9b --- /dev/null +++ b/test/features/recent-projects/renderer/utils/navigation.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + buildSyntheticRepositoryGroup, + findMatchingWorktree, +} from '@features/recent-projects/renderer/utils/navigation'; + +import type { RepositoryGroup } from '@renderer/types/data'; + +describe('recent-projects navigation utils', () => { + it('finds a matching worktree across normalized candidate paths', () => { + const groups: RepositoryGroup[] = [ + { + id: 'repo-alpha', + identity: null, + name: 'alpha', + mostRecentSession: 1_000, + totalSessions: 2, + worktrees: [ + { + id: 'wt-alpha', + path: '/Users/test/Alpha', + name: 'alpha', + isMainWorktree: true, + source: 'unknown', + sessions: [], + totalSessions: 2, + createdAt: 1_000, + }, + ], + }, + ]; + + expect( + findMatchingWorktree(groups, ['/users/test/alpha/', '/users/test/other']) + ).toEqual({ + repoId: 'repo-alpha', + worktreeId: 'wt-alpha', + }); + }); + + it('builds a synthetic repository group with encoded repo and worktree ids', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-14T12:00:00Z')); + + const group = buildSyntheticRepositoryGroup('/Users/test/dev/my project'); + + expect(group.id).toBe('-Users-test-dev-my project'); + expect(group.name).toBe('my project'); + expect(group.worktrees).toHaveLength(1); + expect(group.worktrees[0]).toMatchObject({ + id: '-Users-test-dev-my project', + path: '/Users/test/dev/my project', + name: 'my project', + isMainWorktree: true, + totalSessions: 0, + }); + expect(group.worktrees[0].createdAt).toBe(Date.parse('2026-04-14T12:00:00Z')); + + vi.useRealTimers(); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index d1b421bc..029359fe 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,7 @@ "noEmit": true, "baseUrl": ".", "paths": { + "@features/*": ["./src/features/*"], "@main/*": ["./src/main/*"], "@renderer/*": ["./src/renderer/*"], "@preload/*": ["./src/preload/*"], diff --git a/tsconfig.node.json b/tsconfig.node.json index 65b03beb..95df2128 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -12,11 +12,19 @@ "noEmit": true, "baseUrl": ".", "paths": { + "@features/*": ["./src/features/*"], "@main/*": ["./src/main/*"], "@preload/*": ["./src/preload/*"], "@shared/*": ["./src/shared/*"] }, "types": ["node"] }, - "include": ["electron.vite.config.ts", "src/main/**/*", "src/preload/**/*"] + "include": [ + "electron.vite.config.ts", + "src/main/**/*", + "src/preload/**/*", + "src/features/*/contracts/**/*", + "src/features/*/main/**/*", + "src/features/*/preload/**/*" + ] } diff --git a/vitest.config.ts b/vitest.config.ts index 6d1ad570..92574741 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -17,6 +17,7 @@ export default defineConfig({ }, resolve: { alias: { + '@features': resolve(__dirname, 'src/features'), '@shared': resolve(__dirname, 'src/shared'), '@main': resolve(__dirname, 'src/main'), '@renderer': resolve(__dirname, 'src/renderer'), From 928ed6cfc6f524a141bd0d0e898acf2f2ab8f76f Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 14 Apr 2026 16:08:09 +0300 Subject: [PATCH 002/121] docs: fix readme architecture docs placement --- README.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index b8f55f57..ed65dcb3 100644 --- a/README.md +++ b/README.md @@ -119,15 +119,6 @@ A local orchestration layer for AI agent teams across Claude and Codex. - **Task-specific logs and messages** — clearly see agent/runtime logs and messages in isolation for each individual task, making it easy to trace what happened for any assignment - **Live process section** — see which agents are running processes and open URLs directly in the browser - **Stay in control** — send a direct message to any agent, drop a comment on a task, or pick a quick action right on the kanban card whenever you want to clarify something or add new work - -## 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` - **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 - **Solo mode** — one-member team: a single agent that creates its own tasks and shows live progress. Saves tokens; can expand to a full team anytime @@ -166,6 +157,15 @@ For feature architecture and implementation guidance: +## 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 | From c8f9d9bbdd4ebd339f3c0e005c224e58b05dcc89 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 14 Apr 2026 16:24:09 +0300 Subject: [PATCH 003/121] refactor: migrate agent graph to feature slice --- docs/FEATURE_ARCHITECTURE_STANDARD.md | 18 +++++ eslint.config.js | 71 +++++++++++++++++++ src/features/README.md | 5 ++ src/features/agent-graph/README.md | 20 ++++++ .../domain}/buildInlineActivityEntries.ts | 2 +- .../core/domain}/collapseOverflowStacks.ts | 0 .../core/domain}/taskGraphSemantics.ts | 0 .../renderer}/adapters/TeamGraphAdapter.ts | 17 +++-- .../hooks}/useGraphCreateTaskDialog.tsx | 0 .../renderer/hooks}/useTeamGraphAdapter.ts | 2 +- src/features/agent-graph/renderer/index.ts | 12 ++++ .../renderer}/ui/GraphActivityHud.tsx | 2 +- .../renderer}/ui/GraphBlockingEdgePopover.tsx | 0 .../renderer}/ui/GraphNodePopover.tsx | 29 +++++--- .../renderer}/ui/GraphProvisioningHud.tsx | 0 .../renderer}/ui/GraphTaskCard.tsx | 11 +-- .../renderer}/ui/TeamGraphOverlay.tsx | 4 +- .../agent-graph/renderer}/ui/TeamGraphTab.tsx | 6 +- .../components/layout/PaneContent.tsx | 2 +- .../components/team/TeamDetailView.tsx | 4 +- .../team/members/MemberDetailDialog.tsx | 2 +- .../team/members/MemberMessagesTab.tsx | 2 +- src/renderer/features/agent-graph/index.ts | 9 --- .../agent-graph/GraphActivityHud.test.ts | 32 +++++---- .../GraphBlockingEdgePopover.test.ts | 2 +- .../agent-graph/GraphNodePopover.test.ts | 4 +- .../agent-graph/GraphProvisioningHud.test.ts | 4 +- .../agent-graph/TeamGraphAdapter.test.ts | 4 +- .../buildInlineActivityEntries.test.ts | 2 +- .../collapseOverflowStacks.test.ts | 2 +- 30 files changed, 203 insertions(+), 65 deletions(-) create mode 100644 src/features/agent-graph/README.md rename src/{renderer/features/agent-graph/utils => features/agent-graph/core/domain}/buildInlineActivityEntries.ts (99%) rename src/{renderer/features/agent-graph/utils => features/agent-graph/core/domain}/collapseOverflowStacks.ts (100%) rename src/{renderer/features/agent-graph/utils => features/agent-graph/core/domain}/taskGraphSemantics.ts (100%) rename src/{renderer/features/agent-graph => features/agent-graph/renderer}/adapters/TeamGraphAdapter.ts (98%) rename src/{renderer/features/agent-graph/ui => features/agent-graph/renderer/hooks}/useGraphCreateTaskDialog.tsx (100%) rename src/{renderer/features/agent-graph/adapters => features/agent-graph/renderer/hooks}/useTeamGraphAdapter.ts (97%) create mode 100644 src/features/agent-graph/renderer/index.ts rename src/{renderer/features/agent-graph => features/agent-graph/renderer}/ui/GraphActivityHud.tsx (99%) rename src/{renderer/features/agent-graph => features/agent-graph/renderer}/ui/GraphBlockingEdgePopover.tsx (100%) rename src/{renderer/features/agent-graph => features/agent-graph/renderer}/ui/GraphNodePopover.tsx (95%) rename src/{renderer/features/agent-graph => features/agent-graph/renderer}/ui/GraphProvisioningHud.tsx (100%) rename src/{renderer/features/agent-graph => features/agent-graph/renderer}/ui/GraphTaskCard.tsx (92%) rename src/{renderer/features/agent-graph => features/agent-graph/renderer}/ui/TeamGraphOverlay.tsx (97%) rename src/{renderer/features/agent-graph => features/agent-graph/renderer}/ui/TeamGraphTab.tsx (96%) delete mode 100644 src/renderer/features/agent-graph/index.ts diff --git a/docs/FEATURE_ARCHITECTURE_STANDARD.md b/docs/FEATURE_ARCHITECTURE_STANDARD.md index 913970ce..483acda2 100644 --- a/docs/FEATURE_ARCHITECTURE_STANDARD.md +++ b/docs/FEATURE_ARCHITECTURE_STANDARD.md @@ -262,6 +262,12 @@ A smaller feature may skip `core/` and `preload/` when it is: - 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: @@ -295,3 +301,15 @@ Use it as the example for: - 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/eslint.config.js b/eslint.config.js index cbb8fac6..15da01bf 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -227,6 +227,77 @@ export default defineConfig([ ], }, }, + { + 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.', + }, + ], + }, + ], + }, + }, // Module boundaries - Enforce Electron three-process architecture { diff --git a/src/features/README.md b/src/features/README.md index c6bf5609..4e9851ff 100644 --- a/src/features/README.md +++ b/src/features/README.md @@ -8,6 +8,7 @@ Before creating or refactoring a feature, read: 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 @@ -17,3 +18,7 @@ Use `src/features//` by default when the work introduces: 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..70101e02 --- /dev/null +++ b/src/features/agent-graph/README.md @@ -0,0 +1,20 @@ +# 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) + +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/renderer/features/agent-graph/utils/buildInlineActivityEntries.ts b/src/features/agent-graph/core/domain/buildInlineActivityEntries.ts similarity index 99% rename from src/renderer/features/agent-graph/utils/buildInlineActivityEntries.ts rename to src/features/agent-graph/core/domain/buildInlineActivityEntries.ts index 1327fd64..690c6f4f 100644 --- a/src/renderer/features/agent-graph/utils/buildInlineActivityEntries.ts +++ b/src/features/agent-graph/core/domain/buildInlineActivityEntries.ts @@ -160,7 +160,7 @@ export function buildInlineActivityEntries({ for (const [ownerNodeId, entries] of entriesByOwnerNodeId) { entriesByOwnerNodeId.set( ownerNodeId, - entries.sort((a, b) => b.graphItem.timestamp.localeCompare(a.graphItem.timestamp)) + entries.toSorted((a, b) => b.graphItem.timestamp.localeCompare(a.graphItem.timestamp)) ); } 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/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 98% rename from src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts rename to src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts index bb279bbd..a7fd0bf9 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. */ @@ -26,16 +27,15 @@ import { isLeadMember } from '@shared/utils/leadDetection'; import { buildInlineActivityEntries, getGraphLeadMemberName, -} from '../utils/buildInlineActivityEntries'; -import { collapseOverflowStacksWithMeta } from '../utils/collapseOverflowStacks'; +} from '../../core/domain/buildInlineActivityEntries'; +import { collapseOverflowStacksWithMeta } from '../../core/domain/collapseOverflowStacks'; import { isTaskBlocked, isTaskInReviewCycle, resolveTaskReviewer, -} from '../utils/taskGraphSemantics'; +} from '../../core/domain/taskGraphSemantics'; import type { - GraphActivityItem, GraphDataPort, GraphEdge, GraphNode, @@ -571,7 +571,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({ 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/renderer/features/agent-graph/adapters/useTeamGraphAdapter.ts b/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts similarity index 97% rename from src/renderer/features/agent-graph/adapters/useTeamGraphAdapter.ts rename to src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts index 2b160983..346a7105 100644 --- a/src/renderer/features/agent-graph/adapters/useTeamGraphAdapter.ts +++ b/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts @@ -13,7 +13,7 @@ import { } from '@renderer/store/slices/teamSlice'; import { useShallow } from 'zustand/react/shallow'; -import { TeamGraphAdapter } from './TeamGraphAdapter'; +import { TeamGraphAdapter } from '../adapters/TeamGraphAdapter'; import type { GraphDataPort } from '@claude-teams/agent-graph'; diff --git a/src/features/agent-graph/renderer/index.ts b/src/features/agent-graph/renderer/index.ts new file mode 100644 index 00000000..fca5e786 --- /dev/null +++ b/src/features/agent-graph/renderer/index.ts @@ -0,0 +1,12 @@ +/** + * 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 { TeamGraphAdapter } from './adapters/TeamGraphAdapter'; +export type { TeamGraphOverlayProps } from './ui/TeamGraphOverlay'; +export { TeamGraphOverlay } from './ui/TeamGraphOverlay'; +export { TeamGraphTab } from './ui/TeamGraphTab'; diff --git a/src/renderer/features/agent-graph/ui/GraphActivityHud.tsx b/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx similarity index 99% rename from src/renderer/features/agent-graph/ui/GraphActivityHud.tsx rename to src/features/agent-graph/renderer/ui/GraphActivityHud.tsx index 29ba6fd9..0be0a066 100644 --- a/src/renderer/features/agent-graph/ui/GraphActivityHud.tsx +++ b/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx @@ -17,7 +17,7 @@ import { buildInlineActivityEntries, getGraphLeadMemberName, type InlineActivityEntry, -} from '../utils/buildInlineActivityEntries'; +} from '../../core/domain/buildInlineActivityEntries'; import type { GraphNode } from '@claude-teams/agent-graph'; import type { TimelineItem } from '@renderer/components/team/activity/LeadThoughtsGroup'; diff --git a/src/renderer/features/agent-graph/ui/GraphBlockingEdgePopover.tsx b/src/features/agent-graph/renderer/ui/GraphBlockingEdgePopover.tsx similarity index 100% rename from src/renderer/features/agent-graph/ui/GraphBlockingEdgePopover.tsx rename to src/features/agent-graph/renderer/ui/GraphBlockingEdgePopover.tsx diff --git a/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx b/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx similarity index 95% rename from src/renderer/features/agent-graph/ui/GraphNodePopover.tsx rename to src/features/agent-graph/renderer/ui/GraphNodePopover.tsx index 029b1834..ffb7d100 100644 --- a/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx +++ b/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx @@ -1,7 +1,7 @@ /** * 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'; @@ -16,7 +16,7 @@ import { buildTeamProvisioningPresentation } from '@renderer/utils/teamProvision 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 { GraphTaskCard } from './GraphTaskCard'; @@ -40,11 +40,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); @@ -421,7 +432,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/renderer/features/agent-graph/ui/GraphProvisioningHud.tsx b/src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx similarity index 100% rename from src/renderer/features/agent-graph/ui/GraphProvisioningHud.tsx rename to src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx diff --git a/src/renderer/features/agent-graph/ui/GraphTaskCard.tsx b/src/features/agent-graph/renderer/ui/GraphTaskCard.tsx similarity index 92% rename from src/renderer/features/agent-graph/ui/GraphTaskCard.tsx rename to src/features/agent-graph/renderer/ui/GraphTaskCard.tsx index ba2c343e..903fdeea 100644 --- a/src/renderer/features/agent-graph/ui/GraphTaskCard.tsx +++ b/src/features/agent-graph/renderer/ui/GraphTaskCard.tsx @@ -1,6 +1,7 @@ /** * 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'; @@ -10,10 +11,10 @@ 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 type { GraphNode } from '@claude-teams/agent-graph'; -import type { KanbanColumnId, TeamTask, TeamTaskWithKanban } from '@shared/types'; +import type { KanbanColumnId, TeamTask } from '@shared/types'; // ─── Types ────────────────────────────────────────────────────────────────── @@ -119,8 +120,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 97% rename from src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx rename to src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx index 2a12a7d3..d71db2ab 100644 --- a/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx +++ b/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx @@ -9,13 +9,13 @@ 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 { useTeamGraphAdapter } from '../hooks/useTeamGraphAdapter'; 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 { diff --git a/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx b/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx similarity index 96% rename from src/renderer/features/agent-graph/ui/TeamGraphTab.tsx rename to src/features/agent-graph/renderer/ui/TeamGraphTab.tsx index 940b62dd..76025c59 100644 --- a/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx +++ b/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx @@ -9,15 +9,15 @@ 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 { useTeamGraphAdapter } from '../hooks/useTeamGraphAdapter'; import { GraphActivityHud } from './GraphActivityHud'; import { GraphBlockingEdgePopover } from './GraphBlockingEdgePopover'; import { GraphNodePopover } from './GraphNodePopover'; import { GraphProvisioningHud } from './GraphProvisioningHud'; -import { useGraphCreateTaskDialog } from './useGraphCreateTaskDialog'; -import type { GraphDomainRef, GraphEventPort, GraphNode } from '@claude-teams/agent-graph'; +import type { GraphDomainRef, GraphEventPort } from '@claude-teams/agent-graph'; import type { MemberActivityFilter, MemberDetailTab, diff --git a/src/renderer/components/layout/PaneContent.tsx b/src/renderer/components/layout/PaneContent.tsx index a7449d29..e65e0a9e 100644 --- a/src/renderer/components/layout/PaneContent.tsx +++ b/src/renderer/components/layout/PaneContent.tsx @@ -3,8 +3,8 @@ * Uses CSS display-toggle to keep all tabs mounted (preserving state). */ +import { TeamGraphTab } from '@features/agent-graph/renderer'; import { TabUIProvider } from '@renderer/contexts/TabUIContext'; -import { TeamGraphTab } from '@renderer/features/agent-graph/ui/TeamGraphTab'; import { DashboardView } from '../dashboard/DashboardView'; import { ExtensionStoreView } from '../extensions/ExtensionStoreView'; diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index fe8eed43..c53f03c4 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -72,15 +72,15 @@ import { TrashDialog } from './kanban/TrashDialog'; import { MemberDetailDialog } from './members/MemberDetailDialog'; import { type MemberActivityFilter, type MemberDetailTab } from './members/memberDetailTypes'; -import type { TeamMessagesPanelMode } from '@renderer/types/teamMessagesPanelMode'; import type { AddMemberEntry } from './dialogs/AddMemberDialog'; +import type { TeamMessagesPanelMode } from '@renderer/types/teamMessagesPanelMode'; import type { ComponentProps, CSSProperties } from 'react'; const ProjectEditorOverlay = lazy(() => import('./editor/ProjectEditorOverlay').then((m) => ({ default: m.ProjectEditorOverlay })) ); const TeamGraphOverlay = lazy(() => - import('@renderer/features/agent-graph/ui/TeamGraphOverlay').then((m) => ({ + import('@features/agent-graph/renderer').then((m) => ({ default: m.TeamGraphOverlay, })) ); diff --git a/src/renderer/components/team/members/MemberDetailDialog.tsx b/src/renderer/components/team/members/MemberDetailDialog.tsx index d4cdce7c..3db3e9d2 100644 --- a/src/renderer/components/team/members/MemberDetailDialog.tsx +++ b/src/renderer/components/team/members/MemberDetailDialog.tsx @@ -1,9 +1,9 @@ import { useEffect, useMemo, useState } from 'react'; +import { buildInlineActivityEntries } from '@features/agent-graph/renderer'; import { Button } from '@renderer/components/ui/button'; import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'; -import { buildInlineActivityEntries } from '@renderer/features/agent-graph/utils/buildInlineActivityEntries'; import { useMemberStats } from '@renderer/hooks/useMemberStats'; import { isLeadMember } from '@shared/utils/leadDetection'; import { BarChart3, FileText, ListPlus, MessageSquare, UserMinus } from 'lucide-react'; diff --git a/src/renderer/components/team/members/MemberMessagesTab.tsx b/src/renderer/components/team/members/MemberMessagesTab.tsx index d273268c..b2eda169 100644 --- a/src/renderer/components/team/members/MemberMessagesTab.tsx +++ b/src/renderer/components/team/members/MemberMessagesTab.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; +import { buildInlineActivityEntries } from '@features/agent-graph/renderer'; import { api } from '@renderer/api'; import { ActivityItem } from '@renderer/components/team/activity/ActivityItem'; import { @@ -8,7 +9,6 @@ import { } from '@renderer/components/team/activity/activityMessageContext'; import { MessageExpandDialog } from '@renderer/components/team/activity/MessageExpandDialog'; import { Button } from '@renderer/components/ui/button'; -import { buildInlineActivityEntries } from '@renderer/features/agent-graph/utils/buildInlineActivityEntries'; import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages'; import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering'; diff --git a/src/renderer/features/agent-graph/index.ts b/src/renderer/features/agent-graph/index.ts deleted file mode 100644 index 1c15bd65..00000000 --- a/src/renderer/features/agent-graph/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * agent-graph feature — public API. - * Only exports UI components and adapter types. - */ - -export { TeamGraphAdapter } from './adapters/TeamGraphAdapter'; -export type { TeamGraphOverlayProps } from './ui/TeamGraphOverlay'; -export { TeamGraphOverlay } from './ui/TeamGraphOverlay'; -export { TeamGraphTab } from './ui/TeamGraphTab'; diff --git a/test/renderer/features/agent-graph/GraphActivityHud.test.ts b/test/renderer/features/agent-graph/GraphActivityHud.test.ts index 1f772787..55c9648d 100644 --- a/test/renderer/features/agent-graph/GraphActivityHud.test.ts +++ b/test/renderer/features/agent-graph/GraphActivityHud.test.ts @@ -2,7 +2,7 @@ import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { GraphActivityHud } from '@renderer/features/agent-graph/ui/GraphActivityHud'; +import { GraphActivityHud } from '@features/agent-graph/renderer/ui/GraphActivityHud'; import type { GraphNode } from '@claude-teams/agent-graph'; import type { InboxMessage } from '@shared/types/team'; @@ -17,16 +17,22 @@ const teamState = { tasks: [], messages: [], }, - teamDataCacheByName: { - 'demo-team': { - members: [ - { name: 'team-lead', agentType: 'team-lead' }, - { name: 'jack', agentType: 'developer' }, - ], - tasks: [], - messages: [], - }, - } as Record>; tasks: unknown[]; messages: unknown[] }>, + teamDataCacheByName: new Map< + string, + { members: Record[]; tasks: unknown[]; messages: unknown[] } + >([ + [ + 'demo-team', + { + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'jack', agentType: 'developer' }, + ], + tasks: [], + messages: [], + }, + ], + ]), teams: [], }; @@ -38,7 +44,7 @@ vi.mock('@renderer/store', () => ({ vi.mock('@renderer/store/slices/teamSlice', () => ({ selectTeamDataForName: (_state: typeof teamState, teamName: string) => - teamState.teamDataCacheByName[teamName] ?? + teamState.teamDataCacheByName.get(teamName) ?? (teamState.selectedTeamName === teamName ? teamState.selectedTeamData : null), })); @@ -79,7 +85,7 @@ vi.mock('@renderer/components/team/activity/activityMessageContext', () => ({ resolveMessageRenderProps: () => ({}), })); -vi.mock('@renderer/features/agent-graph/utils/buildInlineActivityEntries', () => ({ +vi.mock('@features/agent-graph/core/domain/buildInlineActivityEntries', () => ({ buildInlineActivityEntries: (...args: unknown[]) => buildInlineActivityEntries(...args), getGraphLeadMemberName: () => 'team-lead', })); diff --git a/test/renderer/features/agent-graph/GraphBlockingEdgePopover.test.ts b/test/renderer/features/agent-graph/GraphBlockingEdgePopover.test.ts index 8740ecb6..15d30a81 100644 --- a/test/renderer/features/agent-graph/GraphBlockingEdgePopover.test.ts +++ b/test/renderer/features/agent-graph/GraphBlockingEdgePopover.test.ts @@ -2,8 +2,8 @@ import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { GraphBlockingEdgePopover } from '@features/agent-graph/renderer/ui/GraphBlockingEdgePopover'; import { useStore } from '@renderer/store'; -import { GraphBlockingEdgePopover } from '@renderer/features/agent-graph/ui/GraphBlockingEdgePopover'; import type { GraphEdge, GraphNode } from '@claude-teams/agent-graph'; diff --git a/test/renderer/features/agent-graph/GraphNodePopover.test.ts b/test/renderer/features/agent-graph/GraphNodePopover.test.ts index 9a85ee15..7eaa65b5 100644 --- a/test/renderer/features/agent-graph/GraphNodePopover.test.ts +++ b/test/renderer/features/agent-graph/GraphNodePopover.test.ts @@ -13,11 +13,11 @@ vi.mock('@renderer/components/ui/button', () => ({ React.createElement('button', { type: 'button' }, children), })); -vi.mock('@renderer/features/agent-graph/ui/GraphTaskCard', () => ({ +vi.mock('@features/agent-graph/renderer/ui/GraphTaskCard', () => ({ GraphTaskCard: () => React.createElement('div', null, 'task-card'), })); -import { GraphNodePopover } from '@renderer/features/agent-graph/ui/GraphNodePopover'; +import { GraphNodePopover } from '@features/agent-graph/renderer/ui/GraphNodePopover'; import type { GraphNode } from '@claude-teams/agent-graph'; diff --git a/test/renderer/features/agent-graph/GraphProvisioningHud.test.ts b/test/renderer/features/agent-graph/GraphProvisioningHud.test.ts index e9f830a0..49a29299 100644 --- a/test/renderer/features/agent-graph/GraphProvisioningHud.test.ts +++ b/test/renderer/features/agent-graph/GraphProvisioningHud.test.ts @@ -2,6 +2,8 @@ import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { GraphProvisioningHud } from '@features/agent-graph/renderer/ui/GraphProvisioningHud'; + const hookState = { presentation: null as | { @@ -44,8 +46,6 @@ vi.mock('@renderer/components/team/TeamProvisioningPanel', () => ({ ), })); -import { GraphProvisioningHud } from '@renderer/features/agent-graph/ui/GraphProvisioningHud'; - const placement = { x: 120, y: 80, scale: 1, visible: true }; describe('GraphProvisioningHud', () => { diff --git a/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts index 10f1771e..08fab22a 100644 --- a/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts +++ b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { TeamGraphAdapter } from '@renderer/features/agent-graph/adapters/TeamGraphAdapter'; +import { TeamGraphAdapter } from '@features/agent-graph/renderer/adapters/TeamGraphAdapter'; import type { InboxMessage, TeamData, TeamTaskWithKanban } from '@shared/types/team'; import type { GraphDataPort } from '@claude-teams/agent-graph'; @@ -459,7 +459,7 @@ describe('TeamGraphAdapter particles', () => { const graph = adapter.adapt(next, 'my-team'); expect(graph.particles).toHaveLength(2); - expect(graph.particles.map((particle) => particle.kind).sort()).toEqual([ + expect(graph.particles.map((particle) => particle.kind).toSorted((a, b) => a.localeCompare(b))).toEqual([ 'inbox_message', 'task_comment', ]); diff --git a/test/renderer/features/agent-graph/buildInlineActivityEntries.test.ts b/test/renderer/features/agent-graph/buildInlineActivityEntries.test.ts index 9c8c56b8..495da788 100644 --- a/test/renderer/features/agent-graph/buildInlineActivityEntries.test.ts +++ b/test/renderer/features/agent-graph/buildInlineActivityEntries.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'; import { buildInlineActivityEntries, getGraphLeadMemberName, -} from '@renderer/features/agent-graph/utils/buildInlineActivityEntries'; +} from '@features/agent-graph/core/domain/buildInlineActivityEntries'; import type { InboxMessage, TeamData, TeamTaskWithKanban } from '@shared/types/team'; diff --git a/test/renderer/features/agent-graph/collapseOverflowStacks.test.ts b/test/renderer/features/agent-graph/collapseOverflowStacks.test.ts index a3e637b5..d712659f 100644 --- a/test/renderer/features/agent-graph/collapseOverflowStacks.test.ts +++ b/test/renderer/features/agent-graph/collapseOverflowStacks.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'; import { collapseOverflowStacks, collapseOverflowStacksWithMeta, -} from '@renderer/features/agent-graph/utils/collapseOverflowStacks'; +} from '@features/agent-graph/core/domain/collapseOverflowStacks'; import type { GraphNode } from '@claude-teams/agent-graph'; From 43ae8ae6bc9385134c8b222b9b3075ca2da17695 Mon Sep 17 00:00:00 2001 From: Diego Serrano <129707357+diegoserranobst@users.noreply.github.com> Date: Tue, 14 Apr 2026 07:37:04 -0400 Subject: [PATCH 004/121] fix(extensions): resolve project path from both projects and repositoryGroups --- .../extensions/ExtensionStoreView.tsx | 15 +- src/renderer/store/slices/extensionsSlice.ts | 18 +- src/renderer/utils/projectLookup.ts | 27 +++ test/renderer/store/extensionsSlice.test.ts | 36 ++++ test/renderer/utils/projectLookup.test.ts | 162 +++++++++++++++++- 5 files changed, 247 insertions(+), 11 deletions(-) diff --git a/src/renderer/components/extensions/ExtensionStoreView.tsx b/src/renderer/components/extensions/ExtensionStoreView.tsx index cec304fb..ec905ee0 100644 --- a/src/renderer/components/extensions/ExtensionStoreView.tsx +++ b/src/renderer/components/extensions/ExtensionStoreView.tsx @@ -18,6 +18,7 @@ import { import { useTabIdOptional } from '@renderer/contexts/useTabUIContext'; import { useExtensionsTabState } from '@renderer/hooks/useExtensionsTabState'; import { useStore } from '@renderer/store'; +import { resolveProjectPathById } from '@renderer/utils/projectLookup'; import { AlertTriangle, BookOpen, Info, Key, Plus, Puzzle, RefreshCw, Server } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; @@ -45,6 +46,7 @@ export const ExtensionStoreView = (): React.JSX.Element => { openDashboard, sessions, projects, + repositoryGroups, } = useStore( useShallow((s) => ({ fetchPluginCatalog: s.fetchPluginCatalog, @@ -61,6 +63,7 @@ export const ExtensionStoreView = (): React.JSX.Element => { openDashboard: s.openDashboard, sessions: s.sessions, projects: s.projects, + repositoryGroups: s.repositoryGroups, })) ); const cliInstalled = cliStatus?.installed ?? true; @@ -74,14 +77,12 @@ export const ExtensionStoreView = (): React.JSX.Element => { const tabState = useExtensionsTabState(); const [customMcpDialogOpen, setCustomMcpDialogOpen] = useState(false); - const projectPath = useMemo( - () => projects.find((project) => project.id === extensionsTabProjectId)?.path ?? null, - [extensionsTabProjectId, projects] - ); - const projectLabel = useMemo( - () => projects.find((project) => project.id === extensionsTabProjectId)?.name ?? null, - [extensionsTabProjectId, projects] + const resolvedProject = useMemo( + () => resolveProjectPathById(extensionsTabProjectId, projects, repositoryGroups), + [extensionsTabProjectId, projects, repositoryGroups] ); + const projectPath = resolvedProject?.path ?? null; + const projectLabel = resolvedProject?.name ?? null; const subTabs = useMemo( () => [ { diff --git a/src/renderer/store/slices/extensionsSlice.ts b/src/renderer/store/slices/extensionsSlice.ts index eb44adae..58063575 100644 --- a/src/renderer/store/slices/extensionsSlice.ts +++ b/src/renderer/store/slices/extensionsSlice.ts @@ -7,6 +7,7 @@ import { api } from '@renderer/api'; import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli'; import type { AppState } from '../types'; +import { findPaneByTabId, updatePane } from '../utils/paneHelpers'; import type { ApiKeyEntry, ApiKeySaveRequest, @@ -889,9 +890,24 @@ export const createExtensionsSlice: StateCreator { const state = get(); + const currentProjectId = state.selectedProjectId ?? state.activeProjectId ?? undefined; const focusedPane = state.paneLayout.panes.find((p) => p.id === state.paneLayout.focusedPaneId); const existingTab = focusedPane?.tabs.find((tab) => tab.type === 'extensions'); if (existingTab) { + // Update projectId to reflect the currently selected project + if (existingTab.projectId !== currentProjectId) { + const pane = findPaneByTabId(state.paneLayout, existingTab.id); + if (pane) { + set({ + paneLayout: updatePane(state.paneLayout, { + ...pane, + tabs: pane.tabs.map((t) => + t.id === existingTab.id ? { ...t, projectId: currentProjectId } : t + ), + }), + }); + } + } state.setActiveTab(existingTab.id); return; } @@ -899,7 +915,7 @@ export const createExtensionsSlice: StateCreator[], + repositoryGroups: readonly Pick[] +): { path: string; name: string } | null { + if (!projectId) return null; + + const fromProjects = projects.find((p) => p.id === projectId); + if (fromProjects) return { path: fromProjects.path, name: fromProjects.name }; + + for (const group of repositoryGroups) { + const worktree = group.worktrees.find((w) => w.id === projectId); + if (worktree) return { path: worktree.path, name: worktree.name }; + } + + return null; +} diff --git a/test/renderer/store/extensionsSlice.test.ts b/test/renderer/store/extensionsSlice.test.ts index 623a7b06..a69507fa 100644 --- a/test/renderer/store/extensionsSlice.test.ts +++ b/test/renderer/store/extensionsSlice.test.ts @@ -258,6 +258,42 @@ describe('extensionsSlice', () => { expect(count1).toBe(1); expect(count2).toBe(1); // no duplicate }); + + it('updates projectId on existing tab when selected project changes', () => { + // Open Extensions with project-A + store.setState({ selectedProjectId: 'project-A', activeProjectId: null }); + store.getState().openExtensionsTab(); + + const tabsBefore = store.getState().paneLayout.panes.flatMap((p) => p.tabs); + const extTabBefore = tabsBefore.find((t) => t.type === 'extensions'); + expect(extTabBefore?.projectId).toBe('project-A'); + + // Switch to project-B and reopen Extensions + store.setState({ selectedProjectId: 'project-B' }); + store.getState().openExtensionsTab(); + + const tabsAfter = store.getState().paneLayout.panes.flatMap((p) => p.tabs); + const extTabAfter = tabsAfter.find((t) => t.type === 'extensions'); + expect(extTabAfter?.projectId).toBe('project-B'); + // Still only one extensions tab + expect(tabsAfter.filter((t) => t.type === 'extensions')).toHaveLength(1); + }); + + it('does not update projectId when it already matches', () => { + store.setState({ selectedProjectId: 'project-A', activeProjectId: null }); + store.getState().openExtensionsTab(); + + const layoutBefore = store.getState().paneLayout; + + // Reopen with same project — layout should be referentially stable (no set() call) + store.getState().openExtensionsTab(); + + const tabsBefore = layoutBefore.panes.flatMap((p) => p.tabs); + const tabsAfter = store.getState().paneLayout.panes.flatMap((p) => p.tabs); + const extBefore = tabsBefore.find((t) => t.type === 'extensions'); + const extAfter = tabsAfter.find((t) => t.type === 'extensions'); + expect(extAfter?.projectId).toBe(extBefore?.projectId); + }); }); describe('installPlugin', () => { diff --git a/test/renderer/utils/projectLookup.test.ts b/test/renderer/utils/projectLookup.test.ts index e8685d45..c90b2626 100644 --- a/test/renderer/utils/projectLookup.test.ts +++ b/test/renderer/utils/projectLookup.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { resolveProjectIdByPath } from '@renderer/utils/projectLookup'; +import { resolveProjectIdByPath, resolveProjectPathById } from '@renderer/utils/projectLookup'; import type { Project, RepositoryGroup } from '@renderer/types/data'; @@ -9,16 +9,19 @@ import type { Project, RepositoryGroup } from '@renderer/types/data'; // --------------------------------------------------------------------------- type ProjectLike = Pick; +type ProjectWithName = Pick; type RepoGroupLike = Pick; -const CRYPTO_PROJECT: ProjectLike = { +const CRYPTO_PROJECT: ProjectWithName = { id: '-Users-belief-dev-projects-crypto-research', path: '/Users/belief/dev/projects/crypto_research', + name: 'crypto_research', }; -const CLAUDE_PROJECT: ProjectLike = { +const CLAUDE_PROJECT: ProjectWithName = { id: '-Users-belief-dev-projects-claude-claude-team', path: '/Users/belief/dev/projects/claude/claude_team', + name: 'claude_team', }; function makeRepoGroup(worktrees: { id: string; path: string }[]): RepoGroupLike { @@ -290,3 +293,156 @@ describe('resolveProjectIdByPath', () => { }); }); }); + +// =========================================================================== +// resolveProjectPathById — inverse lookup (ID → path + name) +// =========================================================================== + +describe('resolveProjectPathById', () => { + // ----------------------------------------------------------------------- + // Null / undefined / empty input + // ----------------------------------------------------------------------- + describe('null/undefined/empty projectId', () => { + it('returns null for undefined projectId', () => { + expect(resolveProjectPathById(undefined, [CRYPTO_PROJECT], [])).toBeNull(); + }); + + it('returns null for null projectId', () => { + expect(resolveProjectPathById(null, [CRYPTO_PROJECT], [])).toBeNull(); + }); + + it('returns null for empty string projectId', () => { + expect(resolveProjectPathById('', [CRYPTO_PROJECT], [])).toBeNull(); + }); + }); + + // ----------------------------------------------------------------------- + // Lookup from projects (flat view mode) + // ----------------------------------------------------------------------- + describe('lookup from projects (flat mode)', () => { + it('finds project by exact id match', () => { + const result = resolveProjectPathById( + '-Users-belief-dev-projects-crypto-research', + [CRYPTO_PROJECT, CLAUDE_PROJECT], + [] + ); + expect(result).toEqual({ + path: '/Users/belief/dev/projects/crypto_research', + name: 'crypto_research', + }); + }); + + it('returns null when id not in projects', () => { + expect( + resolveProjectPathById('-Users-belief-dev-projects-unknown', [CRYPTO_PROJECT], []) + ).toBeNull(); + }); + + it('returns null when projects list is empty', () => { + expect( + resolveProjectPathById('-Users-belief-dev-projects-crypto-research', [], []) + ).toBeNull(); + }); + }); + + // ----------------------------------------------------------------------- + // Lookup from repositoryGroups (grouped view mode) + // ----------------------------------------------------------------------- + describe('lookup from repositoryGroups (grouped mode)', () => { + it('finds project in worktrees when projects is empty', () => { + const result = resolveProjectPathById( + '-Users-belief-dev-projects-crypto-research', + [], + [CRYPTO_REPO_GROUP] + ); + expect(result).toEqual({ + path: '/Users/belief/dev/projects/crypto_research', + name: '-Users-belief-dev-projects-crypto-research', + }); + }); + + it('finds project across multiple repo groups', () => { + const result = resolveProjectPathById( + '-Users-belief-dev-projects-claude-claude-team', + [], + [CRYPTO_REPO_GROUP, CLAUDE_REPO_GROUP] + ); + expect(result).toEqual({ + path: '/Users/belief/dev/projects/claude/claude_team', + name: '-Users-belief-dev-projects-claude-claude-team', + }); + }); + + it('finds correct worktree in multi-worktree group', () => { + const result = resolveProjectPathById( + '-Users-belief-dev-projects-app-wt-feature', + [], + [MULTI_WORKTREE_GROUP] + ); + expect(result).toEqual({ + path: '/Users/belief/dev/projects/app-wt-feature', + name: '-Users-belief-dev-projects-app-wt-feature', + }); + }); + + it('returns null when id not in any worktree', () => { + expect( + resolveProjectPathById('-Users-belief-dev-projects-unknown', [], [CRYPTO_REPO_GROUP]) + ).toBeNull(); + }); + }); + + // ----------------------------------------------------------------------- + // Priority: projects takes precedence over repositoryGroups + // ----------------------------------------------------------------------- + describe('priority order', () => { + it('prefers projects match over repositoryGroups match', () => { + const projectEntry: ProjectWithName = { + id: 'shared-id', + path: '/from/projects', + name: 'from-projects', + }; + + const repoGroupEntry = makeRepoGroup([ + { id: 'shared-id', path: '/from/repo-group' }, + ]); + + const result = resolveProjectPathById('shared-id', [projectEntry], [repoGroupEntry]); + expect(result).toEqual({ path: '/from/projects', name: 'from-projects' }); + }); + + it('falls back to repositoryGroups when projects has no match', () => { + const result = resolveProjectPathById( + '-Users-belief-dev-projects-crypto-research', + [CLAUDE_PROJECT], + [CRYPTO_REPO_GROUP] + ); + expect(result).toEqual({ + path: '/Users/belief/dev/projects/crypto_research', + name: '-Users-belief-dev-projects-crypto-research', + }); + }); + }); + + // ----------------------------------------------------------------------- + // Regression: Extensions tab with grouped view mode + // ----------------------------------------------------------------------- + describe('regression: Extensions tab skills in grouped view mode', () => { + it('resolves projectPath from id when only repositoryGroups is populated', () => { + // This is the exact scenario that caused skills not to show: + // viewMode=grouped → projects=[] but repositoryGroups has the data + // ExtensionStoreView used projects.find(p => p.id === tabProjectId) + // which returned null, so projectPath was null and no project skills loaded + const emptyProjects: ProjectWithName[] = []; + const populatedGroups: RepoGroupLike[] = [CRYPTO_REPO_GROUP, CLAUDE_REPO_GROUP]; + + const result = resolveProjectPathById( + '-Users-belief-dev-projects-crypto-research', + emptyProjects, + populatedGroups + ); + expect(result).not.toBeNull(); + expect(result!.path).toBe('/Users/belief/dev/projects/crypto_research'); + }); + }); +}); From 15b2b655ea92b0e630d2df4d264559308a170b2c Mon Sep 17 00:00:00 2001 From: Diego Serrano <129707357+diegoserranobst@users.noreply.github.com> Date: Tue, 14 Apr 2026 07:37:27 -0400 Subject: [PATCH 005/121] feat(chat): show project and user skills as slash command suggestions --- .../team/messages/MessageComposer.tsx | 25 ++++--- .../components/ui/MentionSuggestionList.tsx | 19 ++++- src/renderer/types/mention.ts | 6 +- src/renderer/utils/mentionSuggestions.ts | 4 +- src/renderer/utils/skillCommandSuggestions.ts | 48 +++++++++++++ src/shared/utils/slashCommands.ts | 5 ++ .../utils/skillCommandSuggestions.test.ts | 72 +++++++++++++++++++ test/shared/utils/slashCommands.test.ts | 8 +++ 8 files changed, 169 insertions(+), 18 deletions(-) create mode 100644 src/renderer/utils/skillCommandSuggestions.ts create mode 100644 test/renderer/utils/skillCommandSuggestions.test.ts diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index 7f79114c..7f5003ea 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -19,6 +19,7 @@ import { serializeChipsWithText } from '@renderer/types/inlineChip'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { nameColorSet } from '@renderer/utils/projectColor'; +import { buildSlashCommandSuggestions } from '@renderer/utils/skillCommandSuggestions'; import { extractTaskRefsFromText, stripEncodedTaskReferenceMetadata, @@ -208,17 +209,21 @@ export const MessageComposer = ({ const { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName); const { suggestions: taskSuggestions } = useTaskSuggestions(teamName); + // Project skills as slash command suggestions + const projectSkills = useStore( + useShallow((s) => (projectPath ? (s.skillsProjectCatalogByProjectPath[projectPath] ?? []) : [])) + ); + const userSkills = useStore(useShallow((s) => s.skillsUserCatalog)); + const fetchSkillsCatalog = useStore((s) => s.fetchSkillsCatalog); + + // Fetch skills catalog for the team's project on mount / project change + useEffect(() => { + void fetchSkillsCatalog(projectPath ?? undefined); + }, [fetchSkillsCatalog, projectPath]); + const slashCommandSuggestions = useMemo( - () => - KNOWN_SLASH_COMMANDS.map((command) => ({ - id: `command:${command.name}`, - name: command.name, - command: command.command, - description: command.description, - subtitle: command.description, - type: 'command', - })), - [] + () => buildSlashCommandSuggestions(KNOWN_SLASH_COMMANDS, projectSkills, userSkills), + [projectSkills, userSkills] ); const trimmed = stripEncodedTaskReferenceMetadata(draft.text).trim(); diff --git a/src/renderer/components/ui/MentionSuggestionList.tsx b/src/renderer/components/ui/MentionSuggestionList.tsx index 141c9460..e64d6341 100644 --- a/src/renderer/components/ui/MentionSuggestionList.tsx +++ b/src/renderer/components/ui/MentionSuggestionList.tsx @@ -83,11 +83,12 @@ export const MentionSuggestionList = ({ } // Categorize suggestions (folders are grouped with files) - type Section = 'member' | 'team' | 'task' | 'file' | 'command'; + type Section = 'member' | 'team' | 'task' | 'file' | 'command' | 'skill'; const getSuggestionSection = (s: MentionSuggestion): Section => { if (s.type === 'file' || s.type === 'folder') return 'file'; if (s.type === 'task') return 'task'; if (s.type === 'command') return 'command'; + if (s.type === 'skill') return 'skill'; if (s.type === 'team') return 'team'; return 'member'; }; @@ -98,6 +99,7 @@ export const MentionSuggestionList = ({ task: 'Tasks', file: 'Files', command: 'Commands', + skill: 'Skills', }; // Determine which sections are present @@ -117,6 +119,7 @@ export const MentionSuggestionList = ({ const isTeam = section === 'team'; const isTask = section === 'task'; const isCommand = section === 'command'; + const isSkill = section === 'skill'; const taskTeamColorSet = isTask && s.color ? getTeamColorSet(s.color) @@ -165,6 +168,8 @@ export const MentionSuggestionList = ({ ) : isCommand ? ( + ) : isSkill ? ( + ) : isTeam ? ( @@ -218,7 +231,7 @@ export const MentionSuggestionList = ({ {isTask && s.subtitle ? (
{s.subtitle}
) : null} - {isCommand && s.description ? ( + {(isCommand || isSkill) && s.description ? (
{s.description}
diff --git a/src/renderer/types/mention.ts b/src/renderer/types/mention.ts index a708abc8..f5150e30 100644 --- a/src/renderer/types/mention.ts +++ b/src/renderer/types/mention.ts @@ -9,8 +9,8 @@ export interface MentionSuggestion { description?: string; /** Color name from TeamColorSet palette */ color?: string; - /** Suggestion type — 'member' (default), 'team', 'file', 'folder', 'task', or 'command' */ - type?: 'member' | 'team' | 'file' | 'folder' | 'task' | 'command'; + /** Suggestion type — 'member' (default), 'team', 'file', 'folder', 'task', 'command', or 'skill' */ + type?: 'member' | 'team' | 'file' | 'folder' | 'task' | 'command' | 'skill'; /** Whether the team is currently online (team suggestions only) */ isOnline?: boolean; /** Absolute file/folder path (file/folder suggestions only) */ @@ -21,7 +21,7 @@ export interface MentionSuggestion { insertText?: string; /** Optional extra searchable text (subject, team name, path, etc.) */ searchText?: string; - /** Optional slash command string including leading slash (command suggestions only) */ + /** Optional slash command string including leading slash (command and skill suggestions only) */ command?: `/${string}`; /** Canonical task id (task suggestions only) */ taskId?: string; diff --git a/src/renderer/utils/mentionSuggestions.ts b/src/renderer/utils/mentionSuggestions.ts index a83e1bed..cc979085 100644 --- a/src/renderer/utils/mentionSuggestions.ts +++ b/src/renderer/utils/mentionSuggestions.ts @@ -2,12 +2,12 @@ import type { MentionSuggestion } from '@renderer/types/mention'; export function getSuggestionTriggerChar(suggestion: MentionSuggestion): '@' | '#' | '/' { if (suggestion.type === 'task') return '#'; - if (suggestion.type === 'command') return '/'; + if (suggestion.type === 'command' || suggestion.type === 'skill') return '/'; return '@'; } export function getSuggestionInsertionText(suggestion: MentionSuggestion): string { - if (suggestion.type === 'command') { + if (suggestion.type === 'command' || suggestion.type === 'skill') { return suggestion.command?.slice(1) ?? suggestion.insertText ?? suggestion.name; } return suggestion.insertText ?? suggestion.name; diff --git a/src/renderer/utils/skillCommandSuggestions.ts b/src/renderer/utils/skillCommandSuggestions.ts new file mode 100644 index 00000000..201cffea --- /dev/null +++ b/src/renderer/utils/skillCommandSuggestions.ts @@ -0,0 +1,48 @@ +import { getKnownSlashCommand, isSupportedSlashCommandName } from '@shared/utils/slashCommands'; + +import type { MentionSuggestion } from '@renderer/types/mention'; +import type { SkillCatalogItem } from '@shared/types/extensions'; +import type { KnownSlashCommandDefinition } from '@shared/utils/slashCommands'; + +export function buildSlashCommandSuggestions( + builtIns: readonly KnownSlashCommandDefinition[], + projectSkills: readonly SkillCatalogItem[], + userSkills: readonly SkillCatalogItem[] +): MentionSuggestion[] { + const builtInSuggestions: MentionSuggestion[] = builtIns.map((command) => ({ + id: `command:${command.name}`, + name: command.name, + command: command.command, + description: command.description, + subtitle: command.description, + type: 'command', + })); + + const seenSkillNames = new Set(); + const skillSuggestions: MentionSuggestion[] = []; + for (const skill of [...projectSkills, ...userSkills]) { + const normalizedFolderName = skill.folderName.trim().toLowerCase(); + if ( + !skill.isValid || + !normalizedFolderName || + !isSupportedSlashCommandName(normalizedFolderName) || + getKnownSlashCommand(normalizedFolderName) !== null || + seenSkillNames.has(normalizedFolderName) + ) { + continue; + } + + seenSkillNames.add(normalizedFolderName); + skillSuggestions.push({ + id: `skill:${skill.id}`, + name: skill.folderName, + command: `/${normalizedFolderName}`, + description: skill.description, + subtitle: skill.scope === 'project' ? 'Project skill' : 'Personal skill', + searchText: `${skill.name} ${skill.folderName}`, + type: 'skill', + }); + } + + return [...builtInSuggestions, ...skillSuggestions]; +} diff --git a/src/shared/utils/slashCommands.ts b/src/shared/utils/slashCommands.ts index 35e4f19b..e06dd174 100644 --- a/src/shared/utils/slashCommands.ts +++ b/src/shared/utils/slashCommands.ts @@ -15,6 +15,7 @@ export interface ParsedStandaloneSlashCommand { endIndex: number; } +const SLASH_COMMAND_NAME_PATTERN = /^[a-z][a-z0-9:-]{0,63}$/i; const STANDALONE_SLASH_COMMAND_PATTERN = /^\/([a-z][a-z0-9:-]{0,63})(?:\s+([\s\S]*\S))?$/i; export const KNOWN_SLASH_COMMANDS: readonly KnownSlashCommandDefinition[] = [ @@ -78,6 +79,10 @@ export function getKnownSlashCommand(name: string): KnownSlashCommandDefinition return KNOWN_SLASH_COMMANDS_BY_NAME.get(name.trim().toLowerCase()) ?? null; } +export function isSupportedSlashCommandName(name: string): boolean { + return SLASH_COMMAND_NAME_PATTERN.test(name.trim()); +} + export function buildSlashCommandMeta( name: string, args?: string, diff --git a/test/renderer/utils/skillCommandSuggestions.test.ts b/test/renderer/utils/skillCommandSuggestions.test.ts new file mode 100644 index 00000000..e7e2a1d2 --- /dev/null +++ b/test/renderer/utils/skillCommandSuggestions.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; + +import { buildSlashCommandSuggestions } from '@renderer/utils/skillCommandSuggestions'; +import { KNOWN_SLASH_COMMANDS } from '@shared/utils/slashCommands'; + +import type { SkillCatalogItem } from '@shared/types/extensions'; + +function createSkill(overrides: Partial): SkillCatalogItem { + return { + id: overrides.id ?? 'skill-id', + sourceType: 'filesystem', + name: overrides.name ?? 'Skill Name', + description: overrides.description ?? 'Skill description', + folderName: overrides.folderName ?? 'skill-name', + scope: overrides.scope ?? 'project', + rootKind: overrides.rootKind ?? 'claude', + projectRoot: overrides.projectRoot ?? '/tmp/project', + discoveryRoot: overrides.discoveryRoot ?? '/tmp/project/.claude/skills', + skillDir: overrides.skillDir ?? '/tmp/project/.claude/skills/skill-name', + skillFile: overrides.skillFile ?? '/tmp/project/.claude/skills/skill-name/SKILL.md', + metadata: overrides.metadata ?? {}, + invocationMode: overrides.invocationMode ?? 'manual-only', + flags: overrides.flags ?? { hasScripts: false, hasReferences: false, hasAssets: false }, + isValid: overrides.isValid ?? true, + issues: overrides.issues ?? [], + modifiedAt: overrides.modifiedAt ?? 0, + }; +} + +describe('buildSlashCommandSuggestions', () => { + it('keeps built-ins and adds valid skills in a separate suggestion type', () => { + const suggestions = buildSlashCommandSuggestions(KNOWN_SLASH_COMMANDS, [ + createSkill({ id: 'project-skill', folderName: 'review-skill', scope: 'project' }), + ], []); + + expect(suggestions[0]?.type).toBe('command'); + expect(suggestions.some((suggestion) => suggestion.type === 'skill')).toBe(true); + expect(suggestions.find((suggestion) => suggestion.id === 'skill:project-skill')).toMatchObject({ + name: 'review-skill', + command: '/review-skill', + subtitle: 'Project skill', + type: 'skill', + }); + }); + + it('filters slash-unsafe names and built-in collisions', () => { + const suggestions = buildSlashCommandSuggestions( + KNOWN_SLASH_COMMANDS, + [ + createSkill({ id: 'unsafe', folderName: 'bad skill' }), + createSkill({ id: 'collision', folderName: 'plan' }), + ], + [] + ); + + expect(suggestions.find((suggestion) => suggestion.id === 'skill:unsafe')).toBeUndefined(); + expect(suggestions.find((suggestion) => suggestion.id === 'skill:collision')).toBeUndefined(); + }); + + it('prefers project skills when user and project skills share the same slash name', () => { + const suggestions = buildSlashCommandSuggestions( + KNOWN_SLASH_COMMANDS, + [createSkill({ id: 'project', folderName: 'shared-skill', scope: 'project' })], + [createSkill({ id: 'user', folderName: 'shared-skill', scope: 'user' })] + ); + + expect(suggestions.filter((suggestion) => suggestion.command === '/shared-skill')).toHaveLength(1); + expect(suggestions.find((suggestion) => suggestion.command === '/shared-skill')?.id).toBe( + 'skill:project' + ); + }); +}); diff --git a/test/shared/utils/slashCommands.test.ts b/test/shared/utils/slashCommands.test.ts index 29a48857..e30492a9 100644 --- a/test/shared/utils/slashCommands.test.ts +++ b/test/shared/utils/slashCommands.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { getKnownSlashCommand, + isSupportedSlashCommandName, KNOWN_SLASH_COMMANDS, parseStandaloneSlashCommand, } from '@shared/utils/slashCommands'; @@ -53,4 +54,11 @@ describe('slashCommands', () => { expect(getKnownSlashCommand('MODEL')?.description).toContain('Claude model'); expect(getKnownSlashCommand('foo')).toBeNull(); }); + + it('validates slash-compatible command names', () => { + expect(isSupportedSlashCommandName('review')).toBe(true); + expect(isSupportedSlashCommandName('skill:name')).toBe(true); + expect(isSupportedSlashCommandName('my_skill')).toBe(false); + expect(isSupportedSlashCommandName('my skill')).toBe(false); + }); }); From 8b53f63e97fdff05bfd5e097bae480d8730abb04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D0=B8=D1=8F?= Date: Tue, 14 Apr 2026 14:56:29 +0300 Subject: [PATCH 006/121] fix(team): preserve provider model ids and codex slash suggestions --- .../team/dialogs/TeamModelSelector.tsx | 8 +- .../team/dialogs/launchDialogPrefill.ts | 14 +- .../team/messages/MessageComposer.tsx | 22 +++- src/renderer/store/slices/teamSlice.ts | 81 ++++++------ src/renderer/utils/providerSlashCommands.ts | 122 ++++++++++++++++++ src/renderer/utils/skillCommandSuggestions.ts | 5 +- src/renderer/utils/teamModelContext.ts | 32 +++++ .../components/team/TeamModelSelector.test.ts | 37 +++++- .../team/dialogs/launchDialogPrefill.test.ts | 29 ++++- .../utils/providerSlashCommands.test.ts | 27 ++++ .../utils/skillCommandSuggestions.test.ts | 50 +++++-- 11 files changed, 358 insertions(+), 69 deletions(-) create mode 100644 src/renderer/utils/providerSlashCommands.ts create mode 100644 src/renderer/utils/teamModelContext.ts create mode 100644 test/renderer/utils/providerSlashCommands.test.ts diff --git a/src/renderer/components/team/dialogs/TeamModelSelector.tsx b/src/renderer/components/team/dialogs/TeamModelSelector.tsx index c1aa208a..c0175dbf 100644 --- a/src/renderer/components/team/dialogs/TeamModelSelector.tsx +++ b/src/renderer/components/team/dialogs/TeamModelSelector.tsx @@ -26,6 +26,7 @@ import { normalizeTeamModelForUi, TEAM_MODEL_UI_DISABLED_BADGE_LABEL, } from '@renderer/utils/teamModelCatalog'; +import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext'; import { Info } from 'lucide-react'; export { getProviderScopedTeamModelLabel } from '@renderer/utils/teamModelCatalog'; @@ -99,8 +100,11 @@ export function computeEffectiveTeamModel( limitContext: boolean, providerId: 'anthropic' | 'codex' | 'gemini' = 'anthropic' ): string | undefined { - const base = selectedModel || undefined; - if (providerId !== 'anthropic') return base; + if (providerId !== 'anthropic') { + return selectedModel.trim() || undefined; + } + + const base = extractProviderScopedBaseModel(selectedModel, providerId); if (limitContext) return base; if (base === 'haiku') return base; return base ? `${base}[1m]` : 'opus[1m]'; diff --git a/src/renderer/components/team/dialogs/launchDialogPrefill.ts b/src/renderer/components/team/dialogs/launchDialogPrefill.ts index 874a3164..289beef9 100644 --- a/src/renderer/components/team/dialogs/launchDialogPrefill.ts +++ b/src/renderer/components/team/dialogs/launchDialogPrefill.ts @@ -1,5 +1,6 @@ import { normalizeCreateLaunchProviderForUi } from '@renderer/utils/geminiUiFreeze'; import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability'; +import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext'; import { isLeadMember } from '@shared/utils/leadDetection'; import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; @@ -27,12 +28,15 @@ interface LaunchDialogPrefillResult { effort: string; } -function normalizeModelCandidate(model: string | undefined): string { +function normalizeModelCandidate( + model: string | undefined, + providerId: TeamProviderId | undefined +): string { const trimmed = model?.trim() ?? ''; if (!trimmed || trimmed === 'default' || trimmed === '__default__') { return ''; } - return trimmed; + return extractProviderScopedBaseModel(trimmed, providerId) ?? ''; } function canReuseModelForSelectedProvider( @@ -69,15 +73,15 @@ export function resolveLaunchDialogPrefill({ const modelCandidates = [ { providerId: currentLeadProviderId, - model: normalizeModelCandidate(currentLead?.model), + model: normalizeModelCandidate(currentLead?.model, currentLeadProviderId), }, { providerId: savedRequestProviderId, - model: normalizeModelCandidate(savedRequest?.model), + model: normalizeModelCandidate(savedRequest?.model, savedRequestProviderId), }, { providerId: previousLaunchProviderId, - model: normalizeModelCandidate(previousLaunchParams?.model), + model: normalizeModelCandidate(previousLaunchParams?.model, previousLaunchProviderId), }, ]; diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index 7f5003ea..eff3ceea 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -19,6 +19,7 @@ import { serializeChipsWithText } from '@renderer/types/inlineChip'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { nameColorSet } from '@renderer/utils/projectColor'; +import { getSuggestedSlashCommandsForProvider } from '@renderer/utils/providerSlashCommands'; import { buildSlashCommandSuggestions } from '@renderer/utils/skillCommandSuggestions'; import { extractTaskRefsFromText, @@ -26,7 +27,11 @@ import { } from '@renderer/utils/taskReferenceUtils'; import { MAX_TEXT_LENGTH } from '@shared/constants'; import { isLeadMember } from '@shared/utils/leadDetection'; -import { KNOWN_SLASH_COMMANDS, parseStandaloneSlashCommand } from '@shared/utils/slashCommands'; +import { parseStandaloneSlashCommand } from '@shared/utils/slashCommands'; +import { + inferTeamProviderIdFromModel, + normalizeOptionalTeamProviderId, +} from '@shared/utils/teamProvider'; import { AlertCircle, Check, ChevronDown, Mic, Paperclip, Search, Send } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; @@ -206,6 +211,12 @@ export const MessageComposer = ({ })), [members, colorMap] ); + const leadProviderId = useMemo(() => { + const lead = members.find((member) => isLeadMember(member)); + return ( + normalizeOptionalTeamProviderId(lead?.providerId) ?? inferTeamProviderIdFromModel(lead?.model) + ); + }, [members]); const { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName); const { suggestions: taskSuggestions } = useTaskSuggestions(teamName); @@ -222,8 +233,13 @@ export const MessageComposer = ({ }, [fetchSkillsCatalog, projectPath]); const slashCommandSuggestions = useMemo( - () => buildSlashCommandSuggestions(KNOWN_SLASH_COMMANDS, projectSkills, userSkills), - [projectSkills, userSkills] + () => + buildSlashCommandSuggestions( + getSuggestedSlashCommandsForProvider(leadProviderId), + projectSkills, + userSkills + ), + [leadProviderId, projectSkills, userSkills] ); const trimmed = stripEncodedTaskReferenceMetadata(draft.text).trim(); diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index d52244ea..91dc5d1e 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -6,15 +6,51 @@ import { canDisplayTaskChangesForOptions, type TaskChangeRequestOptions, } from '@renderer/utils/taskChangeRequest'; +import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext'; import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; +import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team'; import { createLogger } from '@shared/utils/logger'; import { getTaskKanbanColumn } from '@shared/utils/reviewState'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { getWorktreeNavigationState } from '../utils/stateResetHelpers'; +import type { AppState } from '../types'; +import type { AppConfig } from '@renderer/types/data'; import type { TeamMessagesPanelMode } from '@renderer/types/teamMessagesPanelMode'; +import type { + ActiveToolCall, + AddMemberRequest, + AddTaskCommentRequest, + CreateTaskRequest, + CrossTeamSendRequest, + EffortLevel, + GlobalTask, + InboxMessage, + KanbanColumnId, + LeadActivityState, + LeadContextUsage, + MemberSpawnStatusEntry, + MemberSpawnStatusesSnapshot, + PersistedTeamLaunchSummary, + SendMessageRequest, + SendMessageResult, + TaskChangePresenceState, + TaskComment, + TeamCreateRequest, + TeamData, + TeamLaunchRequest, + TeamProviderId, + TeamProvisioningProgress, + TeamSummary, + TeamTask, + TeamTaskStatus, + ToolApprovalRequest, + ToolApprovalSettings, + UpdateKanbanPatch, +} from '@shared/types'; +import type { StateCreator } from 'zustand'; const logger = createLogger('teamSlice'); @@ -483,42 +519,6 @@ async function pollProvisioningStatus( } } -import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team'; - -import type { AppState } from '../types'; -import type { AppConfig } from '@renderer/types/data'; -import type { - ActiveToolCall, - AddMemberRequest, - AddTaskCommentRequest, - CreateTaskRequest, - CrossTeamSendRequest, - EffortLevel, - GlobalTask, - InboxMessage, - KanbanColumnId, - LeadActivityState, - LeadContextUsage, - MemberSpawnStatusEntry, - MemberSpawnStatusesSnapshot, - PersistedTeamLaunchSummary, - SendMessageRequest, - SendMessageResult, - TaskChangePresenceState, - TaskComment, - TeamCreateRequest, - TeamData, - TeamLaunchRequest, - TeamProvisioningProgress, - TeamSummary, - TeamTask, - TeamTaskStatus, - ToolApprovalRequest, - ToolApprovalSettings, - UpdateKanbanPatch, -} from '@shared/types'; -import type { StateCreator } from 'zustand'; - // --- Clarification notification tracking --- // Native OS notifications for new inbox messages are handled in main process // (main/index.ts → notifyNewInboxMessages). This renderer-side tracking only @@ -1219,9 +1219,8 @@ function saveLaunchParams(teamName: string, params: TeamLaunchParams): void { * Extract the base model name from the raw model string sent to CLI. * E.g. 'opus[1m]' → 'opus', 'sonnet' → 'sonnet', undefined → undefined. */ -function extractBaseModel(raw?: string): string | undefined { - if (!raw) return undefined; - return raw.replace(/\[1m\]$/, '') || undefined; +function extractBaseModel(raw?: string, providerId?: TeamProviderId): string | undefined { + return extractProviderScopedBaseModel(raw, providerId); } const TOOL_APPROVAL_PREFIX = 'team:toolApprovalSettings:'; @@ -2587,7 +2586,7 @@ export const createTeamSlice: StateCreator = (set, const response = await unwrapIpc('team:create', () => api.teams.createTeam(request)); // Persist per-team launch params (model, effort, limit context) - const baseModel = extractBaseModel(request.model); + const baseModel = extractBaseModel(request.model, request.providerId); const params: TeamLaunchParams = { providerId: request.providerId ?? 'anthropic', model: baseModel || 'default', @@ -2767,7 +2766,7 @@ export const createTeamSlice: StateCreator = (set, const response = await unwrapIpc('team:launch', () => api.teams.launchTeam(request)); // Persist per-team launch params (model, effort, limit context) - const baseModel = extractBaseModel(request.model); + const baseModel = extractBaseModel(request.model, request.providerId); const params: TeamLaunchParams = { providerId: request.providerId ?? 'anthropic', model: baseModel || 'default', diff --git a/src/renderer/utils/providerSlashCommands.ts b/src/renderer/utils/providerSlashCommands.ts new file mode 100644 index 00000000..d7cc66af --- /dev/null +++ b/src/renderer/utils/providerSlashCommands.ts @@ -0,0 +1,122 @@ +import { KNOWN_SLASH_COMMANDS } from '@shared/utils/slashCommands'; + +import type { TeamProviderId } from '@shared/types'; +import type { KnownSlashCommandDefinition } from '@shared/utils/slashCommands'; + +const CODEX_SLASH_COMMAND_SUGGESTIONS: readonly KnownSlashCommandDefinition[] = [ + { + name: 'model', + command: '/model', + description: 'Choose the active model for this session.', + }, + { + name: 'fast', + command: '/fast', + description: 'Toggle Fast mode on or off.', + }, + { + name: 'permissions', + command: '/permissions', + description: 'Adjust approval requirements for tools and commands.', + }, + { + name: 'plan', + command: '/plan', + description: 'Switch to plan mode with an optional prompt.', + }, + { + name: 'review', + command: '/review', + description: 'Ask Codex to review the current working tree.', + }, + { + name: 'diff', + command: '/diff', + description: 'Show the current Git diff, including untracked files.', + }, + { + name: 'status', + command: '/status', + description: 'Show session configuration and token usage.', + }, + { + name: 'mcp', + command: '/mcp', + description: 'List configured MCP tools for this session.', + }, + { + name: 'mention', + command: '/mention', + description: 'Attach a file or folder to the conversation.', + }, + { + name: 'apps', + command: '/apps', + description: 'Browse available apps and connectors.', + }, + { + name: 'plugins', + command: '/plugins', + description: 'Browse and manage installed plugins.', + }, + { + name: 'agent', + command: '/agent', + description: 'Switch to another agent thread.', + }, + { + name: 'personality', + command: '/personality', + description: 'Change Codex response style for the current thread.', + }, + { + name: 'compact', + command: '/compact', + description: 'Summarize the conversation to free tokens.', + }, + { + name: 'clear', + command: '/clear', + description: 'Clear the terminal and start a fresh chat.', + }, + { + name: 'new', + command: '/new', + description: 'Start a new conversation in the current session.', + }, + { + name: 'copy', + command: '/copy', + description: 'Copy the latest completed Codex output.', + }, + { + name: 'fork', + command: '/fork', + description: 'Fork the current conversation into a new thread.', + }, + { + name: 'resume', + command: '/resume', + description: 'Resume a previous conversation.', + }, + { + name: 'quit', + command: '/quit', + description: 'Exit the CLI.', + }, + { + name: 'exit', + command: '/exit', + description: 'Exit the CLI.', + }, +] as const; + +export function getSuggestedSlashCommandsForProvider( + providerId?: TeamProviderId +): readonly KnownSlashCommandDefinition[] { + if (providerId === 'codex') { + return CODEX_SLASH_COMMAND_SUGGESTIONS; + } + + return KNOWN_SLASH_COMMANDS; +} diff --git a/src/renderer/utils/skillCommandSuggestions.ts b/src/renderer/utils/skillCommandSuggestions.ts index 201cffea..370df4e9 100644 --- a/src/renderer/utils/skillCommandSuggestions.ts +++ b/src/renderer/utils/skillCommandSuggestions.ts @@ -1,4 +1,4 @@ -import { getKnownSlashCommand, isSupportedSlashCommandName } from '@shared/utils/slashCommands'; +import { isSupportedSlashCommandName } from '@shared/utils/slashCommands'; import type { MentionSuggestion } from '@renderer/types/mention'; import type { SkillCatalogItem } from '@shared/types/extensions'; @@ -9,6 +9,7 @@ export function buildSlashCommandSuggestions( projectSkills: readonly SkillCatalogItem[], userSkills: readonly SkillCatalogItem[] ): MentionSuggestion[] { + const builtInNames = new Set(builtIns.map((command) => command.name.trim().toLowerCase())); const builtInSuggestions: MentionSuggestion[] = builtIns.map((command) => ({ id: `command:${command.name}`, name: command.name, @@ -26,7 +27,7 @@ export function buildSlashCommandSuggestions( !skill.isValid || !normalizedFolderName || !isSupportedSlashCommandName(normalizedFolderName) || - getKnownSlashCommand(normalizedFolderName) !== null || + builtInNames.has(normalizedFolderName) || seenSkillNames.has(normalizedFolderName) ) { continue; diff --git a/src/renderer/utils/teamModelContext.ts b/src/renderer/utils/teamModelContext.ts new file mode 100644 index 00000000..706dc691 --- /dev/null +++ b/src/renderer/utils/teamModelContext.ts @@ -0,0 +1,32 @@ +import { inferTeamProviderIdFromModel } from '@shared/utils/teamProvider'; + +import type { TeamProviderId } from '@shared/types'; + +export function stripTrailingOneMillionSuffixes(model: string | undefined): string | undefined { + const trimmed = model?.trim(); + if (!trimmed) { + return undefined; + } + + return trimmed.replace(/(?:\[1m\])+$/, '') || undefined; +} + +export function extractProviderScopedBaseModel( + model: string | undefined, + providerId?: TeamProviderId +): string | undefined { + const trimmed = model?.trim(); + if (!trimmed) { + return undefined; + } + + const effectiveProviderId = + providerId ?? + inferTeamProviderIdFromModel(trimmed) ?? + inferTeamProviderIdFromModel(stripTrailingOneMillionSuffixes(trimmed)); + if (effectiveProviderId !== 'anthropic') { + return trimmed; + } + + return stripTrailingOneMillionSuffixes(trimmed); +} diff --git a/test/renderer/components/team/TeamModelSelector.test.ts b/test/renderer/components/team/TeamModelSelector.test.ts index 87ae7485..f441903a 100644 --- a/test/renderer/components/team/TeamModelSelector.test.ts +++ b/test/renderer/components/team/TeamModelSelector.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { formatTeamModelSummary } from '@renderer/components/team/dialogs/TeamModelSelector'; +import { + computeEffectiveTeamModel, + formatTeamModelSummary, +} from '@renderer/components/team/dialogs/TeamModelSelector'; import { GPT_5_1_CODEX_MINI_UI_DISABLED_REASON, GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON, @@ -36,3 +39,35 @@ describe('formatTeamModelSummary', () => { expect(normalizeTeamModelForUi('codex', 'gpt-5.4-mini')).toBe('gpt-5.4-mini'); }); }); + +describe('computeEffectiveTeamModel', () => { + it('appends [1m] for anthropic models', () => { + expect(computeEffectiveTeamModel('opus', false, 'anthropic')).toBe('opus[1m]'); + expect(computeEffectiveTeamModel('sonnet', false, 'anthropic')).toBe('sonnet[1m]'); + }); + + it('does not double-append [1m] when input already has it', () => { + expect(computeEffectiveTeamModel('opus[1m]', false, 'anthropic')).toBe('opus[1m]'); + expect(computeEffectiveTeamModel('sonnet[1m]', false, 'anthropic')).toBe('sonnet[1m]'); + expect(computeEffectiveTeamModel('opus[1m][1m]', false, 'anthropic')).toBe('opus[1m]'); + }); + + it('defaults to opus[1m] when no model selected', () => { + expect(computeEffectiveTeamModel('', false, 'anthropic')).toBe('opus[1m]'); + }); + + it('returns base model without [1m] when limitContext is true', () => { + expect(computeEffectiveTeamModel('opus', true, 'anthropic')).toBe('opus'); + expect(computeEffectiveTeamModel('opus[1m]', true, 'anthropic')).toBe('opus'); + expect(computeEffectiveTeamModel('opus[1m][1m]', true, 'anthropic')).toBe('opus'); + }); + + it('returns haiku as-is', () => { + expect(computeEffectiveTeamModel('haiku', false, 'anthropic')).toBe('haiku'); + }); + + it('returns non-anthropic models as-is', () => { + expect(computeEffectiveTeamModel('gpt-5.4', false, 'codex')).toBe('gpt-5.4'); + expect(computeEffectiveTeamModel('custom-model[1m]', false, 'codex')).toBe('custom-model[1m]'); + }); +}); diff --git a/test/renderer/components/team/dialogs/launchDialogPrefill.test.ts b/test/renderer/components/team/dialogs/launchDialogPrefill.test.ts index 2b1d4d58..2fdf52fc 100644 --- a/test/renderer/components/team/dialogs/launchDialogPrefill.test.ts +++ b/test/renderer/components/team/dialogs/launchDialogPrefill.test.ts @@ -64,7 +64,7 @@ describe('resolveLaunchDialogPrefill', () => { const savedRequest = { teamName: 'vector-room-2', - cwd: '/tmp/project', + cwd: '/Users/test/project', providerId: 'anthropic', model: 'haiku', effort: 'low', @@ -146,4 +146,31 @@ describe('resolveLaunchDialogPrefill', () => { effort: 'medium', }); }); + + it('preserves literal [1m] suffixes for non-anthropic providers', () => { + const result = resolveLaunchDialogPrefill({ + members: [], + savedRequest: null, + previousLaunchParams: { + providerId: 'codex', + model: 'custom-model[1m]', + effort: 'medium', + }, + multimodelEnabled: true, + storedProviderId: 'anthropic', + storedEffort: 'medium', + storedLimitContext: false, + getStoredModel: createStoredModelGetter({ + anthropic: 'haiku', + codex: 'gpt-5.4', + }), + }); + + expect(result).toEqual({ + providerId: 'codex', + model: 'custom-model[1m]', + effort: 'medium', + limitContext: false, + }); + }); }); diff --git a/test/renderer/utils/providerSlashCommands.test.ts b/test/renderer/utils/providerSlashCommands.test.ts new file mode 100644 index 00000000..0934de33 --- /dev/null +++ b/test/renderer/utils/providerSlashCommands.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; + +import { getSuggestedSlashCommandsForProvider } from '@renderer/utils/providerSlashCommands'; + +describe('getSuggestedSlashCommandsForProvider', () => { + it('returns Codex-specific command suggestions without Anthropic-only entries', () => { + const commands = getSuggestedSlashCommandsForProvider('codex').map( + (command) => command.command + ); + + expect(commands).toContain('/permissions'); + expect(commands).toContain('/agent'); + expect(commands).toContain('/review'); + expect(commands).not.toContain('/effort'); + expect(commands).not.toContain('/usage'); + }); + + it('falls back to the default curated list for Anthropic-like providers', () => { + const commands = getSuggestedSlashCommandsForProvider('anthropic').map( + (command) => command.command + ); + + expect(commands).toContain('/effort'); + expect(commands).toContain('/usage'); + expect(commands).not.toContain('/permissions'); + }); +}); diff --git a/test/renderer/utils/skillCommandSuggestions.test.ts b/test/renderer/utils/skillCommandSuggestions.test.ts index e7e2a1d2..528a2f2d 100644 --- a/test/renderer/utils/skillCommandSuggestions.test.ts +++ b/test/renderer/utils/skillCommandSuggestions.test.ts @@ -14,10 +14,10 @@ function createSkill(overrides: Partial): SkillCatalogItem { folderName: overrides.folderName ?? 'skill-name', scope: overrides.scope ?? 'project', rootKind: overrides.rootKind ?? 'claude', - projectRoot: overrides.projectRoot ?? '/tmp/project', - discoveryRoot: overrides.discoveryRoot ?? '/tmp/project/.claude/skills', - skillDir: overrides.skillDir ?? '/tmp/project/.claude/skills/skill-name', - skillFile: overrides.skillFile ?? '/tmp/project/.claude/skills/skill-name/SKILL.md', + projectRoot: overrides.projectRoot ?? '/Users/test/project', + discoveryRoot: overrides.discoveryRoot ?? '/Users/test/project/.claude/skills', + skillDir: overrides.skillDir ?? '/Users/test/project/.claude/skills/skill-name', + skillFile: overrides.skillFile ?? '/Users/test/project/.claude/skills/skill-name/SKILL.md', metadata: overrides.metadata ?? {}, invocationMode: overrides.invocationMode ?? 'manual-only', flags: overrides.flags ?? { hasScripts: false, hasReferences: false, hasAssets: false }, @@ -29,18 +29,22 @@ function createSkill(overrides: Partial): SkillCatalogItem { describe('buildSlashCommandSuggestions', () => { it('keeps built-ins and adds valid skills in a separate suggestion type', () => { - const suggestions = buildSlashCommandSuggestions(KNOWN_SLASH_COMMANDS, [ - createSkill({ id: 'project-skill', folderName: 'review-skill', scope: 'project' }), - ], []); + const suggestions = buildSlashCommandSuggestions( + KNOWN_SLASH_COMMANDS, + [createSkill({ id: 'project-skill', folderName: 'review-skill', scope: 'project' })], + [] + ); expect(suggestions[0]?.type).toBe('command'); expect(suggestions.some((suggestion) => suggestion.type === 'skill')).toBe(true); - expect(suggestions.find((suggestion) => suggestion.id === 'skill:project-skill')).toMatchObject({ - name: 'review-skill', - command: '/review-skill', - subtitle: 'Project skill', - type: 'skill', - }); + expect(suggestions.find((suggestion) => suggestion.id === 'skill:project-skill')).toMatchObject( + { + name: 'review-skill', + command: '/review-skill', + subtitle: 'Project skill', + type: 'skill', + } + ); }); it('filters slash-unsafe names and built-in collisions', () => { @@ -64,9 +68,27 @@ describe('buildSlashCommandSuggestions', () => { [createSkill({ id: 'user', folderName: 'shared-skill', scope: 'user' })] ); - expect(suggestions.filter((suggestion) => suggestion.command === '/shared-skill')).toHaveLength(1); + expect(suggestions.filter((suggestion) => suggestion.command === '/shared-skill')).toHaveLength( + 1 + ); expect(suggestions.find((suggestion) => suggestion.command === '/shared-skill')?.id).toBe( 'skill:project' ); }); + + it('uses the provided built-in set when filtering skill collisions', () => { + const suggestions = buildSlashCommandSuggestions( + [ + { + name: 'custom-cmd', + command: '/custom-cmd', + description: 'Custom command', + }, + ], + [createSkill({ id: 'collision', folderName: 'custom-cmd' })], + [] + ); + + expect(suggestions.find((suggestion) => suggestion.id === 'skill:collision')).toBeUndefined(); + }); }); From f0f43be0643cafa95b8c0877d05706bcc7361b3c Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 14 Apr 2026 16:52:44 +0300 Subject: [PATCH 007/121] fix: stabilize codex recent project selection --- .../ListDashboardRecentProjectsUseCase.ts | 15 +++++ .../CodexRecentProjectsSourceAdapter.ts | 8 +-- .../codex/CodexBinaryResolver.ts | 57 +++++++++++++++++-- .../sidebar/DateGroupedSessions.tsx | 31 ++++++++-- 4 files changed, 98 insertions(+), 13 deletions(-) diff --git a/src/features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase.ts b/src/features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase.ts index 15c4295a..7abf05db 100644 --- a/src/features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase.ts +++ b/src/features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase.ts @@ -29,6 +29,7 @@ export interface ListDashboardRecentProjectsDeps { 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; @@ -41,6 +42,20 @@ export class ListDashboardRecentProjectsUseCase { 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 results = await Promise.all( this.deps.sources.map((source, index) => this.#loadSource(source, index)) diff --git a/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts b/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts index 4430b8bb..b98ebc7b 100644 --- a/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts +++ b/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts @@ -12,10 +12,10 @@ import type { RecentProjectIdentityResolver } from '@features/recent-projects/ma import type { ServiceContext } from '@main/services'; const CODEX_THREAD_LIMIT = 40; -const CODEX_LIVE_FETCH_TIMEOUT_MS = 1_200; -const CODEX_ARCHIVED_FETCH_TIMEOUT_MS = 1_800; -const CODEX_REQUEST_TIMEOUT_MS = 1_800; -const CODEX_SOURCE_TIMEOUT_MS = 1_500; +const CODEX_LIVE_FETCH_TIMEOUT_MS = 4_500; +const CODEX_ARCHIVED_FETCH_TIMEOUT_MS = 2_500; +const CODEX_REQUEST_TIMEOUT_MS = 4_500; +const CODEX_SOURCE_TIMEOUT_MS = 5_200; const FAST_ARCHIVED_MERGE_TIMEOUT_MS = 150; function isInteractiveSource(source: unknown): boolean { diff --git a/src/features/recent-projects/main/infrastructure/codex/CodexBinaryResolver.ts b/src/features/recent-projects/main/infrastructure/codex/CodexBinaryResolver.ts index 7239af90..7b7184bf 100644 --- a/src/features/recent-projects/main/infrastructure/codex/CodexBinaryResolver.ts +++ b/src/features/recent-projects/main/infrastructure/codex/CodexBinaryResolver.ts @@ -1,4 +1,6 @@ -import { execCli } from '@main/utils/childProcess'; +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; @@ -6,13 +8,60 @@ let cachedBinaryPath: string | null | undefined; let cacheVerifiedAt = 0; let resolveInFlight: Promise | null = null; -async function verifyBinary(candidate: string): Promise { +async function fileExists(filePath: string): Promise { try { - await execCli(candidate, ['--version'], { timeout: 2_000, windowsHide: true }); - return candidate; + 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 { diff --git a/src/renderer/components/sidebar/DateGroupedSessions.tsx b/src/renderer/components/sidebar/DateGroupedSessions.tsx index 22f8d572..f55b1f24 100644 --- a/src/renderer/components/sidebar/DateGroupedSessions.tsx +++ b/src/renderer/components/sidebar/DateGroupedSessions.tsx @@ -325,7 +325,28 @@ export const DateGroupedSessions = (): React.JSX.Element => { }); }, [viewMode, repositoryGroups, projects]); - const activeProjectValue = viewMode === 'grouped' ? selectedRepositoryId : activeProjectId; + const effectiveSelectedWorktreeId = + selectedWorktreeId ?? activeProjectId ?? selectedProjectId ?? null; + const effectiveSelectedRepositoryId = useMemo(() => { + if (selectedRepositoryId) { + return selectedRepositoryId; + } + + if (!effectiveSelectedWorktreeId) { + return null; + } + + return ( + repositoryGroups.find((repo) => + repo.worktrees.some((worktree) => worktree.id === effectiveSelectedWorktreeId) + )?.id ?? null + ); + }, [effectiveSelectedWorktreeId, repositoryGroups, selectedRepositoryId]); + + const activeProjectValue = + viewMode === 'grouped' + ? effectiveSelectedRepositoryId + : (activeProjectId ?? selectedProjectId ?? null); const handleProjectValueChange = (id: string): void => { if (viewMode === 'grouped') selectRepository(id); @@ -333,8 +354,8 @@ export const DateGroupedSessions = (): React.JSX.Element => { }; // Worktree state - const activeRepo = repositoryGroups.find((r) => r.id === selectedRepositoryId); - const activeWorktree = activeRepo?.worktrees.find((w) => w.id === selectedWorktreeId); + const activeRepo = repositoryGroups.find((r) => r.id === effectiveSelectedRepositoryId); + const activeWorktree = activeRepo?.worktrees.find((w) => w.id === effectiveSelectedWorktreeId); const worktrees = (activeRepo?.worktrees ?? []).filter( (w) => (w.totalSessions ?? w.sessions.length) > 0 ); @@ -684,7 +705,7 @@ export const DateGroupedSessions = (): React.JSX.Element => { {mainWorktree && ( handleSelectWorktree(mainWorktree)} /> )} @@ -705,7 +726,7 @@ export const DateGroupedSessions = (): React.JSX.Element => { handleSelectWorktree(worktree)} /> ))} From 0dfd2fc610495d72538aac80942ee3075e816480 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 14 Apr 2026 16:58:00 +0300 Subject: [PATCH 008/121] fix: sync session project selector state --- .../sidebar/DateGroupedSessions.tsx | 25 ++++----- .../sidebar/dateGroupedSessionsSelection.ts | 27 ++++++++++ .../dateGroupedSessionsSelection.test.ts | 53 +++++++++++++++++++ 3 files changed, 90 insertions(+), 15 deletions(-) create mode 100644 src/renderer/components/sidebar/dateGroupedSessionsSelection.ts create mode 100644 test/renderer/components/sidebar/dateGroupedSessionsSelection.test.ts diff --git a/src/renderer/components/sidebar/DateGroupedSessions.tsx b/src/renderer/components/sidebar/DateGroupedSessions.tsx index f55b1f24..b2d99211 100644 --- a/src/renderer/components/sidebar/DateGroupedSessions.tsx +++ b/src/renderer/components/sidebar/DateGroupedSessions.tsx @@ -38,6 +38,7 @@ import { useShallow } from 'zustand/react/shallow'; import { WorktreeBadge } from '../common/WorktreeBadge'; import { Combobox, type ComboboxOption } from '../ui/combobox'; +import { resolveEffectiveSelectedRepositoryId } from './dateGroupedSessionsSelection'; import { SESSION_PROVIDER_IDS, SessionFiltersPopover } from './SessionFiltersPopover'; import { SessionItem } from './SessionItem'; @@ -327,21 +328,15 @@ export const DateGroupedSessions = (): React.JSX.Element => { const effectiveSelectedWorktreeId = selectedWorktreeId ?? activeProjectId ?? selectedProjectId ?? null; - const effectiveSelectedRepositoryId = useMemo(() => { - if (selectedRepositoryId) { - return selectedRepositoryId; - } - - if (!effectiveSelectedWorktreeId) { - return null; - } - - return ( - repositoryGroups.find((repo) => - repo.worktrees.some((worktree) => worktree.id === effectiveSelectedWorktreeId) - )?.id ?? null - ); - }, [effectiveSelectedWorktreeId, repositoryGroups, selectedRepositoryId]); + const effectiveSelectedRepositoryId = useMemo( + () => + resolveEffectiveSelectedRepositoryId({ + repositoryGroups, + selectedRepositoryId, + effectiveSelectedWorktreeId, + }), + [effectiveSelectedWorktreeId, repositoryGroups, selectedRepositoryId] + ); const activeProjectValue = viewMode === 'grouped' diff --git a/src/renderer/components/sidebar/dateGroupedSessionsSelection.ts b/src/renderer/components/sidebar/dateGroupedSessionsSelection.ts new file mode 100644 index 00000000..4c0e806d --- /dev/null +++ b/src/renderer/components/sidebar/dateGroupedSessionsSelection.ts @@ -0,0 +1,27 @@ +import type { RepositoryGroup } from '@renderer/types/data'; + +interface ResolveEffectiveSelectedRepositoryIdInput { + repositoryGroups: readonly RepositoryGroup[]; + selectedRepositoryId: string | null; + effectiveSelectedWorktreeId: string | null; +} + +export function resolveEffectiveSelectedRepositoryId({ + repositoryGroups, + selectedRepositoryId, + effectiveSelectedWorktreeId, +}: ResolveEffectiveSelectedRepositoryIdInput): string | null { + if (selectedRepositoryId) { + return selectedRepositoryId; + } + + if (!effectiveSelectedWorktreeId) { + return null; + } + + return ( + repositoryGroups.find((repo) => + repo.worktrees.some((worktree) => worktree.id === effectiveSelectedWorktreeId) + )?.id ?? null + ); +} diff --git a/test/renderer/components/sidebar/dateGroupedSessionsSelection.test.ts b/test/renderer/components/sidebar/dateGroupedSessionsSelection.test.ts new file mode 100644 index 00000000..63fda15d --- /dev/null +++ b/test/renderer/components/sidebar/dateGroupedSessionsSelection.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveEffectiveSelectedRepositoryId } from '../../../../src/renderer/components/sidebar/dateGroupedSessionsSelection'; + +describe('resolveEffectiveSelectedRepositoryId', () => { + it('falls back to the repository that owns the active worktree when repository selection is empty', () => { + const repositoryGroups = [ + { + id: 'repo-headless', + worktrees: [ + { + id: 'worktree-headless', + path: '/Users/belief/dev/projects/headless', + }, + ], + }, + { + id: 'repo-other', + worktrees: [ + { + id: 'worktree-other', + path: '/Users/belief/dev/projects/other', + }, + ], + }, + ] as const; + + expect( + resolveEffectiveSelectedRepositoryId({ + repositoryGroups, + selectedRepositoryId: null, + effectiveSelectedWorktreeId: 'worktree-headless', + }) + ).toBe('repo-headless'); + }); + + it('keeps the explicit repository selection when it already exists', () => { + const repositoryGroups = [ + { + id: 'repo-headless', + worktrees: [{ id: 'worktree-headless', path: '/Users/belief/dev/projects/headless' }], + }, + ] as const; + + expect( + resolveEffectiveSelectedRepositoryId({ + repositoryGroups, + selectedRepositoryId: 'repo-headless', + effectiveSelectedWorktreeId: 'worktree-headless', + }) + ).toBe('repo-headless'); + }); +}); From 36a79f258638f7cd91fe34473a48f57748c88ae1 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 14 Apr 2026 17:10:23 +0300 Subject: [PATCH 009/121] fix: keep selected project visible in session selector --- .../sidebar/DateGroupedSessions.tsx | 47 +++++++++++-------- 1 file changed, 27 insertions(+), 20 deletions(-) diff --git a/src/renderer/components/sidebar/DateGroupedSessions.tsx b/src/renderer/components/sidebar/DateGroupedSessions.tsx index b2d99211..082afb0b 100644 --- a/src/renderer/components/sidebar/DateGroupedSessions.tsx +++ b/src/renderer/components/sidebar/DateGroupedSessions.tsx @@ -301,12 +301,36 @@ export const DateGroupedSessions = (): React.JSX.Element => { fetchProjects, ]); + const effectiveSelectedWorktreeId = + selectedWorktreeId ?? activeProjectId ?? selectedProjectId ?? null; + const effectiveSelectedRepositoryId = useMemo( + () => + resolveEffectiveSelectedRepositoryId({ + repositoryGroups, + selectedRepositoryId, + effectiveSelectedWorktreeId, + }), + [effectiveSelectedWorktreeId, repositoryGroups, selectedRepositoryId] + ); + + const activeProjectValue = + viewMode === 'grouped' + ? effectiveSelectedRepositoryId + : (activeProjectId ?? selectedProjectId ?? null); + // Project combobox options const projectComboboxOptions = useMemo((): ComboboxOption[] => { const items = viewMode === 'grouped' - ? repositoryGroups.filter((r) => r.totalSessions > 0) - : projects.filter((p) => (p.totalSessions ?? p.sessions.length) > 0); + ? repositoryGroups.filter( + (repo) => repo.totalSessions > 0 || repo.id === effectiveSelectedRepositoryId + ) + : projects.filter( + (project) => + (project.totalSessions ?? project.sessions.length) > 0 || + project.id === activeProjectValue + ); + return items.map((item) => { const sessionCount = viewMode === 'grouped' @@ -324,24 +348,7 @@ export const DateGroupedSessions = (): React.JSX.Element => { meta: { sessionCount, path }, }; }); - }, [viewMode, repositoryGroups, projects]); - - const effectiveSelectedWorktreeId = - selectedWorktreeId ?? activeProjectId ?? selectedProjectId ?? null; - const effectiveSelectedRepositoryId = useMemo( - () => - resolveEffectiveSelectedRepositoryId({ - repositoryGroups, - selectedRepositoryId, - effectiveSelectedWorktreeId, - }), - [effectiveSelectedWorktreeId, repositoryGroups, selectedRepositoryId] - ); - - const activeProjectValue = - viewMode === 'grouped' - ? effectiveSelectedRepositoryId - : (activeProjectId ?? selectedProjectId ?? null); + }, [activeProjectValue, effectiveSelectedRepositoryId, projects, repositoryGroups, viewMode]); const handleProjectValueChange = (id: string): void => { if (viewMode === 'grouped') selectRepository(id); From 89bd4b87e1dc810797711274e58aa682b37eeeeb Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 14 Apr 2026 17:23:29 +0300 Subject: [PATCH 010/121] feat: add standalone web dev command --- package.json | 1 + scripts/dev-web.mjs | 76 +++++++++++++++++++ src/main/http/config.ts | 16 ++-- src/main/http/notifications.ts | 2 +- src/main/http/sessions.ts | 2 +- src/main/http/ssh.ts | 2 +- src/main/http/utility.ts | 15 +++- .../services/infrastructure/HttpServer.ts | 17 +++-- .../infrastructure/NotificationManager.ts | 50 ++++++++++-- vite.web.config.ts | 42 ++++++++++ 10 files changed, 195 insertions(+), 28 deletions(-) create mode 100644 scripts/dev-web.mjs create mode 100644 vite.web.config.ts diff --git a/package.json b/package.json index 9c9c5cdc..27370fa7 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", 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/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/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/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..9a35044b 100644 --- a/src/main/services/infrastructure/NotificationManager.ts +++ b/src/main/services/infrastructure/NotificationManager.ts @@ -21,14 +21,17 @@ 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 { EventEmitter } from 'events'; import * as fsp from 'fs/promises'; +import { createRequire } from 'module'; import * as path from 'path'; import { type DetectedError } from '../error/ErrorMessageBuilder'; +import type { BrowserWindow, NotificationConstructorOptions } from 'electron'; + const logger = createLogger('Service:NotificationManager'); +const require = createRequire(import.meta.url); import { buildDetectedErrorFromTeam, type TeamNotificationPayload, @@ -93,6 +96,35 @@ 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; +} + +let cachedNotificationClass: NotificationClass | null | undefined; + +function getNotificationClass(): NotificationClass | null { + if (cachedNotificationClass !== undefined) { + return cachedNotificationClass; + } + + try { + const electronModule = require('electron') as { Notification?: NotificationClass }; + cachedNotificationClass = electronModule.Notification ?? null; + } catch { + cachedNotificationClass = null; + } + + return cachedNotificationClass; +} + // ============================================================================= // NotificationManager Class // ============================================================================= @@ -110,7 +142,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,7 +410,8 @@ 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 Notification = getNotificationClass(); + if (!Notification || !this.isNativeNotificationSupported()) return; const config = this.configManager.getConfig(); const isMac = process.platform === 'darwin'; @@ -408,7 +441,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 +456,8 @@ export class NotificationManager extends EventEmitter { stored: StoredNotification, payload: TeamNotificationPayload ): void { - if (!this.isNativeNotificationSupported()) { + const Notification = getNotificationClass(); + if (!Notification || !this.isNativeNotificationSupported()) { logger.warn('[team-toast] native notifications not supported — skipping'); return; } @@ -491,8 +525,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,6 +546,7 @@ export class NotificationManager extends EventEmitter { * Returns a result object indicating success or failure reason. */ sendTestNotification(): { success: boolean; error?: string } { + const Notification = getNotificationClass(); if (!this.isNativeNotificationSupported()) { logger.warn('[test-notification] native notifications not supported'); return { success: false, error: 'Native notifications are not supported on this platform' }; @@ -541,7 +577,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/vite.web.config.ts b/vite.web.config.ts new file mode 100644 index 00000000..39c5778d --- /dev/null +++ b/vite.web.config.ts @@ -0,0 +1,42 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +const ROOT = __dirname; +const standalonePort = process.env.VITE_STANDALONE_PORT?.trim() || '3456'; +const webPort = Number.parseInt(process.env.VITE_WEB_PORT?.trim() || '5174', 10); +const pkg = JSON.parse(readFileSync(resolve(ROOT, 'package.json'), 'utf-8')) as { version: string }; + +export default defineConfig({ + root: resolve(ROOT, 'src/renderer'), + plugins: [react()], + server: { + host: '127.0.0.1', + port: webPort, + proxy: { + '/api': { + target: `http://127.0.0.1:${standalonePort}`, + changeOrigin: false, + }, + }, + }, + optimizeDeps: { + include: ['@codemirror/language-data'], + exclude: ['@claude-teams/agent-graph'], + }, + define: { + __APP_VERSION__: JSON.stringify(pkg.version), + 'import.meta.env.VITE_SENTRY_DSN': JSON.stringify(process.env.SENTRY_DSN ?? ''), + }, + resolve: { + alias: { + '@features': resolve(ROOT, 'src/features'), + '@renderer': resolve(ROOT, 'src/renderer'), + '@shared': resolve(ROOT, 'src/shared'), + '@main': resolve(ROOT, 'src/main'), + '@claude-teams/agent-graph': resolve(ROOT, 'packages/agent-graph/src/index.ts'), + }, + }, +}); From 9fe33430383436fa42c22c21278168398e21224c Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 14 Apr 2026 17:25:58 +0300 Subject: [PATCH 011/121] fix: hide empty member message pagination --- .../team/members/MemberMessagesTab.tsx | 4 +- .../team/members/MemberMessagesTab.test.ts | 71 +++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/src/renderer/components/team/members/MemberMessagesTab.tsx b/src/renderer/components/team/members/MemberMessagesTab.tsx index b2eda169..3fc87f1c 100644 --- a/src/renderer/components/team/members/MemberMessagesTab.tsx +++ b/src/renderer/components/team/members/MemberMessagesTab.tsx @@ -207,6 +207,8 @@ export const MemberMessagesTab = ({ ? 'No loaded messages for this member yet' : 'No messages with this member' : 'No activity with this member'; + const canLoadOlderMessages = + hasMore && activityFilter !== 'comments' && displayEntries.length > 0; return (
@@ -281,7 +283,7 @@ export const MemberMessagesTab = ({
)} - {hasMore && activityFilter !== 'comments' && ( + {canLoadOlderMessages && (
+
+ ) : null} {teamsWithProvisioning.length > 0 ? (
@@ -749,9 +801,11 @@ export const TeamListView = (): React.JSX.Element => {
) : null} @@ -794,7 +848,7 @@ export const TeamListView = (): React.JSX.Element => { ); } - const hasActiveFilters = filter.selectedStatuses.size > 0 || filter.selectedProjects.size > 0; + const hasActiveFilters = filter.selectedStatuses.size > 0 || currentProjectPath != null; if (filteredTeams.length === 0 && (searchQuery.trim() || hasActiveFilters)) { return (
diff --git a/src/renderer/components/team/teamProjectSelection.ts b/src/renderer/components/team/teamProjectSelection.ts new file mode 100644 index 00000000..2fbe04f7 --- /dev/null +++ b/src/renderer/components/team/teamProjectSelection.ts @@ -0,0 +1,156 @@ +import { normalizePath } from '@renderer/utils/pathNormalize'; + +import type { Project, RepositoryGroup } from '@renderer/types/data'; +import type { TeamSummary } from '@shared/types'; + +export interface ResolveTeamProjectSelectionInput { + repositoryGroups: readonly RepositoryGroup[]; + projects: readonly Project[]; + selectedRepositoryId: string | null; + selectedWorktreeId: string | null; + selectedProjectId: string | null; + activeProjectId: string | null; +} + +export interface ResolvedTeamProjectSelection { + projectPath: string | null; + repositoryId: string | null; + worktreeId: string | null; + projectId: string | null; +} + +export type TeamProjectSelectionTarget = + | { + kind: 'grouped'; + repositoryId: string; + worktreeId: string; + projectPath: string; + } + | { + kind: 'flat'; + projectId: string; + projectPath: string; + }; + +function findWorktreeSelection( + repositoryGroups: readonly RepositoryGroup[], + worktreeId: string +): { repositoryId: string; worktreeId: string; projectPath: string } | null { + for (const repositoryGroup of repositoryGroups) { + const worktree = repositoryGroup.worktrees.find((candidate) => candidate.id === worktreeId); + if (worktree) { + return { + repositoryId: repositoryGroup.id, + worktreeId: worktree.id, + projectPath: worktree.path, + }; + } + } + + return null; +} + +export function resolveTeamProjectSelection({ + repositoryGroups, + projects, + selectedRepositoryId, + selectedWorktreeId, + selectedProjectId, + activeProjectId, +}: ResolveTeamProjectSelectionInput): ResolvedTeamProjectSelection { + const effectiveWorktreeId = selectedWorktreeId ?? activeProjectId ?? selectedProjectId ?? null; + if (effectiveWorktreeId) { + const worktreeSelection = findWorktreeSelection(repositoryGroups, effectiveWorktreeId); + if (worktreeSelection) { + return { + projectPath: worktreeSelection.projectPath, + repositoryId: worktreeSelection.repositoryId, + worktreeId: worktreeSelection.worktreeId, + projectId: worktreeSelection.worktreeId, + }; + } + } + + const effectiveProjectId = activeProjectId ?? selectedProjectId ?? null; + if (effectiveProjectId) { + const project = projects.find((candidate) => candidate.id === effectiveProjectId); + if (project) { + return { + projectPath: project.path, + repositoryId: null, + worktreeId: null, + projectId: project.id, + }; + } + } + + if (selectedRepositoryId) { + const repositoryGroup = repositoryGroups.find( + (candidate) => candidate.id === selectedRepositoryId + ); + const fallbackWorktree = repositoryGroup?.worktrees[0] ?? null; + if (fallbackWorktree) { + return { + projectPath: fallbackWorktree.path, + repositoryId: repositoryGroup?.id ?? null, + worktreeId: fallbackWorktree.id, + projectId: fallbackWorktree.id, + }; + } + } + + return { + projectPath: null, + repositoryId: null, + worktreeId: null, + projectId: null, + }; +} + +export function findTeamProjectSelectionTarget( + repositoryGroups: readonly RepositoryGroup[], + projects: readonly Project[], + projectPath: string +): TeamProjectSelectionTarget | null { + const normalizedProjectPath = normalizePath(projectPath); + + for (const repositoryGroup of repositoryGroups) { + const worktree = repositoryGroup.worktrees.find( + (candidate) => normalizePath(candidate.path) === normalizedProjectPath + ); + if (worktree) { + return { + kind: 'grouped', + repositoryId: repositoryGroup.id, + worktreeId: worktree.id, + projectPath: worktree.path, + }; + } + } + + const project = projects.find( + (candidate) => normalizePath(candidate.path) === normalizedProjectPath + ); + if (project) { + return { + kind: 'flat', + projectId: project.id, + projectPath: project.path, + }; + } + + return null; +} + +export function teamMatchesProjectSelection(team: TeamSummary, projectPath: string): boolean { + const normalizedProjectPath = normalizePath(projectPath); + if (team.projectPath && normalizePath(team.projectPath) === normalizedProjectPath) { + return true; + } + + return ( + team.projectPathHistory?.some( + (candidate) => normalizePath(candidate) === normalizedProjectPath + ) ?? false + ); +} diff --git a/src/renderer/store/utils/stateResetHelpers.ts b/src/renderer/store/utils/stateResetHelpers.ts index d8407e34..60ecab71 100644 --- a/src/renderer/store/utils/stateResetHelpers.ts +++ b/src/renderer/store/utils/stateResetHelpers.ts @@ -39,6 +39,20 @@ export function getWorktreeNavigationState(repoId: string, worktreeId: string): }; } +/** + * Clear the active project/worktree selection without resetting unrelated UI state. + * Used when a screen wants to remove the current project context entirely. + */ +export function getProjectSelectionResetState(): Partial { + return { + selectedRepositoryId: null, + selectedWorktreeId: null, + selectedProjectId: null, + activeProjectId: null, + ...getSessionResetState(), + }; +} + /** * Full state reset (session + project + repository + conversation). * Used when closing all tabs or resetting to initial state. diff --git a/test/renderer/components/team/teamProjectSelection.test.ts b/test/renderer/components/team/teamProjectSelection.test.ts new file mode 100644 index 00000000..376ea2d3 --- /dev/null +++ b/test/renderer/components/team/teamProjectSelection.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest'; + +import { + findTeamProjectSelectionTarget, + resolveTeamProjectSelection, + teamMatchesProjectSelection, +} from '@renderer/components/team/teamProjectSelection'; + +import type { Project, RepositoryGroup } from '@renderer/types/data'; +import type { TeamSummary } from '@shared/types'; + +const repositoryGroups: RepositoryGroup[] = [ + { + id: 'repo-headless', + identity: null, + name: 'headless', + mostRecentSession: 1, + totalSessions: 5, + worktrees: [ + { + id: 'wt-headless', + path: '/Users/test/headless', + name: 'headless', + isMainWorktree: true, + source: 'git', + sessions: [], + totalSessions: 5, + createdAt: 1, + }, + ], + }, +]; + +const projects: Project[] = [ + { + id: 'project-standalone', + name: 'standalone', + path: '/Users/test/standalone', + sessions: [], + totalSessions: 2, + createdAt: 1, + }, +]; + +describe('teamProjectSelection', () => { + it('resolves selected grouped worktree path', () => { + expect( + resolveTeamProjectSelection({ + repositoryGroups, + projects, + selectedRepositoryId: 'repo-headless', + selectedWorktreeId: 'wt-headless', + selectedProjectId: 'wt-headless', + activeProjectId: 'wt-headless', + }) + ).toEqual({ + projectPath: '/Users/test/headless', + repositoryId: 'repo-headless', + worktreeId: 'wt-headless', + projectId: 'wt-headless', + }); + }); + + it('falls back to active project id when grouped ids are stale', () => { + expect( + resolveTeamProjectSelection({ + repositoryGroups, + projects, + selectedRepositoryId: null, + selectedWorktreeId: null, + selectedProjectId: null, + activeProjectId: 'wt-headless', + }) + ).toEqual({ + projectPath: '/Users/test/headless', + repositoryId: 'repo-headless', + worktreeId: 'wt-headless', + projectId: 'wt-headless', + }); + }); + + it('finds grouped selection target by project path', () => { + expect( + findTeamProjectSelectionTarget(repositoryGroups, projects, '/users/test/headless/') + ).toEqual({ + kind: 'grouped', + repositoryId: 'repo-headless', + worktreeId: 'wt-headless', + projectPath: '/Users/test/headless', + }); + }); + + it('falls back to flat projects when no grouped worktree exists', () => { + expect( + findTeamProjectSelectionTarget(repositoryGroups, projects, '/users/test/standalone/') + ).toEqual({ + kind: 'flat', + projectId: 'project-standalone', + projectPath: '/Users/test/standalone', + }); + }); + + it('matches team project history against the selected project', () => { + const team = { + teamName: 'demo-team', + displayName: 'Demo Team', + description: '', + color: null, + deletedAt: null, + pendingCreate: false, + partialLaunchFailure: false, + teamLaunchState: null, + projectPath: '/Users/test/other', + projectPathHistory: ['/Users/test/headless', '/Users/test/archive'], + lastActivity: null, + members: [], + } as TeamSummary; + + expect(teamMatchesProjectSelection(team, '/users/test/headless')).toBe(true); + expect(teamMatchesProjectSelection(team, '/users/test/missing')).toBe(false); + }); +}); From 0bbeac8c84adaee54c6ddd0bf1ea692342b23c2e Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 14 Apr 2026 18:18:52 +0300 Subject: [PATCH 015/121] fix: keep team project selection as sort priority --- .../components/team/TeamListFilterPopover.tsx | 6 ++--- src/renderer/components/team/TeamListView.tsx | 22 ++++++++++++------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/renderer/components/team/TeamListFilterPopover.tsx b/src/renderer/components/team/TeamListFilterPopover.tsx index 3c70e159..67ba7ffb 100644 --- a/src/renderer/components/team/TeamListFilterPopover.tsx +++ b/src/renderer/components/team/TeamListFilterPopover.tsx @@ -42,9 +42,8 @@ export const TeamListFilterPopover = ({ const activeCount = useMemo(() => { let count = 0; if (filter.selectedStatuses.size > 0) count += 1; - if (selectedProjectPath) count += 1; return count; - }, [filter.selectedStatuses, selectedProjectPath]); + }, [filter.selectedStatuses]); const uniqueProjects = useMemo(() => { const paths = new Set(); @@ -73,7 +72,6 @@ export const TeamListFilterPopover = ({ const handleClearAll = (): void => { onFilterChange(EMPTY_TEAM_FILTER); - onProjectChange(null); }; const aliveSet = useMemo(() => new Set(aliveTeams), [aliveTeams]); @@ -146,7 +144,7 @@ export const TeamListFilterPopover = ({ {uniqueProjects.length > 0 && (

- Project + Project priority

{uniqueProjects.map((project) => ( diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index 8e884231..11bbee89 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -421,11 +421,10 @@ export const TeamListView = (): React.JSX.Element => { }); } - if (currentProjectPath) { - result = result.filter((team) => teamMatchesProjectSelection(team, currentProjectPath)); - } - const aliveSet = new Set(aliveTeams); + const matchesCurrentProject = currentProjectPath + ? (team: TeamSummary): boolean => teamMatchesProjectSelection(team, currentProjectPath) + : null; result = [...result].sort((a, b) => { // 1. Alive (running) teams first @@ -433,12 +432,19 @@ export const TeamListView = (): React.JSX.Element => { const aliveB = aliveSet.has(b.teamName) ? 0 : 1; if (aliveA !== aliveB) return aliveA - aliveB; - // 2. Most recently active teams first (stable secondary sort) + // 2. Teams related to the selected project are prioritized next + if (matchesCurrentProject) { + const projectA = matchesCurrentProject(a) ? 0 : 1; + const projectB = matchesCurrentProject(b) ? 0 : 1; + if (projectA !== projectB) return projectA - projectB; + } + + // 3. Most recently active teams first (stable secondary sort) const tsA = a.lastActivity ? new Date(a.lastActivity).getTime() : 0; const tsB = b.lastActivity ? new Date(b.lastActivity).getTime() : 0; if (tsA !== tsB) return tsB - tsA; - // 3. Fallback: alphabetical by team name for deterministic order + // 4. Fallback: alphabetical by team name for deterministic order return a.teamName.localeCompare(b.teamName); }); @@ -770,7 +776,7 @@ export const TeamListView = (): React.JSX.Element => { className="mt-1 truncate text-xs text-[var(--color-text-muted)]" title={currentProjectPath} > - {currentProjectPath} + Prioritizing teams related to this project - not hiding the rest

-
- ) : null} {teamsWithProvisioning.length > 0 ? (
From 8a7c1a764b0724a3cd7646b0f6dfe7767b28bebd Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 14 Apr 2026 18:24:13 +0300 Subject: [PATCH 018/121] chore: clean team list view warnings --- src/renderer/components/team/TeamListView.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index ebdd3f36..94bddaa1 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -27,7 +27,7 @@ import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildTaskCountsByTeam, normalizePath } from '@renderer/utils/pathNormalize'; import { getBaseName } from '@renderer/utils/pathUtils'; import { nameColorSet } from '@renderer/utils/projectColor'; -import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection'; +import { isLeadMember } from '@shared/utils/leadDetection'; import { CheckCircle, Clock, @@ -455,8 +455,7 @@ export const TeamListView = (): React.JSX.Element => { currentProjectPath, aliveTeams, filter, - currentProvisioningRunIdByTeam, - provisioningRuns, + provisioningState, leadActivityByTeam, ]); From 58c21f3d24d3a167e988e7c4860a79277322c8f2 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 14 Apr 2026 18:39:34 +0300 Subject: [PATCH 019/121] fix: keep recent projects visible during refresh --- .../hooks/useRecentProjectsSection.ts | 62 ++++++++++---- .../utils/recentProjectsClientCache.ts | 62 ++++++++++++++ .../utils/recentProjectsClientCache.test.ts | 85 +++++++++++++++++++ 3 files changed, 192 insertions(+), 17 deletions(-) create mode 100644 src/features/recent-projects/renderer/utils/recentProjectsClientCache.ts create mode 100644 test/features/recent-projects/renderer/utils/recentProjectsClientCache.test.ts diff --git a/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts b/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts index 8b4af403..60fddcd5 100644 --- a/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts +++ b/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts @@ -7,6 +7,10 @@ import { buildTaskCountsByProject, normalizePath } from '@renderer/utils/pathNor import { useShallow } from 'zustand/react/shallow'; import { adaptRecentProjectsSection } from '../adapters/RecentProjectsSectionAdapter'; +import { + getRecentProjectsClientSnapshot, + loadRecentProjectsWithClientCache, +} from '../utils/recentProjectsClientCache'; import { useOpenRecentProject } from './useOpenRecentProject'; @@ -51,27 +55,45 @@ export function useRecentProjectsSection( openProjectPath: (projectPath: string) => Promise; selectProjectFolder: () => Promise; } { - const { globalTasks, globalTasksLoading, fetchAllTasks, teams } = useStore( - useShallow((state) => ({ - globalTasks: state.globalTasks, - globalTasksLoading: state.globalTasksLoading, - fetchAllTasks: state.fetchAllTasks, - teams: state.teams, - })) - ); + 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([]); - const [loading, setLoading] = useState(true); + const [recentProjects, setRecentProjects] = useState( + initialSnapshot?.projects ?? [] + ); + const [loading, setLoading] = useState(initialSnapshot == null); const [error, setError] = useState(null); const [visibleProjects, setVisibleProjects] = useState(maxProjects); const [aliveTeams, setAliveTeams] = useState([]); - const hasFetchedTasksRef = useRef(false); + const hasFetchedTasksRef = useRef(globalTasksInitialized); + const recentProjectsRef = useRef(initialSnapshot?.projects ?? []); - const reload = useCallback(async (): Promise => { - setLoading(true); + 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 projects = await api.getDashboardRecentProjects(); + const projects = await loadRecentProjectsWithClientCache( + () => api.getDashboardRecentProjects(), + options + ); setRecentProjects(projects); } catch (nextError) { setError(nextError instanceof Error ? nextError.message : 'Failed to load recent projects'); @@ -81,17 +103,23 @@ export function useRecentProjectsSection( }, []); useEffect(() => { - void reload(); + const snapshot = getRecentProjectsClientSnapshot(); + if (snapshot && !snapshot.isStale) { + return; + } + + void reload({ force: snapshot != null }); }, [reload]); useEffect(() => { - if (recentProjects.length === 0 || hasFetchedTasksRef.current) { + if (recentProjects.length === 0 || hasFetchedTasksRef.current || globalTasksInitialized) { + hasFetchedTasksRef.current = hasFetchedTasksRef.current || globalTasksInitialized; return; } hasFetchedTasksRef.current = true; void fetchAllTasks(); - }, [fetchAllTasks, recentProjects.length]); + }, [fetchAllTasks, globalTasksInitialized, recentProjects.length]); useEffect(() => { let cancelled = false; 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..e28d89b2 --- /dev/null +++ b/src/features/recent-projects/renderer/utils/recentProjectsClientCache.ts @@ -0,0 +1,62 @@ +import type { DashboardRecentProject } from '@features/recent-projects/contracts'; + +const RECENT_PROJECTS_CLIENT_CACHE_TTL_MS = 15_000; + +let cachedProjects: DashboardRecentProject[] | null = null; +let cachedAt = 0; +let inFlightLoad: Promise | null = null; + +export interface RecentProjectsClientSnapshot { + projects: DashboardRecentProject[]; + fetchedAt: number; + isStale: boolean; +} + +export function getRecentProjectsClientSnapshot(): RecentProjectsClientSnapshot | null { + if (!cachedProjects) { + return null; + } + + return { + projects: cachedProjects, + fetchedAt: cachedAt, + isStale: Date.now() - cachedAt > RECENT_PROJECTS_CLIENT_CACHE_TTL_MS, + }; +} + +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.projects; + } + + if (inFlightLoad) { + return inFlightLoad; + } + + const request = loader() + .then((projects) => { + cachedProjects = projects; + cachedAt = Date.now(); + return projects; + }) + .finally(() => { + if (inFlightLoad === request) { + inFlightLoad = null; + } + }); + + inFlightLoad = request; + return request; +} + +export function __resetRecentProjectsClientCacheForTests(): void { + cachedProjects = null; + cachedAt = 0; + inFlightLoad = null; +} diff --git a/test/features/recent-projects/renderer/utils/recentProjectsClientCache.test.ts b/test/features/recent-projects/renderer/utils/recentProjectsClientCache.test.ts new file mode 100644 index 00000000..99bbfaca --- /dev/null +++ b/test/features/recent-projects/renderer/utils/recentProjectsClientCache.test.ts @@ -0,0 +1,85 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + __resetRecentProjectsClientCacheForTests, + getRecentProjectsClientSnapshot, + loadRecentProjectsWithClientCache, +} from '@features/recent-projects/renderer/utils/recentProjectsClientCache'; + +import type { DashboardRecentProject } from '@features/recent-projects/contracts'; + +const project = (id: string): DashboardRecentProject => ({ + id, + name: id, + primaryPath: `/tmp/${id}`, + associatedPaths: [`/tmp/${id}`], + primaryBranch: null, + providerIds: ['anthropic'], + updatedAt: '2026-04-14T12:00:00.000Z', +}); + +describe('recentProjectsClientCache', () => { + afterEach(() => { + __resetRecentProjectsClientCacheForTests(); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('returns cached projects while the client cache is fresh', async () => { + const loader = vi.fn().mockResolvedValue([project('alpha')]); + + await expect(loadRecentProjectsWithClientCache(loader)).resolves.toEqual([project('alpha')]); + await expect(loadRecentProjectsWithClientCache(loader)).resolves.toEqual([project('alpha')]); + + expect(loader).toHaveBeenCalledTimes(1); + expect(getRecentProjectsClientSnapshot()?.projects).toEqual([project('alpha')]); + }); + + it('revalidates stale cache without dropping the previous snapshot', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-14T12:00:00.000Z')); + + const loader = vi + .fn<() => Promise>() + .mockResolvedValueOnce([project('alpha')]) + .mockResolvedValueOnce([project('beta')]); + + await loadRecentProjectsWithClientCache(loader); + vi.setSystemTime(new Date('2026-04-14T12:00:16.000Z')); + + expect(getRecentProjectsClientSnapshot()).toMatchObject({ + projects: [project('alpha')], + isStale: true, + }); + + await expect(loadRecentProjectsWithClientCache(loader, { force: true })).resolves.toEqual([ + project('beta'), + ]); + + expect(loader).toHaveBeenCalledTimes(2); + expect(getRecentProjectsClientSnapshot()).toMatchObject({ + projects: [project('beta')], + isStale: false, + }); + }); + + it('deduplicates concurrent client refreshes', async () => { + let resolveLoader: ((projects: DashboardRecentProject[]) => void) | null = null; + const loader = vi.fn( + () => + new Promise((resolve) => { + resolveLoader = resolve; + }) + ); + + const first = loadRecentProjectsWithClientCache(loader, { force: true }); + const second = loadRecentProjectsWithClientCache(loader, { force: true }); + + expect(loader).toHaveBeenCalledTimes(1); + + resolveLoader?.([project('alpha')]); + + await expect(first).resolves.toEqual([project('alpha')]); + await expect(second).resolves.toEqual([project('alpha')]); + }); +}); From db7c4fe160b4e8021aecd12a0cf83e76f7b9ea23 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 14 Apr 2026 18:40:17 +0300 Subject: [PATCH 020/121] fix: restore team project highlight matching --- src/renderer/components/team/TeamListView.tsx | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index 94bddaa1..678dd3c5 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -846,13 +846,9 @@ export const TeamListView = (): React.JSX.Element => { const teamColorSet = team.color ? getTeamColorSet(team.color) : nameColorSet(team.displayName); - const matchesCurrentProject = - !!currentProjectPath && - ((team.projectPath - ? normalizePath(team.projectPath) === currentProjectPath - : false) || - (team.projectPathHistory?.some((p) => normalizePath(p) === currentProjectPath) ?? - false)); + const matchesCurrentProject = currentProjectPath + ? teamMatchesProjectSelection(team, currentProjectPath) + : false; return (
Date: Tue, 14 Apr 2026 20:07:57 +0300 Subject: [PATCH 021/121] feat(tmux): add hybrid installer flow --- docs/FEATURE_ARCHITECTURE_STANDARD.md | 297 ++ docs/research/tmux-hybrid-installer-plan.md | 2443 +++++++++++++++++ electron.vite.config.ts | 3 + eslint.config.js | 179 ++ package.json | 3 + src/features/tmux-installer/contracts/api.ts | 11 + .../tmux-installer/contracts/channels.ts | 7 + src/features/tmux-installer/contracts/dto.ts | 109 + .../tmux-installer/contracts/index.ts | 3 + .../ports/TmuxInstallerRunnerPort.ts | 5 + .../ports/TmuxInstallerSnapshotPort.ts | 5 + .../application/ports/TmuxStatusSourcePort.ts | 6 + .../use-cases/CancelTmuxInstallUseCase.ts | 13 + .../GetTmuxInstallerSnapshotUseCase.ts | 14 + .../use-cases/GetTmuxStatusUseCase.ts | 14 + .../use-cases/InstallTmuxUseCase.ts | 13 + .../SubmitTmuxInstallerInputUseCase.ts | 13 + .../__tests__/tmuxInstallerUseCases.test.ts | 98 + .../buildTmuxAutoInstallCapability.test.ts | 95 + .../buildTmuxEffectiveAvailability.test.ts | 82 + .../buildTmuxAutoInstallCapability.ts | 192 ++ .../buildTmuxEffectiveAvailability.ts | 93 + .../input/ipc/registerTmuxInstallerIpc.ts | 84 + .../TmuxInstallerProgressPresenter.ts | 17 + .../runtime/TmuxInstallerRunnerAdapter.ts | 607 ++++ .../TmuxInstallerRunnerAdapter.test.ts | 369 +++ .../output/sources/TmuxStatusSourceAdapter.ts | 268 ++ .../__tests__/TmuxStatusSourceAdapter.test.ts | 103 + .../composition/createTmuxInstallerFeature.ts | 81 + .../main/composition/runtimeSupport.ts | 24 + src/features/tmux-installer/main/index.ts | 12 + .../installer/TmuxCommandRunner.ts | 86 + .../installer/TmuxInstallStrategyResolver.ts | 443 +++ .../installer/TmuxInstallTerminalSession.ts | 88 + .../platform/TmuxPackageManagerResolver.ts | 123 + .../platform/TmuxPlatformResolver.ts | 72 + .../runtime/TmuxPlatformCommandExecutor.ts | 109 + .../TmuxPlatformCommandExecutor.test.ts | 71 + .../wsl/TmuxWslPreferenceStore.ts | 79 + .../main/infrastructure/wsl/TmuxWslService.ts | 481 ++++ .../wsl/WindowsElevatedStepRunner.ts | 214 ++ .../wsl/__tests__/TmuxWslService.test.ts | 177 ++ .../WindowsElevatedStepRunner.test.ts | 71 + .../preload/createTmuxInstallerBridge.ts | 43 + src/features/tmux-installer/preload/index.ts | 1 + .../adapters/TmuxInstallerBannerAdapter.ts | 127 + .../TmuxInstallerBannerAdapter.test.ts | 261 ++ .../__tests__/useTmuxInstallerBanner.test.tsx | 237 ++ .../renderer/hooks/useTmuxInstallerBanner.ts | 174 ++ src/features/tmux-installer/renderer/index.ts | 1 + .../renderer/ui/TmuxInstallerBannerView.tsx | 254 ++ .../renderer/utils/formatTmuxInstallerText.ts | 63 + src/main/index.ts | 5 +- src/main/ipc/tmux.ts | 139 +- .../services/team/TeamProvisioningService.ts | 3 +- src/main/services/team/runtimeTeammateMode.ts | 40 +- src/preload/constants/ipcChannels.ts | 11 +- src/preload/index.ts | 12 +- src/renderer/api/httpClient.ts | 80 +- .../components/dashboard/TmuxStatusBanner.tsx | 348 +-- src/shared/types/tmux.ts | 16 +- .../services/team/runtimeTeammateMode.test.ts | 52 + tsconfig.json | 1 + tsconfig.node.json | 11 +- vitest.config.ts | 3 +- 65 files changed, 8608 insertions(+), 551 deletions(-) create mode 100644 docs/FEATURE_ARCHITECTURE_STANDARD.md create mode 100644 docs/research/tmux-hybrid-installer-plan.md create mode 100644 src/features/tmux-installer/contracts/api.ts create mode 100644 src/features/tmux-installer/contracts/channels.ts create mode 100644 src/features/tmux-installer/contracts/dto.ts create mode 100644 src/features/tmux-installer/contracts/index.ts create mode 100644 src/features/tmux-installer/core/application/ports/TmuxInstallerRunnerPort.ts create mode 100644 src/features/tmux-installer/core/application/ports/TmuxInstallerSnapshotPort.ts create mode 100644 src/features/tmux-installer/core/application/ports/TmuxStatusSourcePort.ts create mode 100644 src/features/tmux-installer/core/application/use-cases/CancelTmuxInstallUseCase.ts create mode 100644 src/features/tmux-installer/core/application/use-cases/GetTmuxInstallerSnapshotUseCase.ts create mode 100644 src/features/tmux-installer/core/application/use-cases/GetTmuxStatusUseCase.ts create mode 100644 src/features/tmux-installer/core/application/use-cases/InstallTmuxUseCase.ts create mode 100644 src/features/tmux-installer/core/application/use-cases/SubmitTmuxInstallerInputUseCase.ts create mode 100644 src/features/tmux-installer/core/application/use-cases/__tests__/tmuxInstallerUseCases.test.ts create mode 100644 src/features/tmux-installer/core/domain/policies/__tests__/buildTmuxAutoInstallCapability.test.ts create mode 100644 src/features/tmux-installer/core/domain/policies/__tests__/buildTmuxEffectiveAvailability.test.ts create mode 100644 src/features/tmux-installer/core/domain/policies/buildTmuxAutoInstallCapability.ts create mode 100644 src/features/tmux-installer/core/domain/policies/buildTmuxEffectiveAvailability.ts create mode 100644 src/features/tmux-installer/main/adapters/input/ipc/registerTmuxInstallerIpc.ts create mode 100644 src/features/tmux-installer/main/adapters/output/presenters/TmuxInstallerProgressPresenter.ts create mode 100644 src/features/tmux-installer/main/adapters/output/runtime/TmuxInstallerRunnerAdapter.ts create mode 100644 src/features/tmux-installer/main/adapters/output/runtime/__tests__/TmuxInstallerRunnerAdapter.test.ts create mode 100644 src/features/tmux-installer/main/adapters/output/sources/TmuxStatusSourceAdapter.ts create mode 100644 src/features/tmux-installer/main/adapters/output/sources/__tests__/TmuxStatusSourceAdapter.test.ts create mode 100644 src/features/tmux-installer/main/composition/createTmuxInstallerFeature.ts create mode 100644 src/features/tmux-installer/main/composition/runtimeSupport.ts create mode 100644 src/features/tmux-installer/main/index.ts create mode 100644 src/features/tmux-installer/main/infrastructure/installer/TmuxCommandRunner.ts create mode 100644 src/features/tmux-installer/main/infrastructure/installer/TmuxInstallStrategyResolver.ts create mode 100644 src/features/tmux-installer/main/infrastructure/installer/TmuxInstallTerminalSession.ts create mode 100644 src/features/tmux-installer/main/infrastructure/platform/TmuxPackageManagerResolver.ts create mode 100644 src/features/tmux-installer/main/infrastructure/platform/TmuxPlatformResolver.ts create mode 100644 src/features/tmux-installer/main/infrastructure/runtime/TmuxPlatformCommandExecutor.ts create mode 100644 src/features/tmux-installer/main/infrastructure/runtime/__tests__/TmuxPlatformCommandExecutor.test.ts create mode 100644 src/features/tmux-installer/main/infrastructure/wsl/TmuxWslPreferenceStore.ts create mode 100644 src/features/tmux-installer/main/infrastructure/wsl/TmuxWslService.ts create mode 100644 src/features/tmux-installer/main/infrastructure/wsl/WindowsElevatedStepRunner.ts create mode 100644 src/features/tmux-installer/main/infrastructure/wsl/__tests__/TmuxWslService.test.ts create mode 100644 src/features/tmux-installer/main/infrastructure/wsl/__tests__/WindowsElevatedStepRunner.test.ts create mode 100644 src/features/tmux-installer/preload/createTmuxInstallerBridge.ts create mode 100644 src/features/tmux-installer/preload/index.ts create mode 100644 src/features/tmux-installer/renderer/adapters/TmuxInstallerBannerAdapter.ts create mode 100644 src/features/tmux-installer/renderer/adapters/__tests__/TmuxInstallerBannerAdapter.test.ts create mode 100644 src/features/tmux-installer/renderer/hooks/__tests__/useTmuxInstallerBanner.test.tsx create mode 100644 src/features/tmux-installer/renderer/hooks/useTmuxInstallerBanner.ts create mode 100644 src/features/tmux-installer/renderer/index.ts create mode 100644 src/features/tmux-installer/renderer/ui/TmuxInstallerBannerView.tsx create mode 100644 src/features/tmux-installer/renderer/utils/formatTmuxInstallerText.ts create mode 100644 test/main/services/team/runtimeTeammateMode.test.ts diff --git a/docs/FEATURE_ARCHITECTURE_STANDARD.md b/docs/FEATURE_ARCHITECTURE_STANDARD.md new file mode 100644 index 00000000..913970ce --- /dev/null +++ b/docs/FEATURE_ARCHITECTURE_STANDARD.md @@ -0,0 +1,297 @@ +# 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 + +## 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 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..4f4bcbb2 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -97,6 +97,54 @@ export default defineConfig([ }, }, + // 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 { name: 'module-boundaries', @@ -624,6 +672,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..43d1506d 100644 --- a/package.json +++ b/package.json @@ -321,6 +321,9 @@ "tsconfig*.json" ], "paths": { + "@features/*": [ + "./src/features/*" + ], "@main/*": [ "./src/main/*" ], 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..5553a07d --- /dev/null +++ b/src/features/tmux-installer/main/adapters/output/runtime/TmuxInstallerRunnerAdapter.ts @@ -0,0 +1,607 @@ +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'; + +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; + #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() + ) { + this.#statusSource = statusSource; + this.#presenter = presenter; + this.#strategyResolver = strategyResolver; + this.#commandRunner = commandRunner; + this.#terminalSession = terminalSession; + this.#wslService = wslService; + this.#windowsElevatedStepRunner = windowsElevatedStepRunner; + } + + 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(); + if (!status.wsl?.wslInstalled) { + return; + } + } + + 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?.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(): 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); + } + + 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; + } + + 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 tmux setup can continue.', + error: null, + canCancel: false, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + }); + return status; + } + + 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.#refreshStatus(); + if (!status.wsl?.distroName) { + this.#setSnapshot({ + phase: 'needs_manual_step', + strategy: 'wsl', + message: 'WSL distro install still needs a manual step', + detail: + status.wsl?.statusDetail ?? + 'The app could not confirm that a WSL distro is ready yet. Finish the distro install manually, then re-check.', + error: null, + canCancel: false, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + }); + return status; + } + return status; + } + + 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..5463e60f --- /dev/null +++ b/src/features/tmux-installer/main/adapters/output/runtime/__tests__/TmuxInstallerRunnerAdapter.test.ts @@ -0,0 +1,369 @@ +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, + })), + }; + let resolveTerminalRun: ((result: { exitCode: number }) => void) | null = null; + const terminalSession = { + run: vi.fn( + () => + new Promise<{ exitCode: number }>((resolve) => { + resolveTerminalRun = resolve; + }) + ), + writeLine: vi.fn((input: string) => { + resolveTerminalRun?.({ 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(), + }; + let resolveCommandRun: ((result: { exitCode: number }) => void) | null = null; + const commandRunner = { + run: vi.fn( + () => + new Promise<{ exitCode: number }>((resolve) => { + resolveCommandRun = 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(); + resolveCommandRun?.({ 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'); + }); +}); 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..d93ab89e --- /dev/null +++ b/src/features/tmux-installer/main/adapters/output/sources/__tests__/TmuxStatusSourceAdapter.test.ts @@ -0,0 +1,103 @@ +// @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'); + let firstCallback: + | ((error: Error | null, stdout: string | Buffer, stderr: string | Buffer) => void) + | null = null; + + const execFileMock = vi.mocked(childProcess.execFile); + execFileMock.mockImplementation( + ( + _command: string, + _args: string[], + _options: unknown, + callback: (error: Error | null, stdout: string | Buffer, stderr: string | Buffer) => void + ) => { + if (!firstCallback) { + firstCallback = callback; + return {} as never; + } + + callback(null, 'tmux second\n', ''); + return {} 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'); + + firstCallback?.(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..89e4c71a --- /dev/null +++ b/src/features/tmux-installer/main/infrastructure/installer/TmuxCommandRunner.ts @@ -0,0 +1,86 @@ +import { spawn } from 'node:child_process'; + +import { killProcessTree } from '@main/utils/childProcess'; + +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 createBufferedLineWriter = (): { push: (chunk: string) => void; flush: () => void } => { + let pending = ''; + + const emitLine = (line: string): void => { + const normalizedLine = line.replace(/\r$/, ''); + if (normalizedLine.trim()) { + options.onLine(normalizedLine); + } + }; + + return { + push: (chunk: string): void => { + pending += chunk; + const lines = pending.split(/\r?\n/); + pending = lines.pop() ?? ''; + for (const line of lines) { + emitLine(line); + } + }, + flush: (): void => { + if (!pending) { + return; + } + emitLine(pending.trimEnd()); + pending = ''; + }, + }; + }; + + const stdoutWriter = createBufferedLineWriter(); + const stderrWriter = createBufferedLineWriter(); + + child.stdout.on('data', (chunk: Buffer | string) => stdoutWriter.push(String(chunk))); + child.stderr.on('data', (chunk: Buffer | string) => stderrWriter.push(String(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..f89028e4 --- /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/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..3741d985 --- /dev/null +++ b/src/features/tmux-installer/main/infrastructure/wsl/TmuxWslService.ts @@ -0,0 +1,481 @@ +import { execFile } from 'node:child_process'; +import path from 'node:path'; + +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; +} + +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.'; + +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 persistedPreferredDistro = await this.#preferenceStore.getPreferredDistro(); + const wslInstalled = statusProbe.exitCode === 0 || distroListProbe.exitCode === 0; + const rebootRequired = 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 { + if (typeof output === 'string') { + return output.replace(/\0/g, ''); + } + if (output.length === 0) { + return ''; + } + + const hasUtf16LeBom = output.length >= 2 && output[0] === 0xff && output[1] === 0xfe; + const decoded = + hasUtf16LeBom || this.#looksLikeUtf16Le(output) + ? output.toString('utf16le') + : output.toString('utf8'); + return decoded.replace(/\0/g, ''); + } + + #looksLikeUtf16Le(buffer: Buffer): boolean { + const sampleSize = Math.min(buffer.length, 512); + if (sampleSize < 2) { + return false; + } + + let pairs = 0; + let nullsAtOddIndex = 0; + for (let i = 0; i + 1 < sampleSize; i += 2) { + pairs += 1; + if (buffer[i + 1] === 0) { + nullsAtOddIndex += 1; + } + } + + return pairs > 0 && nullsAtOddIndex / pairs >= 0.3; + } + + #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'); + } + + #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..c5f7734e --- /dev/null +++ b/src/features/tmux-installer/main/infrastructure/wsl/WindowsElevatedStepRunner.ts @@ -0,0 +1,214 @@ +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'; + +const logger = createLogger('Feature:tmux-installer:windows-elevation'); + +interface ExecResult { + exitCode: number; + stdout: string; + stderr: string; +} + +interface PersistedElevationResult { + ok?: boolean; + detail?: string | 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; + }, + callback: ExecFileCallback +) => void; + +type MakeTempDir = (prefix: string) => Promise; + +export interface WindowsElevatedStepResult { + outcome: + | 'elevated_succeeded' + | 'elevated_cancelled' + | 'elevated_failed' + | 'elevated_unknown_outcome'; + detail: string | null; + 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, + resultFilePath, + }; + } + + if (this.#looksLikeElevationCancelled(result)) { + return { + outcome: 'elevated_cancelled', + detail: 'Administrator permission request was cancelled.', + 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), + 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, + }, + (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 #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})`, + '$result = @{ ok = $false; detail = $null }', + 'try {', + ' & wsl.exe @wslArgs', + ' 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..f3daee50 --- /dev/null +++ b/src/features/tmux-installer/main/infrastructure/wsl/__tests__/TmuxWslService.test.ts @@ -0,0 +1,177 @@ +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(); + }); +}); 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..72adbc1b --- /dev/null +++ b/src/features/tmux-installer/main/infrastructure/wsl/__tests__/WindowsElevatedStepRunner.test.ts @@ -0,0 +1,71 @@ +import * as fsp from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { describe, expect, it } 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.' }), + '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.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.' })}`, + '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'); + }); + + 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.resultFilePath).toBeNull(); + }); +}); 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..7c5978bb --- /dev/null +++ b/src/features/tmux-installer/renderer/adapters/TmuxInstallerBannerAdapter.ts @@ -0,0 +1,127 @@ +import { + formatInstallButtonLabel, + formatTmuxInstallerProgress, + formatTmuxInstallerTitle, + formatTmuxLocationLabel, + 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; + 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[]; + primaryGuideUrl: string | null; + installSupported: boolean; + installDisabled: boolean; + installLabel: string; + 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; +} + +export class TmuxInstallerBannerAdapter { + static create(): TmuxInstallerBannerAdapter { + return new TmuxInstallerBannerAdapter(); + } + + adapt(input: AdaptInput): TmuxInstallerBannerViewModel { + const status = input.status; + const snapshot = input.snapshot; + const visible = input.loading + ? false + : (status ? !status.effective.runtimeReady : true) || snapshot.phase !== 'idle'; + const title = + snapshot.phase === 'idle' && status?.effective.available && !status.effective.runtimeReady + ? 'tmux needs one more step' + : formatTmuxInstallerTitle(snapshot.phase); + 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 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 installLabel = + snapshot.phase === '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(snapshot.phase); + + return { + visible, + loading: input.loading, + title, + body, + error: input.error ?? snapshot.error ?? status?.error ?? null, + platformLabel: formatTmuxPlatformLabel(status?.platform ?? null), + locationLabel: formatTmuxLocationLabel(status?.effective.location ?? null), + runtimeReadyLabel, + versionLabel, + phase: snapshot.phase, + progressPercent: formatTmuxInstallerProgress(snapshot.phase), + logs: snapshot.logs, + manualHints: status?.autoInstall.manualHints ?? [], + primaryGuideUrl, + installSupported: status?.autoInstall.supported ?? false, + installDisabled: + input.loading || + snapshot.phase === 'preparing' || + snapshot.phase === 'checking' || + snapshot.phase === 'requesting_privileges' || + snapshot.phase === 'pending_external_elevation' || + snapshot.phase === 'waiting_for_external_step' || + snapshot.phase === 'installing' || + snapshot.phase === 'verifying', + installLabel, + canCancel: snapshot.canCancel, + acceptsInput: snapshot.acceptsInput, + inputPrompt: snapshot.inputPrompt, + inputSecret: snapshot.inputSecret, + detailsOpen: input.detailsOpen, + }; + } +} 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..20b89577 --- /dev/null +++ b/src/features/tmux-installer/renderer/adapters/__tests__/TmuxInstallerBannerAdapter.test.ts @@ -0,0 +1,261 @@ +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.body).toContain('persistent teammate reliability'); + }); + + 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.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...']); + }); + + 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(100); + }); + + it('keeps the banner visible when tmux is installed but runtime is not ready yet', () => { + 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(true); + expect(result.title).toBe('tmux needs one more step'); + expect(result.locationLabel).toBe('Host runtime'); + expect(result.runtimeReadyLabel).toBe('Installed, but not active yet'); + expect(result.versionLabel).toBe('tmux 3.4'); + }); + + 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'); + }); +}); 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..9f1e9c21 --- /dev/null +++ b/src/features/tmux-installer/renderer/hooks/__tests__/useTmuxInstallerBanner.test.tsx @@ -0,0 +1,237 @@ +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('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..fb312fce --- /dev/null +++ b/src/features/tmux-installer/renderer/hooks/useTmuxInstallerBanner.ts @@ -0,0 +1,174 @@ +import { useCallback, useEffect, useMemo, 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(), +}; + +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 getErrorMessage = useCallback((value: unknown, fallback: string): string => { + return value instanceof Error ? value.message : fallback; + }, []); + + const refresh = useCallback(async () => { + if (!electronMode) { + setLoading(false); + return; + } + + setLoading(true); + setError(null); + try { + const [nextStatus, nextSnapshot] = await Promise.all([ + api.tmux.getStatus(), + api.tmux.getInstallerSnapshot(), + ]); + setStatus(nextStatus); + setSnapshot(nextSnapshot); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : 'Failed to load tmux state'); + } finally { + setLoading(false); + } + }, [electronMode]); + + useEffect(() => { + if (!electronMode) { + setLoading(false); + return; + } + + void refresh(); + + return api.tmux.onProgress((_event, progress) => { + setSnapshot(progress); + 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(); + } + }); + }, [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..d9fc7621 --- /dev/null +++ b/src/features/tmux-installer/renderer/ui/TmuxInstallerBannerView.tsx @@ -0,0 +1,254 @@ +import React from 'react'; + +import { AlertTriangle, ExternalLink, RefreshCw, Wrench, XCircle } from 'lucide-react'; + +import { useTmuxInstallerBanner } from '../hooks/useTmuxInstallerBanner'; + +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 [inputValue, setInputValue] = React.useState(''); + + React.useEffect(() => { + if (!viewModel.acceptsInput) { + setInputValue(''); + } + }, [viewModel.acceptsInput]); + + if (!viewModel.visible) { + return null; + } + + return ( +
+
+
+
+ {viewModel.error ? ( + + ) : ( + + )} + {viewModel.title} +
+

+ {viewModel.body} +

+ {(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 && ( + + )} + {viewModel.primaryGuideUrl && ( + + )} + +
+
+ + {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. +
+ )} +
+ )} + + {viewModel.manualHints.length > 0 && ( +
+ {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/utils/formatTmuxInstallerText.ts b/src/features/tmux-installer/renderer/utils/formatTmuxInstallerText.ts new file mode 100644 index 00000000..1fe2eb66 --- /dev/null +++ b/src/features/tmux-installer/renderer/utils/formatTmuxInstallerText.ts @@ -0,0 +1,63 @@ +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 100; + 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; +} diff --git a/src/main/index.ts b/src/main/index.ts index 832baa3e..bddcd51b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -61,6 +61,7 @@ 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, ExtensionFacadeService, @@ -102,8 +103,8 @@ import { } from './utils/safeWebContentsSend'; import { syncTelemetryFlag } from './sentry'; import { - BoardTaskActivityRecordSource, BoardTaskActivityDetailService, + BoardTaskActivityRecordSource, BoardTaskActivityService, BoardTaskExactLogDetailService, BoardTaskExactLogsService, @@ -1390,6 +1391,7 @@ function createWindow(): void { if (cliInstallerService) { cliInstallerService.setMainWindow(null); } + setTmuxMainWindow(null); if (ptyTerminalService) { ptyTerminalService.setMainWindow(null); } @@ -1423,6 +1425,7 @@ function createWindow(): void { if (cliInstallerService) { cliInstallerService.setMainWindow(mainWindow); } + setTmuxMainWindow(mainWindow); if (ptyTerminalService) { ptyTerminalService.setMainWindow(mainWindow); } 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/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 1dc1a7ba..9ad61e36 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -1,3 +1,4 @@ +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'; @@ -7232,7 +7233,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( 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/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 6c25b52d..b584e5b1 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -446,9 +446,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..6cb0d612 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,3 +1,4 @@ +import { createTmuxInstallerBridge } from '@features/tmux-installer/preload'; import { WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL } from '@shared/constants'; import { contextBridge, ipcRenderer, webUtils } from 'electron'; @@ -184,7 +185,6 @@ import { TERMINAL_RESIZE, TERMINAL_SPAWN, TERMINAL_WRITE, - TMUX_GET_STATUS, UPDATER_CHECK, UPDATER_DOWNLOAD, UPDATER_INSTALL, @@ -243,6 +243,7 @@ import type { ClaudeRootInfo, CliInstallationStatus, CliInstallerProgress, + CliProviderId, ConflictCheckResult, ContextInfo, CreateScheduleInput, @@ -300,7 +301,6 @@ import type { TeamTask, TeamTaskStatus, TeamUpdateConfigRequest, - TmuxStatus, ToolApprovalEvent, ToolApprovalFileContent, ToolApprovalSettings, @@ -1408,7 +1408,7 @@ 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); }, install: async (): Promise => { @@ -1431,11 +1431,7 @@ const electronAPI: ElectronAPI = { }, }, - tmux: { - getStatus: async (): Promise => { - return invokeIpcWithResult(TMUX_GET_STATUS); - }, - }, + tmux: createTmuxInstallerBridge({ ipcRenderer, invokeIpcWithResult }), // ===== Terminal API ===== terminal: { diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index b205a4f6..9617a23c 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -1122,14 +1122,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 () => {}; + }, }; // --------------------------------------------------------------------------- @@ -1219,41 +1263,47 @@ export class HttpAPIClient implements ElectronAPI { }; schedules = { - list: async () => { + list: async (): Promise => { 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/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/shared/types/tmux.ts b/src/shared/types/tmux.ts index d6c26099..9a8545f0 100644 --- a/src/shared/types/tmux.ts +++ b/src/shared/types/tmux.ts @@ -1,15 +1 @@ -export type TmuxPlatform = 'darwin' | 'linux' | 'win32' | 'unknown'; - -export interface TmuxStatus { - available: boolean; - version: string | null; - binaryPath: string | null; - platform: TmuxPlatform; - nativeSupported: boolean; - checkedAt: string; - error: string | null; -} - -export interface TmuxAPI { - getStatus: () => Promise; -} +export type * from '@features/tmux-installer/contracts'; diff --git a/test/main/services/team/runtimeTeammateMode.test.ts b/test/main/services/team/runtimeTeammateMode.test.ts new file mode 100644 index 00000000..61bae905 --- /dev/null +++ b/test/main/services/team/runtimeTeammateMode.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockIsTmuxRuntimeReadyForCurrentPlatform = vi.fn<() => Promise>(); + +vi.mock('@features/tmux-installer/main', () => ({ + isTmuxRuntimeReadyForCurrentPlatform: mockIsTmuxRuntimeReadyForCurrentPlatform, +})); + +describe('runtimeTeammateMode', () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + }); + + it('enables process teammates in auto mode when tmux runtime is ready', async () => { + mockIsTmuxRuntimeReadyForCurrentPlatform.mockResolvedValue(true); + const { resolveDesktopTeammateModeDecision } = + await import('@main/services/team/runtimeTeammateMode'); + + const decision = await resolveDesktopTeammateModeDecision(undefined); + + expect(decision.forceProcessTeammates).toBe(true); + expect(decision.injectedTeammateMode).toBe('tmux'); + }); + + it('keeps fallback mode when tmux runtime is not ready', async () => { + mockIsTmuxRuntimeReadyForCurrentPlatform.mockResolvedValue(false); + const { resolveDesktopTeammateModeDecision } = + await import('@main/services/team/runtimeTeammateMode'); + + const decision = await resolveDesktopTeammateModeDecision(undefined); + + expect(decision.forceProcessTeammates).toBe(false); + expect(decision.injectedTeammateMode).toBeNull(); + }); + + it('re-checks tmux readiness after the environment changes instead of keeping a stale negative cache', async () => { + mockIsTmuxRuntimeReadyForCurrentPlatform + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + const { resolveDesktopTeammateModeDecision } = + await import('@main/services/team/runtimeTeammateMode'); + + const firstDecision = await resolveDesktopTeammateModeDecision(undefined); + const secondDecision = await resolveDesktopTeammateModeDecision(undefined); + + expect(firstDecision.forceProcessTeammates).toBe(false); + expect(firstDecision.injectedTeammateMode).toBeNull(); + expect(secondDecision.forceProcessTeammates).toBe(true); + expect(secondDecision.injectedTeammateMode).toBe('tmux'); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index d1b421bc..029359fe 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,7 @@ "noEmit": true, "baseUrl": ".", "paths": { + "@features/*": ["./src/features/*"], "@main/*": ["./src/main/*"], "@renderer/*": ["./src/renderer/*"], "@preload/*": ["./src/preload/*"], diff --git a/tsconfig.node.json b/tsconfig.node.json index 65b03beb..27c96603 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -12,11 +12,20 @@ "noEmit": true, "baseUrl": ".", "paths": { + "@features/*": ["./src/features/*"], "@main/*": ["./src/main/*"], "@preload/*": ["./src/preload/*"], "@shared/*": ["./src/shared/*"] }, "types": ["node"] }, - "include": ["electron.vite.config.ts", "src/main/**/*", "src/preload/**/*"] + "include": [ + "electron.vite.config.ts", + "src/main/**/*", + "src/preload/**/*", + "src/features/**/contracts/**/*", + "src/features/**/core/**/*", + "src/features/**/main/**/*", + "src/features/**/preload/**/*" + ] } diff --git a/vitest.config.ts b/vitest.config.ts index 6d1ad570..9f5b1c94 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,7 +7,7 @@ export default defineConfig({ environment: 'happy-dom', testTimeout: 15000, setupFiles: ['./test/setup.ts'], - include: ['test/**/*.test.ts'], + include: ['test/**/*.test.ts', 'src/**/*.test.ts', 'src/**/*.test.tsx'], coverage: { provider: 'v8', reporter: ['text', 'json', 'html'], @@ -17,6 +17,7 @@ export default defineConfig({ }, resolve: { alias: { + '@features': resolve(__dirname, 'src/features'), '@shared': resolve(__dirname, 'src/shared'), '@main': resolve(__dirname, 'src/main'), '@renderer': resolve(__dirname, 'src/renderer'), From cf5014a67618753214523dea620c203239818bfc Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 14 Apr 2026 20:10:22 +0300 Subject: [PATCH 022/121] test(tmux): add macos host e2e coverage --- .../TmuxInstallerMacOsHostE2E.test.ts | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 test/main/features/tmux-installer/TmuxInstallerMacOsHostE2E.test.ts diff --git a/test/main/features/tmux-installer/TmuxInstallerMacOsHostE2E.test.ts b/test/main/features/tmux-installer/TmuxInstallerMacOsHostE2E.test.ts new file mode 100644 index 00000000..1dc64f39 --- /dev/null +++ b/test/main/features/tmux-installer/TmuxInstallerMacOsHostE2E.test.ts @@ -0,0 +1,79 @@ +/** + * @vitest-environment node + */ + +import { execFileSync } from 'node:child_process'; + +import { beforeAll, describe, expect, it } from 'vitest'; + +import { TmuxStatusSourceAdapter } from '@features/tmux-installer/main/adapters/output/sources/TmuxStatusSourceAdapter'; +import { isTmuxRuntimeReadyForCurrentPlatform } from '@features/tmux-installer/main/composition/runtimeSupport'; +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 { buildEnrichedEnv } from '@main/utils/cliEnv'; +import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; + +const isDarwin = process.platform === 'darwin'; +const tmuxPath = isDarwin ? tryResolveBinary('tmux') : null; +const brewPath = isDarwin ? tryResolveBinary('brew') : null; + +function tryResolveBinary(command: string): string | null { + try { + return execFileSync('/usr/bin/which', [command], { encoding: 'utf8' }).trim() || null; + } catch { + return null; + } +} + +describe.runIf(isDarwin)('tmux installer macOS host e2e', () => { + let env: NodeJS.ProcessEnv; + + beforeAll(async () => { + await resolveInteractiveShellEnv(); + env = buildEnrichedEnv(); + }); + + it('resolves the current host as native macOS', async () => { + const result = await new TmuxPlatformResolver().resolve(); + + expect(result.platform).toBe('darwin'); + expect(result.nativeSupported).toBe(true); + expect(result.linux).toBeNull(); + }); + + it.skipIf(!brewPath)('chooses Homebrew as the macOS install strategy on this machine', async () => { + const result = await new TmuxInstallStrategyResolver().resolve(); + + expect(result.capability.strategy).toBe('homebrew'); + expect(result.capability.supported).toBe(true); + expect(result.command).not.toBeNull(); + expect(result.command?.command).toBe('brew'); + expect(result.command?.args).toEqual(['install', 'tmux']); + }); + + it.skipIf(!tmuxPath)('finds the real tmux binary on this macOS host', async () => { + const resolver = new TmuxPackageManagerResolver(); + const resolvedTmuxPath = await resolver.resolveTmuxBinary(env, 'darwin'); + + expect(resolvedTmuxPath).toBe(tmuxPath); + expect(resolvedTmuxPath).toContain('tmux'); + }); + + it.skipIf(!tmuxPath)('reports tmux as runtime-ready for the live macOS host path', async () => { + const status = await new TmuxStatusSourceAdapter().getStatus(); + + expect(status.platform).toBe('darwin'); + expect(status.host.available).toBe(true); + expect(status.effective.available).toBe(true); + expect(status.effective.location).toBe('host'); + expect(status.effective.runtimeReady).toBe(true); + expect(status.host.binaryPath).toBe(tmuxPath); + expect(status.effective.binaryPath).toBe(tmuxPath); + expect(status.effective.version ?? status.host.version).toMatch(/^tmux /); + }); + + it.skipIf(!tmuxPath)('keeps the current platform on the tmux runtime path', async () => { + await expect(isTmuxRuntimeReadyForCurrentPlatform()).resolves.toBe(true); + }); +}); From a9668ff15deedf3fad46a7c73daf4c76ef431ddc Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 14 Apr 2026 20:16:54 +0300 Subject: [PATCH 023/121] test(tmux): strengthen macos host e2e smoke --- .../TmuxInstallerMacOsHostE2E.test.ts | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/test/main/features/tmux-installer/TmuxInstallerMacOsHostE2E.test.ts b/test/main/features/tmux-installer/TmuxInstallerMacOsHostE2E.test.ts index 1dc64f39..3568c399 100644 --- a/test/main/features/tmux-installer/TmuxInstallerMacOsHostE2E.test.ts +++ b/test/main/features/tmux-installer/TmuxInstallerMacOsHostE2E.test.ts @@ -11,6 +11,7 @@ import { isTmuxRuntimeReadyForCurrentPlatform } from '@features/tmux-installer/m 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 { TmuxPlatformCommandExecutor } from '@features/tmux-installer/main/infrastructure/runtime/TmuxPlatformCommandExecutor'; import { buildEnrichedEnv } from '@main/utils/cliEnv'; import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; @@ -18,6 +19,15 @@ const isDarwin = process.platform === 'darwin'; const tmuxPath = isDarwin ? tryResolveBinary('tmux') : null; const brewPath = isDarwin ? tryResolveBinary('brew') : null; +function createIsolatedTmuxNames(): { socketName: string; sessionName: string; marker: string } { + const suffix = `${process.pid}-${Date.now()}`; + return { + socketName: `codex-e2e-${suffix}`, + sessionName: `codex_e2e_${suffix.replace(/-/g, '_')}`, + marker: `codex-macos-runtime-${suffix}`, + }; +} + function tryResolveBinary(command: string): string | null { try { return execFileSync('/usr/bin/which', [command], { encoding: 'utf8' }).trim() || null; @@ -76,4 +86,42 @@ describe.runIf(isDarwin)('tmux installer macOS host e2e', () => { it.skipIf(!tmuxPath)('keeps the current platform on the tmux runtime path', async () => { await expect(isTmuxRuntimeReadyForCurrentPlatform()).resolves.toBe(true); }); + + it.skipIf(!tmuxPath)('runs a real isolated tmux session on macOS and executes a shell command in it', async () => { + const executor = new TmuxPlatformCommandExecutor(); + const { socketName, sessionName, marker } = createIsolatedTmuxNames(); + + try { + const createSession = await executor.execTmux( + ['-L', socketName, 'new-session', '-d', '-s', sessionName, '/bin/sh'], + 5_000 + ); + expect(createSession.exitCode).toBe(0); + + const listPanes = await executor.execTmux( + ['-L', socketName, 'list-panes', '-t', sessionName, '-F', '#{pane_id}'], + 5_000 + ); + expect(listPanes.exitCode).toBe(0); + const paneId = listPanes.stdout.trim(); + expect(paneId).toMatch(/^%/); + + const sendKeys = await executor.execTmux( + ['-L', socketName, 'send-keys', '-t', paneId, `printf '${marker}'`, 'Enter'], + 5_000 + ); + expect(sendKeys.exitCode).toBe(0); + + await new Promise((resolve) => setTimeout(resolve, 150)); + + const capturePane = await executor.execTmux( + ['-L', socketName, 'capture-pane', '-p', '-t', paneId], + 5_000 + ); + expect(capturePane.exitCode).toBe(0); + expect(capturePane.stdout).toContain(marker); + } finally { + await executor.execTmux(['-L', socketName, 'kill-session', '-t', sessionName], 3_000); + } + }); }); From dd7b729520d1111e930717542611f97974a3d9d9 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 14 Apr 2026 20:20:50 +0300 Subject: [PATCH 024/121] feat(tmux): collapse windows setup steps by default --- .../adapters/TmuxInstallerBannerAdapter.ts | 6 +- .../TmuxInstallerBannerAdapter.test.ts | 2 + .../renderer/ui/TmuxInstallerBannerView.tsx | 39 +++++- .../TmuxInstallerBannerView.test.tsx | 131 ++++++++++++++++++ 4 files changed, 175 insertions(+), 3 deletions(-) create mode 100644 src/features/tmux-installer/renderer/ui/__tests__/TmuxInstallerBannerView.test.tsx diff --git a/src/features/tmux-installer/renderer/adapters/TmuxInstallerBannerAdapter.ts b/src/features/tmux-installer/renderer/adapters/TmuxInstallerBannerAdapter.ts index 7c5978bb..16cb83d2 100644 --- a/src/features/tmux-installer/renderer/adapters/TmuxInstallerBannerAdapter.ts +++ b/src/features/tmux-installer/renderer/adapters/TmuxInstallerBannerAdapter.ts @@ -26,6 +26,7 @@ export interface TmuxInstallerBannerViewModel { progressPercent: number | null; logs: string[]; manualHints: TmuxInstallHint[]; + manualHintsCollapsible: boolean; primaryGuideUrl: string | null; installSupported: boolean; installDisabled: boolean; @@ -79,6 +80,8 @@ export class TmuxInstallerBannerAdapter { : 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 = snapshot.phase === 'idle' && status?.platform === 'win32' && @@ -104,7 +107,8 @@ export class TmuxInstallerBannerAdapter { phase: snapshot.phase, progressPercent: formatTmuxInstallerProgress(snapshot.phase), logs: snapshot.logs, - manualHints: status?.autoInstall.manualHints ?? [], + manualHints, + manualHintsCollapsible, primaryGuideUrl, installSupported: status?.autoInstall.supported ?? false, installDisabled: diff --git a/src/features/tmux-installer/renderer/adapters/__tests__/TmuxInstallerBannerAdapter.test.ts b/src/features/tmux-installer/renderer/adapters/__tests__/TmuxInstallerBannerAdapter.test.ts index 20b89577..742807b4 100644 --- a/src/features/tmux-installer/renderer/adapters/__tests__/TmuxInstallerBannerAdapter.test.ts +++ b/src/features/tmux-installer/renderer/adapters/__tests__/TmuxInstallerBannerAdapter.test.ts @@ -71,6 +71,7 @@ describe('TmuxInstallerBannerAdapter', () => { 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'); }); @@ -143,6 +144,7 @@ describe('TmuxInstallerBannerAdapter', () => { expect(result.platformLabel).toBe('Windows'); expect(result.primaryGuideUrl).toBe('https://learn.microsoft.com/en-us/windows/wsl/install'); expect(result.progressPercent).toBe(100); + expect(result.manualHintsCollapsible).toBe(true); }); it('keeps the banner visible when tmux is installed but runtime is not ready yet', () => { diff --git a/src/features/tmux-installer/renderer/ui/TmuxInstallerBannerView.tsx b/src/features/tmux-installer/renderer/ui/TmuxInstallerBannerView.tsx index d9fc7621..9b5f2c62 100644 --- a/src/features/tmux-installer/renderer/ui/TmuxInstallerBannerView.tsx +++ b/src/features/tmux-installer/renderer/ui/TmuxInstallerBannerView.tsx @@ -1,6 +1,14 @@ import React from 'react'; -import { AlertTriangle, ExternalLink, RefreshCw, Wrench, XCircle } from 'lucide-react'; +import { + AlertTriangle, + ChevronDown, + ChevronUp, + ExternalLink, + RefreshCw, + Wrench, + XCircle, +} from 'lucide-react'; import { useTmuxInstallerBanner } from '../hooks/useTmuxInstallerBanner'; @@ -28,6 +36,7 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null { const { viewModel, install, cancel, submitInput, refresh, toggleDetails, openExternal } = useTmuxInstallerBanner(); const [inputValue, setInputValue] = React.useState(''); + const [manualHintsExpanded, setManualHintsExpanded] = React.useState(false); React.useEffect(() => { if (!viewModel.acceptsInput) { @@ -35,10 +44,19 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null { } }, [viewModel.acceptsInput]); + React.useEffect(() => { + if (!viewModel.manualHintsCollapsible) { + setManualHintsExpanded(false); + } + }, [viewModel.manualHintsCollapsible]); + if (!viewModel.visible) { return null; } + const manualHintsVisible = + viewModel.manualHints.length > 0 && (!viewModel.manualHintsCollapsible || manualHintsExpanded); + return (
)} + {viewModel.manualHintsCollapsible && ( + + )}
)} - {viewModel.manualHints.length > 0 && ( + {manualHintsVisible && (
{viewModel.manualHints.map((hint) => (
({ + 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.', + 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', + 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('Show setup steps (2)'); + expect(host.textContent).not.toContain('wsl --install --no-distribution'); + + const toggleButton = [...host.querySelectorAll('button')].find((button) => + button.textContent?.includes('Show setup steps') + ); + expect(toggleButton).toBeDefined(); + + await act(async () => { + toggleButton?.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' }, + ], + }); + + expect(host.textContent).toContain('brew install tmux'); + expect(host.textContent).not.toContain('Show setup steps'); + + act(() => { + root.unmount(); + }); + }); +}); From 65da1b8429266c7302d10b4b2c4bccbd6601ffff Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 14 Apr 2026 20:34:10 +0300 Subject: [PATCH 025/121] fix(tmux): improve installer banner layout --- .../renderer/ui/TmuxInstallerBannerView.tsx | 92 +++++++++++++++---- 1 file changed, 72 insertions(+), 20 deletions(-) diff --git a/src/features/tmux-installer/renderer/ui/TmuxInstallerBannerView.tsx b/src/features/tmux-installer/renderer/ui/TmuxInstallerBannerView.tsx index 9b5f2c62..46e1b77f 100644 --- a/src/features/tmux-installer/renderer/ui/TmuxInstallerBannerView.tsx +++ b/src/features/tmux-installer/renderer/ui/TmuxInstallerBannerView.tsx @@ -59,24 +59,29 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null { return (
-
-
-
- {viewModel.error ? ( - - ) : ( - - )} +
+
+
+ + {viewModel.error ? ( + + ) : ( + + )} + {viewModel.title}
-

+

{viewModel.body}

{(viewModel.platformLabel || @@ -84,20 +89,67 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null { 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.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.showRefreshButton && ( + + )}
diff --git a/src/features/tmux-installer/renderer/ui/__tests__/TmuxInstallerBannerView.test.tsx b/src/features/tmux-installer/renderer/ui/__tests__/TmuxInstallerBannerView.test.tsx index f0ed57c6..14e67a3f 100644 --- a/src/features/tmux-installer/renderer/ui/__tests__/TmuxInstallerBannerView.test.tsx +++ b/src/features/tmux-installer/renderer/ui/__tests__/TmuxInstallerBannerView.test.tsx @@ -20,6 +20,8 @@ const baseViewModel: TmuxInstallerBannerViewModel = { 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, @@ -45,6 +47,8 @@ const baseViewModel: TmuxInstallerBannerViewModel = { installSupported: true, installDisabled: false, installLabel: 'Install Ubuntu in WSL', + installButtonPrimary: true, + showRefreshButton: true, canCancel: false, acceptsInput: false, inputPrompt: null, diff --git a/src/features/tmux-installer/renderer/utils/formatTmuxInstallerText.ts b/src/features/tmux-installer/renderer/utils/formatTmuxInstallerText.ts index 1fe2eb66..8cd8294f 100644 --- a/src/features/tmux-installer/renderer/utils/formatTmuxInstallerText.ts +++ b/src/features/tmux-installer/renderer/utils/formatTmuxInstallerText.ts @@ -42,7 +42,7 @@ export function formatTmuxInstallerProgress(phase: TmuxInstallerPhase): number | if (phase === 'verifying') return 90; if (phase === 'needs_restart') return 96; if (phase === 'completed') return 100; - if (phase === 'needs_manual_step') return 100; + if (phase === 'needs_manual_step') return 82; if (phase === 'error') return 100; if (phase === 'cancelled') return 0; return null; @@ -61,3 +61,15 @@ export function formatTmuxLocationLabel(location: 'host' | 'wsl' | null): string 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.'; +} From 44f4af1756631b30e60d610fb5dfc432fd205000 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 14 Apr 2026 21:28:08 +0300 Subject: [PATCH 029/121] fix(tmux): harden windows installer banner flow --- .../installer/TmuxCommandRunner.ts | 39 ++++-- .../decodeInstallerProcessOutput.test.ts | 29 +++++ .../runtime/decodeInstallerProcessOutput.ts | 48 ++++++- .../wsl/WindowsElevatedStepRunner.ts | 10 +- .../WindowsElevatedStepRunner.test.ts | 28 +++- .../adapters/TmuxInstallerBannerAdapter.ts | 8 +- .../TmuxInstallerBannerAdapter.test.ts | 20 +++ .../__tests__/useTmuxInstallerBanner.test.tsx | 123 ++++++++++++++++++ .../renderer/hooks/useTmuxInstallerBanner.ts | 78 +++++++---- 9 files changed, 336 insertions(+), 47 deletions(-) diff --git a/src/features/tmux-installer/main/infrastructure/installer/TmuxCommandRunner.ts b/src/features/tmux-installer/main/infrastructure/installer/TmuxCommandRunner.ts index 72df349e..ed909b10 100644 --- a/src/features/tmux-installer/main/infrastructure/installer/TmuxCommandRunner.ts +++ b/src/features/tmux-installer/main/infrastructure/installer/TmuxCommandRunner.ts @@ -33,9 +33,14 @@ export class TmuxCommandRunner { stdio: ['ignore', 'pipe', 'pipe'], }); this.#activeChild = child; + const platform = process.platform; - const createBufferedLineWriter = (): { push: (chunk: string) => void; flush: () => void } => { + const createBufferedLineWriter = (): { + push: (chunk: Buffer | string) => void; + flush: () => void; + } => { let pending = ''; + let pendingBytes = Buffer.alloc(0); const emitLine = (line: string): void => { const normalizedLine = line.replace(/\r$/, ''); @@ -45,8 +50,24 @@ export class TmuxCommandRunner { }; return { - push: (chunk: string): void => { - pending += chunk; + 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() ?? ''; @@ -55,6 +76,10 @@ export class TmuxCommandRunner { } }, flush: (): void => { + if (pendingBytes.length > 0) { + pending += decodeInstallerProcessOutput(pendingBytes, platform); + pendingBytes = Buffer.alloc(0); + } if (!pending) { return; } @@ -67,12 +92,8 @@ export class TmuxCommandRunner { const stdoutWriter = createBufferedLineWriter(); const stderrWriter = createBufferedLineWriter(); - child.stdout.on('data', (chunk: Buffer | string) => - stdoutWriter.push(decodeInstallerProcessOutput(chunk)) - ); - child.stderr.on('data', (chunk: Buffer | string) => - stderrWriter.push(decodeInstallerProcessOutput(chunk)) - ); + 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); 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 index 60f310c4..288061c1 100644 --- a/src/features/tmux-installer/main/infrastructure/runtime/__tests__/decodeInstallerProcessOutput.test.ts +++ b/src/features/tmux-installer/main/infrastructure/runtime/__tests__/decodeInstallerProcessOutput.test.ts @@ -15,9 +15,38 @@ describe('decodeInstallerProcessOutput', () => { 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 index 18bf7faa..95455a2f 100644 --- a/src/features/tmux-installer/main/infrastructure/runtime/decodeInstallerProcessOutput.ts +++ b/src/features/tmux-installer/main/infrastructure/runtime/decodeInstallerProcessOutput.ts @@ -2,6 +2,7 @@ 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, @@ -14,25 +15,48 @@ export function decodeInstallerProcessOutput( return ''; } + const utf16le = stripNulls(UTF16LE_DECODER.decode(output)); if (hasUtf16LeBom(output) || looksLikeUtf16Le(output)) { - return stripNulls(UTF16LE_DECODER.decode(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] ?? utf8 - ); + 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 { @@ -51,14 +75,24 @@ function looksLikeUtf16Le(buffer: Buffer): boolean { let pairs = 0; let nullsAtOddIndex = 0; + let likelyUtf16OddBytes = 0; for (let i = 0; i + 1 < sampleSize; i += 2) { pairs += 1; - if (buffer[i + 1] === 0) { + 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; + return pairs > 0 && (nullsAtOddIndex / pairs >= 0.3 || likelyUtf16OddBytes / pairs >= 0.3); } function scoreDecodedText(value: string): number { diff --git a/src/features/tmux-installer/main/infrastructure/wsl/WindowsElevatedStepRunner.ts b/src/features/tmux-installer/main/infrastructure/wsl/WindowsElevatedStepRunner.ts index c5f7734e..46bc8f8f 100644 --- a/src/features/tmux-installer/main/infrastructure/wsl/WindowsElevatedStepRunner.ts +++ b/src/features/tmux-installer/main/infrastructure/wsl/WindowsElevatedStepRunner.ts @@ -3,6 +3,8 @@ import * as fsp from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; +import { decodeInstallerProcessOutput } from '../runtime/decodeInstallerProcessOutput'; + import { createLogger } from '@shared/utils/logger'; const logger = createLogger('Feature:tmux-installer:windows-elevation'); @@ -31,6 +33,7 @@ type ExecFileLike = ( timeout: number; windowsHide: boolean; maxBuffer: number; + encoding: 'buffer'; }, callback: ExecFileCallback ) => void; @@ -113,6 +116,7 @@ export class WindowsElevatedStepRunner { timeout, windowsHide: true, maxBuffer: MAX_BUFFER_BYTES, + encoding: 'buffer', }, (error, stdout, stderr) => { const errorCode = @@ -121,8 +125,10 @@ export class WindowsElevatedStepRunner { : undefined; resolve({ exitCode: typeof errorCode === 'number' ? errorCode : error ? 1 : 0, - stdout: String(stdout), - stderr: String(stderr) || (error instanceof Error ? error.message : ''), + stdout: decodeInstallerProcessOutput(stdout, 'win32'), + stderr: + decodeInstallerProcessOutput(stderr, 'win32') || + (error instanceof Error ? error.message : ''), }); } ); 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 index 72adbc1b..56c3019d 100644 --- a/src/features/tmux-installer/main/infrastructure/wsl/__tests__/WindowsElevatedStepRunner.test.ts +++ b/src/features/tmux-installer/main/infrastructure/wsl/__tests__/WindowsElevatedStepRunner.test.ts @@ -2,7 +2,7 @@ import * as fsp from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { WindowsElevatedStepRunner } from '../WindowsElevatedStepRunner'; @@ -68,4 +68,30 @@ describe('WindowsElevatedStepRunner', () => { expect(result.detail).toContain('cancelled'); 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('Требуемая операция выполнена успешно'); + } finally { + consoleWarnSpy.mockRestore(); + } + }); }); diff --git a/src/features/tmux-installer/renderer/adapters/TmuxInstallerBannerAdapter.ts b/src/features/tmux-installer/renderer/adapters/TmuxInstallerBannerAdapter.ts index e927442e..32f11c61 100644 --- a/src/features/tmux-installer/renderer/adapters/TmuxInstallerBannerAdapter.ts +++ b/src/features/tmux-installer/renderer/adapters/TmuxInstallerBannerAdapter.ts @@ -58,9 +58,9 @@ export class TmuxInstallerBannerAdapter { adapt(input: AdaptInput): TmuxInstallerBannerViewModel { const status = input.status; const snapshot = input.snapshot; - const visible = input.loading - ? false - : (status ? !status.effective.runtimeReady : true) || snapshot.phase !== 'idle'; + const visible = + snapshot.phase !== 'idle' || + (!input.loading && (status ? !status.effective.runtimeReady : true)); const title = snapshot.phase === 'idle' && status?.effective.available && !status.effective.runtimeReady ? 'tmux needs one more step' @@ -70,7 +70,7 @@ export class TmuxInstallerBannerAdapter { snapshot.phase === 'needs_restart' || snapshot.phase === 'needs_manual_step') ? snapshot.message - : formatTmuxInstallerTitle(snapshot.phase); + : formatTmuxInstallerTitle(snapshot.phase); const primaryGuideUrl = status?.autoInstall.manualHints.find((hint) => typeof hint.url === 'string')?.url ?? null; const body = diff --git a/src/features/tmux-installer/renderer/adapters/__tests__/TmuxInstallerBannerAdapter.test.ts b/src/features/tmux-installer/renderer/adapters/__tests__/TmuxInstallerBannerAdapter.test.ts index d4be4a04..c74d4758 100644 --- a/src/features/tmux-installer/renderer/adapters/__tests__/TmuxInstallerBannerAdapter.test.ts +++ b/src/features/tmux-installer/renderer/adapters/__tests__/TmuxInstallerBannerAdapter.test.ts @@ -111,6 +111,26 @@ describe('TmuxInstallerBannerAdapter', () => { 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(); diff --git a/src/features/tmux-installer/renderer/hooks/__tests__/useTmuxInstallerBanner.test.tsx b/src/features/tmux-installer/renderer/hooks/__tests__/useTmuxInstallerBanner.test.tsx index 9f1e9c21..8f6cc321 100644 --- a/src/features/tmux-installer/renderer/hooks/__tests__/useTmuxInstallerBanner.test.tsx +++ b/src/features/tmux-installer/renderer/hooks/__tests__/useTmuxInstallerBanner.test.tsx @@ -208,6 +208,129 @@ describe('useTmuxInstallerBanner', () => { }); }); + 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')); diff --git a/src/features/tmux-installer/renderer/hooks/useTmuxInstallerBanner.ts b/src/features/tmux-installer/renderer/hooks/useTmuxInstallerBanner.ts index fb312fce..20ab51f4 100644 --- a/src/features/tmux-installer/renderer/hooks/useTmuxInstallerBanner.ts +++ b/src/features/tmux-installer/renderer/hooks/useTmuxInstallerBanner.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { api, isElectronMode } from '@renderer/api'; @@ -20,6 +20,14 @@ const IDLE_SNAPSHOT: TmuxInstallerSnapshot = { 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; @@ -36,32 +44,50 @@ export function useTmuxInstallerBanner(): { 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 () => { - if (!electronMode) { - setLoading(false); - return; - } + const refresh = useCallback( + async (options?: { background?: boolean }) => { + if (!electronMode) { + setLoading(false); + return; + } - setLoading(true); - setError(null); - try { - const [nextStatus, nextSnapshot] = await Promise.all([ - api.tmux.getStatus(), - api.tmux.getInstallerSnapshot(), - ]); - setStatus(nextStatus); - setSnapshot(nextSnapshot); - } catch (nextError) { - setError(nextError instanceof Error ? nextError.message : 'Failed to load tmux state'); - } finally { - setLoading(false); - } - }, [electronMode]); + 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) { @@ -69,10 +95,14 @@ export function useTmuxInstallerBanner(): { return; } - void refresh(); + void refresh({ background: false }); return api.tmux.onProgress((_event, progress) => { - setSnapshot(progress); + setSnapshot((current) => + getIsoTimestamp(progress.updatedAt) >= getIsoTimestamp(current.updatedAt) + ? progress + : current + ); if ( progress.phase === 'completed' || progress.phase === 'needs_manual_step' || @@ -81,7 +111,7 @@ export function useTmuxInstallerBanner(): { progress.phase === 'error' || progress.phase === 'cancelled' ) { - void refresh(); + void refresh({ background: true }); } }); }, [electronMode, refresh]); From c3f18df062b618fa137e59d163db718570f3621c Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 14 Apr 2026 21:39:16 +0300 Subject: [PATCH 030/121] fix: harden recent project recovery and path matching --- .../ListDashboardRecentProjectsUseCase.ts | 8 +- .../CodexRecentProjectsSourceAdapter.ts | 35 +++++- .../codex/CodexAppServerClient.ts | 106 ++++++++++++++---- .../utils/recentProjectOpenHistory.ts | 104 ++++++++++++----- ...ListDashboardRecentProjectsUseCase.test.ts | 68 ++++++++++- .../CodexRecentProjectsSourceAdapter.test.ts | 102 +++++++++++++++++ .../CodexAppServerClient.test.ts | 44 +++++++- .../utils/recentProjectOpenHistory.test.ts | 32 ++++++ 8 files changed, 443 insertions(+), 56 deletions(-) create mode 100644 test/features/recent-projects/main/adapters/output/CodexRecentProjectsSourceAdapter.test.ts diff --git a/src/features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase.ts b/src/features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase.ts index 5745aacd..fefd56d8 100644 --- a/src/features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase.ts +++ b/src/features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase.ts @@ -64,8 +64,11 @@ export class ListDashboardRecentProjectsUseCase { const successful = results.flatMap((result) => result.candidates); const hasDegradedSources = results.some((result) => result.degraded); + const response: ListDashboardRecentProjectsResponse = { + projects: mergeRecentProjectCandidates(successful), + }; - if (hasDegradedSources && stale) { + 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, @@ -76,9 +79,6 @@ export class ListDashboardRecentProjectsUseCase { return stale; } - const response: ListDashboardRecentProjectsResponse = { - projects: mergeRecentProjectCandidates(successful), - }; const viewModel = this.deps.output.present(response); const cacheTtlMs = hasDegradedSources ? Math.min(this.#cacheTtlMs, this.#degradedCacheTtlMs) diff --git a/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts b/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts index dea0542d..b346ab50 100644 --- a/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts +++ b/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts @@ -17,8 +17,10 @@ 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_LIVE_FETCH_TIMEOUT_MS + CODEX_SESSION_OVERHEAD_TIMEOUT_MS; + CODEX_LIVE_FETCH_TIMEOUT_MS + CODEX_ARCHIVED_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_LIVE_FETCH_TIMEOUT_MS + CODEX_SESSION_OVERHEAD_TIMEOUT_MS + 1_500; function isInteractiveSource(source: unknown): boolean { return source === 'vscode' || source === 'cli'; @@ -116,6 +118,37 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor 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, + 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 }, diff --git a/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts b/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts index b58b870f..10013c01 100644 --- a/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts +++ b/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts @@ -1,4 +1,4 @@ -import type { JsonRpcStdioClient } from './JsonRpcStdioClient'; +import type { JsonRpcSession, JsonRpcStdioClient } from './JsonRpcStdioClient'; const DEFAULT_REQUEST_TIMEOUT_MS = 3_000; const DEFAULT_TOTAL_TIMEOUT_MS = 8_000; @@ -49,9 +49,55 @@ export interface CodexRecentThreadsResult { archived: CodexThreadSegmentResult; } +interface ThreadListSessionOptions { + binaryPath: string; + requestTimeoutMs: number; + totalTimeoutMs: number; + label: string; +} + export class CodexAppServerClient { constructor(private readonly rpcClient: JsonRpcStdioClient) {} + async listRecentLiveThreads( + binaryPath: string, + options: { + limit: number; + requestTimeoutMs?: number; + totalTimeoutMs?: number; + } + ): Promise { + const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + const totalTimeoutMs = Math.max( + options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS, + requestTimeoutMs + MIN_SESSION_OVERHEAD_TIMEOUT_MS + ); + + return this.#withThreadListSession( + { + binaryPath, + requestTimeoutMs, + 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: { @@ -66,36 +112,17 @@ export class CodexAppServerClient { const sessionRequestTimeoutMs = Math.max(liveRequestTimeoutMs, archivedRequestTimeoutMs); const totalTimeoutMs = Math.max( options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS, - sessionRequestTimeoutMs + MIN_SESSION_OVERHEAD_TIMEOUT_MS + liveRequestTimeoutMs + archivedRequestTimeoutMs + MIN_SESSION_OVERHEAD_TIMEOUT_MS ); - return this.rpcClient.withSession( + return this.#withThreadListSession( { binaryPath, - args: ['app-server'], requestTimeoutMs: sessionRequestTimeoutMs, totalTimeoutMs, label: 'codex app-server thread/list', }, 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, - }, - }, - sessionRequestTimeoutMs - ); - - await session.notify('initialized'); - const [live, archived] = await Promise.allSettled([ session.request( 'thread/list', @@ -139,4 +166,39 @@ export class CodexAppServerClient { } ); } + + 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.requestTimeoutMs + ); + + await session.notify('initialized'); + return handler(session); + } + ); + } } diff --git a/src/features/recent-projects/renderer/utils/recentProjectOpenHistory.ts b/src/features/recent-projects/renderer/utils/recentProjectOpenHistory.ts index e7b8de4f..25e60cab 100644 --- a/src/features/recent-projects/renderer/utils/recentProjectOpenHistory.ts +++ b/src/features/recent-projects/renderer/utils/recentProjectOpenHistory.ts @@ -1,5 +1,3 @@ -import { normalizePath } from '@renderer/utils/pathNormalize'; - import type { DashboardRecentProject } from '@features/recent-projects/contracts'; const RECENT_PROJECT_OPEN_HISTORY_KEY = 'recent-projects:open-history'; @@ -22,11 +20,20 @@ function canUseLocalStorage(): boolean { } function normalizeHistoryPath(projectPath: string): string | null { - const trimmed = projectPath.trim(); - if (!trimmed) { + let normalizedPath = projectPath.trim().replace(/\\/g, '/'); + if (!normalizedPath) { return null; } - return normalizePath(trimmed); + 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 { @@ -99,16 +106,72 @@ function writeHistoryEntries(entries: readonly RecentProjectOpenHistoryEntry[]): } } -function createHistoryLookup(): Map { - return new Map(readHistoryState().entries.map((entry) => [entry.path, entry.openedAt])); +interface HistoryLookup { + exact: Map; + folded: Map< + string, + { + openedAt: number; + exactPaths: Set; + } + >; } -function getProjectPaths( +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 -): string[] { - return [project.primaryPath, ...project.associatedPaths] - .map((projectPath) => normalizeHistoryPath(projectPath)) - .filter((projectPath): projectPath is string => Boolean(projectPath)); +): number { + return [project.primaryPath, ...project.associatedPaths].reduce( + (latest, projectPath) => Math.max(latest, resolveHistoryOpenedAt(lookup, projectPath)), + 0 + ); } export function recordRecentProjectOpenPaths( @@ -141,10 +204,7 @@ export function getRecentProjectLastOpenedAt( project: Pick ): number { const historyLookup = createHistoryLookup(); - return getProjectPaths(project).reduce( - (latest, projectPath) => Math.max(latest, historyLookup.get(projectPath) ?? 0), - 0 - ); + return getProjectLastOpenedAtFromLookup(historyLookup, project); } export function sortRecentProjectsByDisplayPriority( @@ -153,20 +213,12 @@ export function sortRecentProjectsByDisplayPriority( ): DashboardRecentProject[] { const historyLookup = createHistoryLookup(); - const getLastOpenedAt = ( - project: Pick - ): number => - getProjectPaths(project).reduce( - (latest, projectPath) => Math.max(latest, historyLookup.get(projectPath) ?? 0), - 0 - ); - const isPriorityOpen = (openedAt: number): boolean => openedAt > 0 && now - openedAt <= OPEN_PRIORITY_WINDOW_MS; return [...projects].sort((left, right) => { - const leftOpenedAt = getLastOpenedAt(left); - const rightOpenedAt = getLastOpenedAt(right); + const leftOpenedAt = getProjectLastOpenedAtFromLookup(historyLookup, left); + const rightOpenedAt = getProjectLastOpenedAtFromLookup(historyLookup, right); const leftPriority = isPriorityOpen(leftOpenedAt); const rightPriority = isPriorityOpen(rightOpenedAt); diff --git a/test/features/recent-projects/core/application/ListDashboardRecentProjectsUseCase.test.ts b/test/features/recent-projects/core/application/ListDashboardRecentProjectsUseCase.test.ts index 8ab8963a..14fedfc6 100644 --- a/test/features/recent-projects/core/application/ListDashboardRecentProjectsUseCase.test.ts +++ b/test/features/recent-projects/core/application/ListDashboardRecentProjectsUseCase.test.ts @@ -248,7 +248,7 @@ describe('ListDashboardRecentProjectsUseCase', () => { } }); - it('returns stale cached data when a source degrades after cache expiry', async () => { + it('prefers fresh healthy-source results over stale cache when degraded sources still leave usable projects', async () => { const stale: TestViewModel = { ids: ['repo:stale'], sources: ['mixed'] }; const cache: RecentProjectsCachePort = { get: vi.fn().mockResolvedValue(null), @@ -294,6 +294,72 @@ describe('ListDashboardRecentProjectsUseCase', () => { logger, }); + await expect(useCase.execute('recent-projects:stale')).resolves.toEqual({ + ids: ['repo:fresh'], + sources: ['claude'], + }); + expect(output.present).toHaveBeenCalledWith({ + projects: [ + expect.objectContaining({ + identity: 'repo:fresh', + source: 'claude', + }), + ], + }); + expect(cache.set).toHaveBeenCalledWith( + 'recent-projects:stale', + { ids: ['repo:fresh'], sources: ['claude'] }, + 1_500 + ); + expect(logger.info).toHaveBeenCalledWith('recent-projects loaded', { + cacheKey: 'recent-projects:stale', + count: 1, + degradedSources: 1, + cacheTtlMs: 1_500, + durationMs: 200, + }); + }); + + it('falls back to stale cache only when degraded sources leave no usable fresh projects', async () => { + const stale: TestViewModel = { ids: ['repo:stale'], sources: ['mixed'] }; + const cache: RecentProjectsCachePort = { + get: vi.fn().mockResolvedValue(null), + getStale: vi.fn().mockResolvedValue(stale), + set: vi.fn().mockResolvedValue(undefined), + }; + const output: ListDashboardRecentProjectsOutputPort = { + present: vi.fn((response: ListDashboardRecentProjectsResponse) => ({ + ids: response.projects.map((project) => project.identity), + sources: response.projects.map((project) => project.source), + })), + }; + const sources: RecentProjectsSourcePort[] = [ + { + sourceId: 'claude', + list: vi.fn().mockResolvedValue([]), + }, + { + sourceId: 'codex', + list: vi.fn().mockRejectedValue(new Error('codex unavailable')), + }, + ]; + const logger = createLogger(); + let now = 15_000; + + const useCase = new ListDashboardRecentProjectsUseCase({ + sources, + cache, + output, + clock: { + now: () => { + const current = now; + now += 200; + return current; + }, + }, + logger, + }); + await expect(useCase.execute('recent-projects:stale')).resolves.toEqual(stale); expect(output.present).not.toHaveBeenCalled(); expect(cache.set).toHaveBeenCalledWith('recent-projects:stale', stale, 1_500); diff --git a/test/features/recent-projects/main/adapters/output/CodexRecentProjectsSourceAdapter.test.ts b/test/features/recent-projects/main/adapters/output/CodexRecentProjectsSourceAdapter.test.ts new file mode 100644 index 00000000..89b1c855 --- /dev/null +++ b/test/features/recent-projects/main/adapters/output/CodexRecentProjectsSourceAdapter.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { CodexRecentProjectsSourceAdapter } from '@features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter'; + +import type { LoggerPort } from '@features/recent-projects/core/application/ports/LoggerPort'; +import type { CodexAppServerClient } from '@features/recent-projects/main/infrastructure/codex/CodexAppServerClient'; +import type { RecentProjectIdentityResolver } from '@features/recent-projects/main/infrastructure/identity/RecentProjectIdentityResolver'; + +function createLogger(): LoggerPort & { + info: ReturnType; + warn: ReturnType; + error: ReturnType; +} { + return { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; +} + +describe('CodexRecentProjectsSourceAdapter', () => { + it('falls back to live-only threads when the full app-server session fails fast', async () => { + const logger = createLogger(); + const appServerClient = { + listRecentThreads: vi + .fn() + .mockRejectedValue(new Error('JSON-RPC process exited unexpectedly (code=1 signal=null)')), + listRecentLiveThreads: vi.fn().mockResolvedValue({ + threads: [ + { + id: 'thread-live', + cwd: '/Users/belief/dev/projects/headless', + source: 'cli', + updatedAt: 1_700_000_000, + gitInfo: { branch: 'main' }, + }, + ], + }), + } as unknown as CodexAppServerClient; + const identityResolver = { + resolve: vi.fn().mockResolvedValue({ + id: 'repo:headless', + name: 'headless', + }), + } as unknown as RecentProjectIdentityResolver; + + const adapter = new CodexRecentProjectsSourceAdapter({ + getActiveContext: () => ({ type: 'local', id: 'local-1' }) as never, + getLocalContext: () => ({ type: 'local', id: 'local-1' }) as never, + resolveBinary: vi.fn().mockResolvedValue('/usr/local/bin/codex'), + appServerClient, + identityResolver, + logger, + }); + + await expect(adapter.list()).resolves.toEqual([ + expect.objectContaining({ + identity: 'repo:headless', + displayName: 'headless', + primaryPath: '/Users/belief/dev/projects/headless', + providerIds: ['codex'], + sourceKind: 'codex', + openTarget: { + type: 'synthetic-path', + path: '/Users/belief/dev/projects/headless', + }, + branchName: 'main', + }), + ]); + + expect(appServerClient.listRecentThreads).toHaveBeenCalledTimes(1); + expect(appServerClient.listRecentLiveThreads).toHaveBeenCalledTimes(1); + expect(logger.info).toHaveBeenCalledWith('codex recent-projects recovered with live-only fallback', { + liveCount: 1, + }); + }); + + it('does not spend extra time on live-only fallback after a full session timeout', async () => { + const logger = createLogger(); + const appServerClient = { + listRecentThreads: vi + .fn() + .mockRejectedValue(new Error('codex app-server thread/list timed out after 8500ms')), + listRecentLiveThreads: vi.fn(), + } as unknown as CodexAppServerClient; + const identityResolver = { + resolve: vi.fn(), + } as unknown as RecentProjectIdentityResolver; + + const adapter = new CodexRecentProjectsSourceAdapter({ + getActiveContext: () => ({ type: 'local', id: 'local-1' }) as never, + getLocalContext: () => ({ type: 'local', id: 'local-1' }) as never, + resolveBinary: vi.fn().mockResolvedValue('/usr/local/bin/codex'), + appServerClient, + identityResolver, + logger, + }); + + await expect(adapter.list()).resolves.toEqual([]); + expect(appServerClient.listRecentLiveThreads).not.toHaveBeenCalled(); + }); +}); diff --git a/test/features/recent-projects/main/infrastructure/CodexAppServerClient.test.ts b/test/features/recent-projects/main/infrastructure/CodexAppServerClient.test.ts index 76b39c08..549c8845 100644 --- a/test/features/recent-projects/main/infrastructure/CodexAppServerClient.test.ts +++ b/test/features/recent-projects/main/infrastructure/CodexAppServerClient.test.ts @@ -53,7 +53,7 @@ describe('CodexAppServerClient', () => { expect.objectContaining({ binaryPath: '/usr/local/bin/codex', requestTimeoutMs: 4500, - totalTimeoutMs: 6000, + totalTimeoutMs: 8500, }), expect.any(Function) ); @@ -135,9 +135,49 @@ describe('CodexAppServerClient', () => { expect(withSession).toHaveBeenCalledWith( expect.objectContaining({ - totalTimeoutMs: 6000, + totalTimeoutMs: 8500, }), expect.any(Function) ); }); + + it('can load only live threads in a dedicated fallback session', async () => { + const session = createSession( + vi.fn().mockImplementation((method: string, params?: { archived?: boolean }) => { + if (method === 'initialize') { + return Promise.resolve({}); + } + + if (method === 'thread/list' && params?.archived === false) { + return Promise.resolve({ + data: [{ id: 'live-1', cwd: '/Users/test/live-project', source: 'cli' }], + }); + } + + return Promise.reject(new Error(`Unexpected method: ${method}`)); + }) + ); + + const withSession = vi.fn().mockImplementation((_options, handler) => handler(session)); + const client = new CodexAppServerClient({ withSession } as unknown as JsonRpcStdioClient); + + const result = await client.listRecentLiveThreads('/usr/local/bin/codex', { + limit: 40, + requestTimeoutMs: 4500, + totalTimeoutMs: 6000, + }); + + expect(withSession).toHaveBeenCalledWith( + expect.objectContaining({ + binaryPath: '/usr/local/bin/codex', + requestTimeoutMs: 4500, + totalTimeoutMs: 6000, + label: 'codex app-server thread/list live', + }), + expect.any(Function) + ); + expect(result).toEqual({ + threads: [{ id: 'live-1', cwd: '/Users/test/live-project', source: 'cli' }], + }); + }); }); diff --git a/test/features/recent-projects/renderer/utils/recentProjectOpenHistory.test.ts b/test/features/recent-projects/renderer/utils/recentProjectOpenHistory.test.ts index 12df0fec..12b489e8 100644 --- a/test/features/recent-projects/renderer/utils/recentProjectOpenHistory.test.ts +++ b/test/features/recent-projects/renderer/utils/recentProjectOpenHistory.test.ts @@ -98,4 +98,36 @@ describe('recentProjectOpenHistory', () => { ).map((project) => project.id) ).toEqual(['repo:active', 'repo:opened']); }); + + it('does not collapse distinct case-variant paths when history contains ambiguous entries', () => { + recordRecentProjectOpenPaths(['/Work/Repo'], 5_000); + recordRecentProjectOpenPaths(['/work/repo'], 8_000); + + expect( + getRecentProjectLastOpenedAt( + makeProject({ + primaryPath: '/Work/Repo', + associatedPaths: ['/Work/Repo'], + }) + ) + ).toBe(5_000); + + expect( + getRecentProjectLastOpenedAt( + makeProject({ + primaryPath: '/work/repo', + associatedPaths: ['/work/repo'], + }) + ) + ).toBe(8_000); + + expect( + getRecentProjectLastOpenedAt( + makeProject({ + primaryPath: '/WORK/repo', + associatedPaths: ['/WORK/repo'], + }) + ) + ).toBe(0); + }); }); From 688752b3f5610cbb7e2c621c4c2a708d5303afc6 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 14 Apr 2026 21:43:38 +0300 Subject: [PATCH 031/121] fix: quiet archived codex recent project timeout --- .../CodexRecentProjectsSourceAdapter.ts | 7 +++ .../CodexRecentProjectsSourceAdapter.test.ts | 57 +++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts b/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts index b346ab50..b8fd002e 100644 --- a/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts +++ b/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts @@ -104,6 +104,13 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor 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, diff --git a/test/features/recent-projects/main/adapters/output/CodexRecentProjectsSourceAdapter.test.ts b/test/features/recent-projects/main/adapters/output/CodexRecentProjectsSourceAdapter.test.ts index 89b1c855..d963ae88 100644 --- a/test/features/recent-projects/main/adapters/output/CodexRecentProjectsSourceAdapter.test.ts +++ b/test/features/recent-projects/main/adapters/output/CodexRecentProjectsSourceAdapter.test.ts @@ -19,6 +19,63 @@ function createLogger(): LoggerPort & { } describe('CodexRecentProjectsSourceAdapter', () => { + it('treats archived-only timeout as non-blocking degradation when live threads loaded', async () => { + const logger = createLogger(); + const appServerClient = { + listRecentThreads: vi.fn().mockResolvedValue({ + live: { + threads: [ + { + id: 'thread-live', + cwd: '/Users/belief/dev/projects/headless', + source: 'cli', + updatedAt: 1_700_000_000, + gitInfo: { branch: 'main' }, + }, + ], + }, + archived: { + threads: [], + error: 'JSON-RPC request timed out: thread/list', + }, + }), + listRecentLiveThreads: vi.fn(), + } as unknown as CodexAppServerClient; + const identityResolver = { + resolve: vi.fn().mockResolvedValue({ + id: 'repo:headless', + name: 'headless', + }), + } as unknown as RecentProjectIdentityResolver; + + const adapter = new CodexRecentProjectsSourceAdapter({ + getActiveContext: () => ({ type: 'local', id: 'local-1' }) as never, + getLocalContext: () => ({ type: 'local', id: 'local-1' }) as never, + resolveBinary: vi.fn().mockResolvedValue('/usr/local/bin/codex'), + appServerClient, + identityResolver, + logger, + }); + + await expect(adapter.list()).resolves.toEqual([ + expect.objectContaining({ + identity: 'repo:headless', + primaryPath: '/Users/belief/dev/projects/headless', + }), + ]); + + expect(logger.info).toHaveBeenCalledWith( + 'codex recent-projects archived thread list degraded', + { + error: 'JSON-RPC request timed out: thread/list', + } + ); + expect(logger.warn).not.toHaveBeenCalledWith('codex recent-projects thread list failed', { + segment: 'archived', + error: 'JSON-RPC request timed out: thread/list', + }); + }); + it('falls back to live-only threads when the full app-server session fails fast', async () => { const logger = createLogger(); const appServerClient = { From 51aac9b7a1ca84a1b84497414050d188aa80b4af Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 14 Apr 2026 21:44:03 +0300 Subject: [PATCH 032/121] fix(tmux): strengthen windows restart detection --- .../runtime/TmuxInstallerRunnerAdapter.ts | 27 ++++- .../TmuxInstallerRunnerAdapter.test.ts | 96 +++++++++++++++++ .../main/infrastructure/wsl/TmuxWslService.ts | 102 +++++++++++++++++- .../wsl/WindowsElevatedStepRunner.ts | 24 ++++- .../wsl/__tests__/TmuxWslService.test.ts | 77 +++++++++++++ .../WindowsElevatedStepRunner.test.ts | 33 +++++- .../adapters/TmuxInstallerBannerAdapter.ts | 24 ++--- .../TmuxInstallerBannerAdapter.test.ts | 36 ++++++- 8 files changed, 392 insertions(+), 27 deletions(-) diff --git a/src/features/tmux-installer/main/adapters/output/runtime/TmuxInstallerRunnerAdapter.ts b/src/features/tmux-installer/main/adapters/output/runtime/TmuxInstallerRunnerAdapter.ts index 8697e8db..f484ba55 100644 --- a/src/features/tmux-installer/main/adapters/output/runtime/TmuxInstallerRunnerAdapter.ts +++ b/src/features/tmux-installer/main/adapters/output/runtime/TmuxInstallerRunnerAdapter.ts @@ -17,6 +17,7 @@ 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() { @@ -325,13 +326,28 @@ export class TmuxInstallerRunnerAdapter return status; } - if (status.wsl?.rebootRequired) { + 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: - status.wsl.statusDetail ?? + elevationResult.detail ?? + status.wsl?.statusDetail ?? 'WSL was installed, but Windows still needs a restart before tmux setup can continue.', error: null, canCancel: false, @@ -339,7 +355,7 @@ export class TmuxInstallerRunnerAdapter inputPrompt: null, inputSecret: false, }); - return status; + return rebootStatus; } if (!status.wsl?.wslInstalled) { @@ -464,6 +480,11 @@ export class TmuxInstallerRunnerAdapter return status; } + #looksLikeRestartRequired(value: string | null | undefined): boolean { + const lowered = value?.toLowerCase() ?? ''; + return RESTART_REQUIRED_PATTERNS.some((pattern) => lowered.includes(pattern)); + } + async #runResolvedPlan(plan: TmuxInstallPlan, resetLogs = true): Promise { this.#setSnapshot({ phase: 'preparing', 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 index b71579db..1a976f1a 100644 --- 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 @@ -467,4 +467,100 @@ describe('TmuxInstallerRunnerAdapter', () => { 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: 'C:\\temp\\result.json', + })), + } as never + ); + + await expect(runner.install()).resolves.toBeUndefined(); + + expect(runner.getSnapshot().phase).toBe('needs_restart'); + expect(runner.getSnapshot().message).toContain('Restart Windows'); + expect(runner.getSnapshot().detail).toContain('WSL core installation command completed'); + }); }); diff --git a/src/features/tmux-installer/main/infrastructure/wsl/TmuxWslService.ts b/src/features/tmux-installer/main/infrastructure/wsl/TmuxWslService.ts index a5dc3740..edbd8a4d 100644 --- a/src/features/tmux-installer/main/infrastructure/wsl/TmuxWslService.ts +++ b/src/features/tmux-installer/main/infrastructure/wsl/TmuxWslService.ts @@ -23,6 +23,17 @@ interface WslVerboseDistroEntry { 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, @@ -48,6 +59,11 @@ export interface TmuxWslProbeResult { 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; @@ -64,11 +80,15 @@ export class TmuxWslService { 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; - const rebootRequired = this.#looksLikeRestartRequired( - `${statusProbe.stdout}\n${statusProbe.stderr}` - ); + 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) { @@ -438,7 +458,79 @@ export class TmuxWslService { #looksLikeRestartRequired(output: string): boolean { const lowered = output.toLowerCase(); - return lowered.includes('restart') || lowered.includes('reboot'); + 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 { diff --git a/src/features/tmux-installer/main/infrastructure/wsl/WindowsElevatedStepRunner.ts b/src/features/tmux-installer/main/infrastructure/wsl/WindowsElevatedStepRunner.ts index 46bc8f8f..0d90b6da 100644 --- a/src/features/tmux-installer/main/infrastructure/wsl/WindowsElevatedStepRunner.ts +++ b/src/features/tmux-installer/main/infrastructure/wsl/WindowsElevatedStepRunner.ts @@ -15,9 +15,18 @@ interface ExecResult { 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 = ( @@ -47,6 +56,8 @@ export interface WindowsElevatedStepResult { | 'elevated_failed' | 'elevated_unknown_outcome'; detail: string | null; + restartRequired: boolean; + featureStates: PersistedFeatureState[]; resultFilePath: string | null; } @@ -84,6 +95,8 @@ export class WindowsElevatedStepRunner { return { outcome: persistedResult.ok ? 'elevated_succeeded' : 'elevated_failed', detail: persistedResult.detail ?? null, + restartRequired: persistedResult.restartRequired === true, + featureStates: persistedResult.featureStates ?? [], resultFilePath, }; } @@ -92,6 +105,8 @@ export class WindowsElevatedStepRunner { return { outcome: 'elevated_cancelled', detail: 'Administrator permission request was cancelled.', + restartRequired: false, + featureStates: [], resultFilePath: null, }; } @@ -103,6 +118,8 @@ export class WindowsElevatedStepRunner { return { outcome: 'elevated_unknown_outcome', detail: this.#firstNonEmpty(result.stderr, result.stdout), + restartRequired: false, + featureStates: [], resultFilePath: null, }; } @@ -170,9 +187,14 @@ export class WindowsElevatedStepRunner { '$ErrorActionPreference = "Stop"', `$resultFile = '${escapedResultFilePath}'`, `$wslArgs = @(${quotedArgs})`, - '$result = @{ ok = $false; detail = $null }', + '$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."', 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 index f3daee50..e1ed78f5 100644 --- a/src/features/tmux-installer/main/infrastructure/wsl/__tests__/TmuxWslService.test.ts +++ b/src/features/tmux-installer/main/infrastructure/wsl/__tests__/TmuxWslService.test.ts @@ -174,4 +174,81 @@ describe('TmuxWslService', () => { 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 index 56c3019d..d939fe89 100644 --- a/src/features/tmux-installer/main/infrastructure/wsl/__tests__/WindowsElevatedStepRunner.test.ts +++ b/src/features/tmux-installer/main/infrastructure/wsl/__tests__/WindowsElevatedStepRunner.test.ts @@ -14,7 +14,18 @@ describe('WindowsElevatedStepRunner', () => { const resultFilePath = path.join(path.dirname(launcherScriptPath), 'result.json'); await fsp.writeFile( resultFilePath, - JSON.stringify({ ok: true, detail: 'WSL core installation command completed.' }), + 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, '', ''); @@ -26,6 +37,8 @@ describe('WindowsElevatedStepRunner', () => { 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'); }); @@ -36,7 +49,18 @@ describe('WindowsElevatedStepRunner', () => { const resultFilePath = path.join(path.dirname(launcherScriptPath), 'result.json'); await fsp.writeFile( resultFilePath, - `\uFEFF${JSON.stringify({ ok: true, detail: 'WSL core installation command completed.' })}`, + `\uFEFF${JSON.stringify({ + ok: true, + detail: 'WSL core installation command completed.', + restartRequired: true, + featureStates: [ + { + featureName: 'VirtualMachinePlatform', + state: 'EnablePending', + restartRequired: 'Possible', + }, + ], + })}`, 'utf8' ); callback(null, '', ''); @@ -48,6 +72,8 @@ describe('WindowsElevatedStepRunner', () => { 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 () => { @@ -66,6 +92,8 @@ describe('WindowsElevatedStepRunner', () => { expect(result.outcome).toBe('elevated_cancelled'); expect(result.detail).toContain('cancelled'); + expect(result.restartRequired).toBe(false); + expect(result.featureStates).toEqual([]); expect(result.resultFilePath).toBeNull(); }); @@ -90,6 +118,7 @@ describe('WindowsElevatedStepRunner', () => { 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/renderer/adapters/TmuxInstallerBannerAdapter.ts b/src/features/tmux-installer/renderer/adapters/TmuxInstallerBannerAdapter.ts index 32f11c61..1dd97aca 100644 --- a/src/features/tmux-installer/renderer/adapters/TmuxInstallerBannerAdapter.ts +++ b/src/features/tmux-installer/renderer/adapters/TmuxInstallerBannerAdapter.ts @@ -58,19 +58,19 @@ export class TmuxInstallerBannerAdapter { adapt(input: AdaptInput): TmuxInstallerBannerViewModel { const status = input.status; const snapshot = input.snapshot; + const hasActiveInstallFlow = + snapshot.phase !== 'idle' && snapshot.phase !== 'completed' && snapshot.phase !== 'cancelled'; + const tmuxMissing = status ? !status.effective.available : !input.loading; const visible = - snapshot.phase !== 'idle' || - (!input.loading && (status ? !status.effective.runtimeReady : true)); + hasActiveInstallFlow || (snapshot.phase !== 'completed' && !input.loading && tmuxMissing); const title = - snapshot.phase === 'idle' && status?.effective.available && !status.effective.runtimeReady - ? 'tmux needs one more step' - : snapshot.message && - (snapshot.phase === 'pending_external_elevation' || - snapshot.phase === 'waiting_for_external_step' || - snapshot.phase === 'needs_restart' || - snapshot.phase === 'needs_manual_step') - ? snapshot.message - : formatTmuxInstallerTitle(snapshot.phase); + snapshot.message && + (snapshot.phase === 'pending_external_elevation' || + snapshot.phase === 'waiting_for_external_step' || + snapshot.phase === 'needs_restart' || + snapshot.phase === 'needs_manual_step') + ? snapshot.message + : formatTmuxInstallerTitle(snapshot.phase); const primaryGuideUrl = status?.autoInstall.manualHints.find((hint) => typeof hint.url === 'string')?.url ?? null; const body = @@ -82,7 +82,7 @@ export class TmuxInstallerBannerAdapter { status?.wsl?.statusDetail ?? 'tmux improves persistent teammate reliability and cleaner recovery for long-running tasks.'; const benefitsBody = - status && !status.effective.runtimeReady ? formatTmuxOptionalBenefits(status.platform) : null; + status && !status.effective.available ? formatTmuxOptionalBenefits(status.platform) : null; const runtimeReadyLabel = status ? status.effective.runtimeReady ? 'Ready for persistent teammates' diff --git a/src/features/tmux-installer/renderer/adapters/__tests__/TmuxInstallerBannerAdapter.test.ts b/src/features/tmux-installer/renderer/adapters/__tests__/TmuxInstallerBannerAdapter.test.ts index c74d4758..2dbb9b7e 100644 --- a/src/features/tmux-installer/renderer/adapters/__tests__/TmuxInstallerBannerAdapter.test.ts +++ b/src/features/tmux-installer/renderer/adapters/__tests__/TmuxInstallerBannerAdapter.test.ts @@ -174,7 +174,7 @@ describe('TmuxInstallerBannerAdapter', () => { expect(result.showRefreshButton).toBe(true); }); - it('keeps the banner visible when tmux is installed but runtime is not ready yet', () => { + it('hides the banner when tmux is already installed', () => { const adapter = TmuxInstallerBannerAdapter.create(); const result = adapter.adapt({ @@ -196,12 +196,40 @@ describe('TmuxInstallerBannerAdapter', () => { detailsOpen: false, }); - expect(result.visible).toBe(true); - expect(result.title).toBe('tmux needs one more step'); + 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).toContain('With tmux in WSL'); + 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', () => { From 80221884ed3ab3796106d1419438d4e603061149 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 14 Apr 2026 21:52:06 +0300 Subject: [PATCH 033/121] fix(tmux): collapse installer banner by default --- .../renderer/ui/TmuxInstallerBannerView.tsx | 600 ++++++++++-------- .../TmuxInstallerBannerView.test.tsx | 67 +- 2 files changed, 383 insertions(+), 284 deletions(-) diff --git a/src/features/tmux-installer/renderer/ui/TmuxInstallerBannerView.tsx b/src/features/tmux-installer/renderer/ui/TmuxInstallerBannerView.tsx index d4cb725a..8e8e63b4 100644 --- a/src/features/tmux-installer/renderer/ui/TmuxInstallerBannerView.tsx +++ b/src/features/tmux-installer/renderer/ui/TmuxInstallerBannerView.tsx @@ -12,6 +12,8 @@ import { import { useTmuxInstallerBanner } from '../hooks/useTmuxInstallerBanner'; +const SUMMARY_TITLE = 'tmux is not installed'; + const SourceLink = ({ label, url, @@ -35,8 +37,10 @@ const SourceLink = ({ 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) { @@ -50,6 +54,21 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null { } }, [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; } @@ -66,297 +85,320 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null { borderColor: 'rgba(245, 158, 11, 0.2)', }} > -
-
-
- - {viewModel.error ? ( - - ) : ( - - )} - - {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 && ( - - )} - {viewModel.primaryGuideUrl && ( - - )} - {viewModel.manualHintsCollapsible && ( - - )} - {viewModel.showRefreshButton && ( - - )} -
-
- - {viewModel.progressPercent !== null && ( -
-
- Installer progress - - {viewModel.progressPercent}% - + {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.acceptsInput && ( -
-
{ - event.preventDefault(); - void (async () => { - const submitted = await submitInput(inputValue); - if (submitted) { - setInputValue(''); +
+ {viewModel.installSupported && ( + - - {viewModel.inputSecret && ( -
- Password input is sent directly to the installer terminal and is not added to the log - output. + > + + {viewModel.installLabel} + + )} + {viewModel.canCancel && ( + + )} + {viewModel.primaryGuideUrl && ( + + )} + {viewModel.manualHintsCollapsible && ( + + )} + {viewModel.showRefreshButton && ( + + )} +
+ + {viewModel.progressPercent !== null && ( +
+
+ Installer progress + + {viewModel.progressPercent}% + +
+
+
+
)} -
- )} - {manualHintsVisible && ( -
- {viewModel.manualHints.map((hint) => ( -
-
- {hint.title} -
-
- {hint.description} -
- {hint.command && ( - - {hint.command} - - )} - {hint.url && ( -
- + {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.
)}
- ))} -
- )} + )} - {(viewModel.logs.length > 0 || viewModel.error) && ( -
- - {viewModel.detailsOpen && ( -
-              {[viewModel.error, ...viewModel.logs].filter(Boolean).join('\n')}
-            
+ {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 index 14e67a3f..109774c3 100644 --- a/src/features/tmux-installer/renderer/ui/__tests__/TmuxInstallerBannerView.test.tsx +++ b/src/features/tmux-installer/renderer/ui/__tests__/TmuxInstallerBannerView.test.tsx @@ -93,16 +93,32 @@ describe('TmuxInstallerBannerView', () => { it('keeps Windows setup steps collapsed by default and expands them on demand', async () => { const { host, root } = renderBanner(baseViewModel); - expect(host.textContent).toContain('Show setup steps (2)'); + 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 toggleButton = [...host.querySelectorAll('button')].find((button) => - button.textContent?.includes('Show setup steps') + const summaryButton = [...host.querySelectorAll('button')].find((button) => + button.textContent?.includes('tmux is not installed') ); - expect(toggleButton).toBeDefined(); + expect(summaryButton).toBeDefined(); await act(async () => { - toggleButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + 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(); }); @@ -125,6 +141,15 @@ describe('TmuxInstallerBannerView', () => { ], }); + 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'); @@ -132,4 +157,36 @@ describe('TmuxInstallerBannerView', () => { 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(); + }); + }); }); From 898a795182cc71953b9442f466bafad4460c6aa0 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 14 Apr 2026 21:58:55 +0300 Subject: [PATCH 034/121] fix(tmux): tighten windows reboot flow --- .../runtime/TmuxInstallerRunnerAdapter.ts | 61 ++++++++++++++-- .../TmuxInstallerRunnerAdapter.test.ts | 72 +++++++++++++++++++ .../renderer/ui/TmuxInstallerBannerView.tsx | 18 +++-- 3 files changed, 140 insertions(+), 11 deletions(-) diff --git a/src/features/tmux-installer/main/adapters/output/runtime/TmuxInstallerRunnerAdapter.ts b/src/features/tmux-installer/main/adapters/output/runtime/TmuxInstallerRunnerAdapter.ts index f484ba55..6b272d78 100644 --- a/src/features/tmux-installer/main/adapters/output/runtime/TmuxInstallerRunnerAdapter.ts +++ b/src/features/tmux-installer/main/adapters/output/runtime/TmuxInstallerRunnerAdapter.ts @@ -190,10 +190,7 @@ export class TmuxInstallerRunnerAdapter let status = currentStatus; if (!status.wsl?.wslInstalled) { - status = await this.#installWindowsWslCore(); - if (!status.wsl?.wslInstalled) { - return; - } + status = await this.#installWindowsWslCore(status); } if (status.wsl?.rebootRequired) { @@ -213,6 +210,10 @@ export class TmuxInstallerRunnerAdapter return; } + if (!status.wsl?.wslInstalled) { + return; + } + if (!status.wsl?.distroName) { status = await this.#installWindowsDistro(); if (!status.wsl?.distroName) { @@ -277,7 +278,7 @@ export class TmuxInstallerRunnerAdapter } } - async #installWindowsWslCore(): Promise { + async #installWindowsWslCore(currentStatus: TmuxStatus): Promise { this.#appendLog('Starting the elevated WSL core install step...'); this.#setSnapshot({ phase: 'pending_external_elevation', @@ -297,6 +298,28 @@ export class TmuxInstallerRunnerAdapter 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', @@ -485,6 +508,34 @@ export class TmuxInstallerRunnerAdapter 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', 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 index 1a976f1a..3c1fc042 100644 --- 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 @@ -559,8 +559,80 @@ describe('TmuxInstallerRunnerAdapter', () => { 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/renderer/ui/TmuxInstallerBannerView.tsx b/src/features/tmux-installer/renderer/ui/TmuxInstallerBannerView.tsx index 8e8e63b4..81469306 100644 --- a/src/features/tmux-installer/renderer/ui/TmuxInstallerBannerView.tsx +++ b/src/features/tmux-installer/renderer/ui/TmuxInstallerBannerView.tsx @@ -13,6 +13,7 @@ import { import { useTmuxInstallerBanner } from '../hooks/useTmuxInstallerBanner'; const SUMMARY_TITLE = 'tmux is not installed'; +const BANNER_MIN_H = 'min-h-[4.25rem]'; const SourceLink = ({ label, @@ -78,7 +79,7 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null { return (
setExpanded((current) => !current)} - className="flex w-full items-center justify-between gap-3 text-left" + className="flex min-h-[1.75rem] w-full items-center justify-between gap-3 text-left" > - - + + {viewModel.error ? ( ) : ( )} - {SUMMARY_TITLE} + + {SUMMARY_TITLE} + {expanded ? ( @@ -109,7 +115,7 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null { {expanded && ( -
+
{viewModel.title !== SUMMARY_TITLE && (
Date: Tue, 14 Apr 2026 22:06:50 +0300 Subject: [PATCH 035/121] refactor: update README and security documentation; enhance activity lane layout and kanban integration --- .github/SECURITY.md | 7 +- README.md | 19 +- .../src/hooks/useGraphSimulation.ts | 95 +++-- .../agent-graph/src/layout/activityLane.ts | 173 +++++++-- .../agent-graph/src/layout/kanbanLayout.ts | 45 ++- .../agent-graph/src/layout/launchAnchor.ts | 38 +- packages/agent-graph/src/ui/GraphView.tsx | 24 ++ .../renderer/ui/GraphActivityHud.tsx | 327 ++++++++++++------ .../renderer/ui/TeamGraphOverlay.tsx | 65 ++-- .../agent-graph/renderer/ui/TeamGraphTab.tsx | 69 ++-- .../components/dashboard/TmuxStatusBanner.tsx | 6 +- .../features/agent-graph/activityLane.test.ts | 58 +++- .../features/agent-graph/kanbanLayout.test.ts | 38 +- 13 files changed, 725 insertions(+), 239 deletions(-) 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/README.md b/README.md index ed65dcb3..0ffe93dc 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,10 +91,10 @@ 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) -- [Developer architecture docs](#developer-architecture-docs) - [Development](#development) - [Tech stack](#tech-stack) - [Build for distribution](#build-for-distribution) @@ -108,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 @@ -168,7 +167,7 @@ For feature architecture and implementation guidance: ## 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) | @@ -306,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) @@ -317,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 --- @@ -326,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/packages/agent-graph/src/hooks/useGraphSimulation.ts b/packages/agent-graph/src/hooks/useGraphSimulation.ts index bd6dd240..2955c737 100644 --- a/packages/agent-graph/src/hooks/useGraphSimulation.ts +++ b/packages/agent-graph/src/hooks/useGraphSimulation.ts @@ -26,7 +26,6 @@ import { KanbanLayoutEngine } from '../layout/kanbanLayout'; import { LAUNCH_ANCHOR_LAYOUT, getActivityAnchorId, - getHandoffAnchorBounds, getLaunchAnchorBounds, getLaunchAnchorId, getLaunchAnchorTarget, @@ -34,7 +33,14 @@ import { isLaunchAnchorId, type WorldBounds, } from '../layout/launchAnchor'; -import { ACTIVITY_ANCHOR_LAYOUT, getActivityAnchorTarget } from '../layout/activityLane'; +import { + ACTIVITY_ANCHOR_LAYOUT, + buildVisibleActivityLaneBounds, + getActivityLaneBounds, + getActivityAnchorTarget, + packActivityLaneWorldRects, + resolveActivityLaneSide, +} from '../layout/activityLane'; // ─── Force Node/Link types (properly typed, no loose `string`) ────────────── @@ -91,35 +97,71 @@ function syncLaunchAnchors(forceNodes: ForceNode[]): void { } const leadNode = forceNodes.find((node) => node.kind === 'lead'); const leadX = leadNode?.x ?? leadNode?.fx ?? null; + const pendingActivityAnchors: Array<{ + node: ForceNode; + target: { x: number; y: number }; + side: 'left' | 'right'; + }> = []; 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 target = getLaunchAnchorTarget(leadNode.x ?? 0, leadNode.y ?? 0); + node.fx = target.x; + node.fy = target.y; + node.x = target.x; + node.y = target.y; + node.vx = 0; + node.vy = 0; + continue; + } + + if (node.kind === 'activity-anchor' && node.anchorForNodeId) { const ownerNode = forceNodeMap.get(node.anchorForNodeId); if (!ownerNode || (ownerNode.kind !== 'lead' && ownerNode.kind !== 'member')) continue; - target = getActivityAnchorTarget({ + const target = getActivityAnchorTarget({ nodeX: ownerNode.x ?? 0, nodeY: ownerNode.y ?? 0, nodeKind: ownerNode.kind, leadX, }); - } else { - continue; - } - if (!target) { - continue; + pendingActivityAnchors.push({ + node, + target, + side: resolveActivityLaneSide({ + nodeKind: ownerNode.kind, + nodeX: ownerNode.x ?? 0, + leadX, + }), + }); } + } - node.fx = target.x; - node.fy = target.y; - node.x = target.x; - node.y = target.y; - node.vx = 0; - node.vy = 0; + const packedActivityAnchors = packActivityLaneWorldRects( + pendingActivityAnchors.map(({ node, target, side }) => ({ + id: node.id, + side, + x: target.x, + y: target.y, + width: ACTIVITY_ANCHOR_LAYOUT.reservedWidth, + height: ACTIVITY_ANCHOR_LAYOUT.reservedHeight, + })), + 18, + ); + + for (const entry of pendingActivityAnchors) { + const packed = packedActivityAnchors.get(entry.node.id); + const centerX = (packed?.x ?? entry.target.x) + + ACTIVITY_ANCHOR_LAYOUT.reservedWidth / 2; + const centerY = (packed?.y ?? entry.target.y) + + ACTIVITY_ANCHOR_LAYOUT.reservedHeight / 2; + entry.node.fx = centerX; + entry.node.fy = centerY; + entry.node.x = centerX; + entry.node.y = centerY; + entry.node.vx = 0; + entry.node.vy = 0; } } @@ -142,8 +184,12 @@ function updateLaunchAnchorCaches( continue; } if (node.kind === 'activity-anchor' && node.anchorForNodeId) { - activityPositions.set(node.anchorForNodeId, { x, y }); - bounds.push(getHandoffAnchorBounds(x, y)); + const topLeft = { + x: x - ACTIVITY_ANCHOR_LAYOUT.reservedWidth / 2, + y: y - ACTIVITY_ANCHOR_LAYOUT.reservedHeight / 2, + }; + activityPositions.set(node.anchorForNodeId, topLeft); + bounds.push(getActivityLaneBounds(topLeft.x, topLeft.y)); } } } @@ -314,7 +360,12 @@ export function useGraphSimulation(): UseGraphSimulationResult { } // Position tasks in kanban zones relative to their owners - KanbanLayoutEngine.layout(nodes); + KanbanLayoutEngine.layout(nodes, { + activityLaneBounds: buildVisibleActivityLaneBounds( + nodes, + activityAnchorPositionsRef.current + ), + }); updateLaunchAnchorCaches( sim.nodes(), launchAnchorPositionsRef.current, @@ -515,7 +566,9 @@ function tickFrame( } // Re-layout tasks in kanban zones — always run to handle new/moved tasks - KanbanLayoutEngine.layout(state.nodes); + KanbanLayoutEngine.layout(state.nodes, { + activityLaneBounds: buildVisibleActivityLaneBounds(state.nodes, activityAnchorPositions), + }); // Update particle progress — in-place removal (no new array allocation) let pw = 0; diff --git a/packages/agent-graph/src/layout/activityLane.ts b/packages/agent-graph/src/layout/activityLane.ts index a5766933..46b1810c 100644 --- a/packages/agent-graph/src/layout/activityLane.ts +++ b/packages/agent-graph/src/layout/activityLane.ts @@ -1,20 +1,21 @@ -import { KANBAN_ZONE, NODE, TASK_PILL } from '../constants/canvas-constants'; +import { CAMERA, KANBAN_ZONE, NODE, TASK_PILL } from '../constants/canvas-constants'; import type { GraphActivityItem, GraphNode } from '../ports/types'; export const ACTIVITY_LANE = { width: 296, - itemHeight: 58, - rowHeight: 62, + itemHeight: 72, + rowHeight: 80, maxVisibleItems: 3, - headerHeight: 18, - overflowHeight: 18, + headerHeight: 20, + overflowHeight: 32, horizontalGapLead: 76, horizontalGapMember: 84, - bottomClearance: 18, + ownerClearanceLead: 92, + ownerClearanceMember: 104, viewportPadding: 12, visiblePadding: 80, - minScale: 0, - maxScale: 1, + minScale: CAMERA.minZoom, + maxScale: CAMERA.maxZoom, } as const; const RESERVED_HEIGHT = @@ -26,10 +27,10 @@ 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), + memberOffsetY: -(RESERVED_HEIGHT + NODE.radiusMember + ACTIVITY_LANE.ownerClearanceMember), 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, + leadOffsetY: -(RESERVED_HEIGHT + NODE.radiusLead + ACTIVITY_LANE.ownerClearanceLead), + collisionRadius: Math.ceil(Math.hypot(ACTIVITY_LANE.width / 2, RESERVED_HEIGHT / 2)) + 56, } as const; export interface ActivityLaneWindow { @@ -51,6 +52,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 +99,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 +116,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 +169,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 +199,64 @@ 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(); + + const sideGroups = groupBySide ? (['left', 'right'] as const) : (['left'] as const); + + for (const side of sideGroups) { + 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: Array = []; + + for (const rect of sideRects) { + 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; + } + } + + placed.push({ ...rect, placedY }); + placements.set(rect.id, { x: rect.x, y: placedY }); + } + } + + return placements; +} + export function findActivityItemAt( worldX: number, worldY: number, @@ -194,3 +299,7 @@ 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; +} diff --git a/packages/agent-graph/src/layout/kanbanLayout.ts b/packages/agent-graph/src/layout/kanbanLayout.ts index ccbb3be2..9618f491 100644 --- a/packages/agent-graph/src/layout/kanbanLayout.ts +++ b/packages/agent-graph/src/layout/kanbanLayout.ts @@ -8,9 +8,10 @@ */ 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 { ActivityLaneWorldBounds } from './activityLane'; /** Column header info for rendering */ export interface KanbanColumnHeader { @@ -41,6 +42,8 @@ const COLUMN_LABELS: Record = { approved: { label: 'Approved', color: COLORS.reviewApproved }, }; +const ACTIVITY_KANBAN_CLEARANCE = 24; + export function getOwnerKanbanBaseX(args: { ownerX: number; ownerKind: GraphNode['kind']; @@ -84,11 +87,15 @@ export class KanbanLayoutEngine { * 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?: { activityLaneBounds?: readonly ActivityLaneWorldBounds[] } + ): 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 activityLaneBounds = options?.activityLaneBounds ?? []; const tasksByOwner = this.#tasksByOwner; tasksByOwner.clear(); @@ -115,7 +122,13 @@ export class KanbanLayoutEngine { for (const [ownerId, tasks] of tasksByOwner) { const owner = nodeMap.get(ownerId); if (!owner || owner.x == null || owner.y == null) continue; - const zoneInfo = KanbanLayoutEngine.#layoutZone(tasks, owner, ownerId, leadX); + const zoneInfo = KanbanLayoutEngine.#layoutZone( + tasks, + owner, + ownerId, + leadX, + activityLaneBounds + ); if (zoneInfo) this.zones.push(zoneInfo); } @@ -128,13 +141,13 @@ export class KanbanLayoutEngine { tasks: GraphNode[], owner: GraphNode, ownerId: string, - leadX: number | null + leadX: number | null, + activityLaneBounds: readonly ActivityLaneWorldBounds[] ): KanbanZoneInfo | null { const { columnWidth, rowHeight, offsetY, columns } = KANBAN_ZONE; const headerHeight = 20; // space for column header label const ownerX = owner.x ?? 0; const ownerY = owner.y ?? 0; - const baseY = ownerY + offsetY; // Classify tasks into columns const colTasks = KanbanLayoutEngine.#colTasks; @@ -166,6 +179,24 @@ export class KanbanLayoutEngine { columnWidth, leadX, }); + const taskZoneLeft = baseX - TASK_PILL.width / 2; + const taskZoneRight = + baseX + (activeColumns.length - 1) * columnWidth + TASK_PILL.width / 2; + const overlappingActivityBottom = activityLaneBounds.reduce((maxBottom, bounds) => { + if (bounds.ownerId === ownerId) { + return Math.max(maxBottom, bounds.bottom); + } + if (!rangesOverlap(taskZoneLeft, taskZoneRight, bounds.left, bounds.right)) { + return maxBottom; + } + return Math.max(maxBottom, bounds.bottom); + }, -Infinity); + const baseY = Math.max( + ownerY + offsetY, + overlappingActivityBottom > -Infinity + ? overlappingActivityBottom + ACTIVITY_KANBAN_CLEARANCE + : -Infinity + ); // Build headers + position tasks const headers: KanbanColumnHeader[] = []; @@ -274,3 +305,7 @@ export class KanbanLayoutEngine { } } } + +function rangesOverlap(aStart: number, aEnd: number, bStart: number, bEnd: number): boolean { + return aStart < bEnd && bStart < aEnd; +} diff --git a/packages/agent-graph/src/layout/launchAnchor.ts b/packages/agent-graph/src/layout/launchAnchor.ts index 522c818d..4d3dad83 100644 --- a/packages/agent-graph/src/layout/launchAnchor.ts +++ b/packages/agent-graph/src/layout/launchAnchor.ts @@ -1,8 +1,7 @@ import { NODE } from '../constants/canvas-constants'; import { ACTIVITY_ANCHOR_LAYOUT, - getActivityAnchorTarget, - getActivityLaneBounds, + resolveActivityLaneSide, } from './activityLane'; export interface WorldBounds { @@ -76,9 +75,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/ui/GraphView.tsx b/packages/agent-graph/src/ui/GraphView.tsx index 90279acb..fe3c1e4c 100644 --- a/packages/agent-graph/src/ui/GraphView.tsx +++ b/packages/agent-graph/src/ui/GraphView.tsx @@ -65,6 +65,13 @@ export interface GraphViewProps { getActivityAnchorScreenPlacement: ( ownerNodeId: string, ) => { x: number; y: number; scale: number; visible: boolean } | null; + getActivityAnchorWorldPosition: ( + ownerNodeId: string, + ) => { x: number; y: 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 }; getNodeScreenPosition: ( nodeId: string, ) => { x: number; y: number; visible: boolean } | null; @@ -229,6 +236,11 @@ export function GraphView({ viewportHeight: viewport.height, }); }, [getViewportSize]); + const getActivityAnchorWorldPosition = useCallback( + (ownerNodeId: string) => simulationRef.current.getActivityAnchorWorldPosition(ownerNodeId), + [], + ); + const getCameraZoom = useCallback(() => cameraRef.current.transformRef.current.zoom, []); const getNodeScreenPosition = useCallback((nodeId: string) => { const viewport = getViewportSize(); if (viewport.width <= 0 || viewport.height <= 0) { @@ -247,6 +259,13 @@ export function GraphView({ visible: x > -80 && x < viewport.width + 80 && y > -80 && y < viewport.height + 80, }; }, [getViewportSize]); + 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) { + return null; + } + return { x: node.x, y: node.y }; + }, []); const animate = useCallback(() => { if (!runningRef.current) return; @@ -710,6 +729,11 @@ export function GraphView({ {renderHud({ getLaunchAnchorScreenPlacement, getActivityAnchorScreenPlacement, + getActivityAnchorWorldPosition, + getCameraZoom, + worldToScreen: camera.worldToScreen, + getNodeWorldPosition, + getViewportSize, getNodeScreenPosition, focusNodeIds: focusState.focusNodeIds, })} diff --git a/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx b/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx index 0be0a066..448ff848 100644 --- a/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx +++ b/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx @@ -33,6 +33,11 @@ interface GraphActivityHudProps { getActivityAnchorScreenPlacement: ( ownerNodeId: string ) => { x: number; y: number; scale: number; visible: boolean } | null; + getActivityAnchorWorldPosition?: (ownerNodeId: string) => { x: number; y: 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 }; getNodeScreenPosition?: (nodeId: string) => { x: number; y: number; visible: boolean } | null; focusNodeIds: ReadonlySet | null; enabled?: boolean; @@ -50,12 +55,19 @@ export const GraphActivityHud = ({ teamName, nodes, getActivityAnchorScreenPlacement, + getActivityAnchorWorldPosition = () => null, + getCameraZoom = () => 1, + worldToScreen, + getNodeWorldPosition = () => null, + getViewportSize, getNodeScreenPosition = () => null, focusNodeIds, enabled = true, onOpenTaskDetail, onOpenMemberProfile, }: GraphActivityHudProps): React.JSX.Element | null => { + const ACTIVITY_LANE_WIDTH = 296; + const worldLayerRef = useRef(null); const shellRefs = useRef(new Map()); const connectorRefs = useRef(new Map()); const connectorPathRefs = useRef(new Map()); @@ -140,16 +152,35 @@ export const GraphActivityHud = ({ 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: Array<{ + lane: (typeof visibleLanes)[number]; + shell: HTMLDivElement; + connector: SVGSVGElement | null; + connectorPath: SVGPathElement | null; + laneTopLeft: { x: number; y: number }; + nodeWorld: { x: number; y: number }; + scale: 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); + const connector = connectorRefs.current.get(lane.node.id) ?? null; const connectorPath = connectorPathRefs.current.get(lane.node.id) ?? null; const placement = getActivityAnchorScreenPlacement(lane.node.id); - if (!placement?.visible) { + const laneTopLeft = getActivityAnchorWorldPosition(lane.node.id); + const nodeWorld = getNodeWorldPosition(lane.node.id); + if (!placement || !laneTopLeft || !nodeWorld) { shell.style.opacity = '0'; if (connector) { connector.style.opacity = '0'; @@ -157,27 +188,57 @@ export const GraphActivityHud = ({ continue; } + const scale = Math.max(getCameraZoom(), 0.001); + const widthScreen = Math.max(1, (shell.offsetWidth || ACTIVITY_LANE_WIDTH) * scale); + const heightScreen = Math.max(1, (shell.offsetHeight || 220) * scale); + const viewport = getViewportSize?.(); + const laneVisible = viewport + ? placement.x + widthScreen > -80 && + placement.x < viewport.width + 80 && + placement.y + heightScreen > -80 && + placement.y < viewport.height + 80 + : placement.visible; + + const nodeScreen = getNodeScreenPosition(lane.node.id); + if (!nodeScreen?.visible || !laneVisible) { + shell.style.opacity = '0'; + if (connector) { + connector.style.opacity = '0'; + } + continue; + } + + measurableLanes.push({ + lane, + shell, + connector, + connectorPath, + laneTopLeft, + nodeWorld, + scale, + }); + } + + for (const entry of measurableLanes) { + const { lane, shell, connector, connectorPath, laneTopLeft, nodeWorld, scale } = entry; const baseOpacity = focusNodeIds && !focusNodeIds.has(lane.node.id) ? 0.25 : 1; shell.style.opacity = String(baseOpacity); - shell.style.transform = `translate(${Math.round(placement.x)}px, ${Math.round(placement.y)}px) scale(${placement.scale.toFixed(3)})`; + shell.style.left = `${Math.round(laneTopLeft.x)}px`; + shell.style.top = `${Math.round(laneTopLeft.y)}px`; + shell.style.transform = ''; if (connector && connectorPath) { - const nodeScreen = getNodeScreenPosition(lane.node.id); - if (!nodeScreen?.visible) { - connector.style.opacity = '0'; - continue; - } - const scaledWidth = (shell.offsetWidth || 296) * placement.scale; - const laneCenterX = placement.x + scaledWidth / 2; - const laneIsLeft = laneCenterX < nodeScreen.x; - const endX = laneIsLeft ? placement.x + scaledWidth - 8 : placement.x + 8; - const endY = placement.y + 10 * placement.scale; - const startX = nodeScreen.x; - const startY = nodeScreen.y - 10; + const widthWorld = shell.offsetWidth || ACTIVITY_LANE_WIDTH; + const laneCenterX = laneTopLeft.x + widthWorld / 2; + const laneIsLeft = laneCenterX < nodeWorld.x; + const endX = laneIsLeft ? laneTopLeft.x + widthWorld - 8 : laneTopLeft.x + 8; + const endY = laneTopLeft.y + 10; + const startX = nodeWorld.x; + const startY = nodeWorld.y - 10 / scale; const minX = Math.min(startX, endX); const minY = Math.min(startY, endY); - const width = Math.max(1, Math.abs(endX - startX)); - const height = Math.max(1, Math.abs(endY - startY)); + 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; @@ -192,9 +253,12 @@ export const GraphActivityHud = ({ 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(width))); - connector.setAttribute('height', String(Math.ceil(height))); - connector.setAttribute('viewBox', `0 0 ${Math.ceil(width)} ${Math.ceil(height)}`); + 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)}` @@ -213,7 +277,12 @@ export const GraphActivityHud = ({ enabled, focusNodeIds, getActivityAnchorScreenPlacement, + getActivityAnchorWorldPosition, + getCameraZoom, + getNodeWorldPosition, getNodeScreenPosition, + getViewportSize, + worldToScreen, visibleLanes, ]); @@ -269,100 +338,154 @@ export const GraphActivityHud = ({ [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; + } + 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: Array<{ 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) => 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) => ( -
- { - connectorRefs.current.set(lane.node.id, element); - }} - className="pointer-events-none absolute z-[9] overflow-visible opacity-0" - > - + {visibleLanes.map((lane) => ( +
+ { - connectorPathRefs.current.set(lane.node.id, element); + connectorRefs.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 w-[296px] origin-top-left opacity-0" - > -
- Activity -
-
- {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); + 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 opacity-0" + style={{ width: `${ACTIVITY_LANE_WIDTH}px`, maxWidth: `${ACTIVITY_LANE_WIDTH}px` }} + > +
+ Activity +
+
+ {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); - } - }} + return ( +
handleMessageClick(timelineItem)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleMessageClick(timelineItem); + } + }} + > + +
+ ); + })} + + {lane.overflowCount > 0 ? ( +
- ); - })} - - {lane.overflowCount > 0 ? ( - - ) : null} + +{lane.overflowCount} more + + ) : null} +
-
- ))} + ))} +
( - <> - - - - )} + renderHud={(hudProps) => { + const extraHudProps = hudProps as typeof hudProps & { + getViewportSize?: () => { width: number; height: number }; + getActivityAnchorWorldPosition?: ( + ownerNodeId: string + ) => { x: number; y: number } | null; + getCameraZoom?: () => number; + worldToScreen?: (x: number, y: number) => { x: number; y: number }; + getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null; + }; + const { + getLaunchAnchorScreenPlacement, + getActivityAnchorScreenPlacement, + getViewportSize, + getNodeScreenPosition, + focusNodeIds, + } = extraHudProps; + + return ( + <> + + + + ); + }} renderEdgeOverlay={({ edge, sourceNode, targetNode, onClose: closeEdge, onSelectNode }) => ( setFullscreen(true)} onOpenTeamPage={openTeamPage} onCreateTask={openCreateTask} - renderHud={({ - getLaunchAnchorScreenPlacement, - getActivityAnchorScreenPlacement, - getNodeScreenPosition, - focusNodeIds, - }) => ( - <> - - - - )} + renderHud={(hudProps) => { + const extraHudProps = hudProps as typeof hudProps & { + getViewportSize?: () => { width: number; height: number }; + getActivityAnchorWorldPosition?: ( + ownerNodeId: string + ) => { x: number; y: number } | null; + getCameraZoom?: () => number; + worldToScreen?: (x: number, y: number) => { x: number; y: number }; + getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null; + }; + const { + getLaunchAnchorScreenPlacement, + getActivityAnchorScreenPlacement, + getViewportSize, + getNodeScreenPosition, + focusNodeIds, + } = extraHudProps; + + return ( + <> + + + + ); + }} renderEdgeOverlay={({ edge, sourceNode, targetNode, onClose, onSelectNode }) => ( { className="mt-1 text-xs leading-relaxed" style={{ color: 'var(--color-text-muted)' }} > - 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)} + You can keep using the app without tmux, but installing it is recommended for the best + experience. It enables the more reliable process/tmux path with persistent teammates, + cleaner restarts, and better recovery for long-running tasks.{' '} + {getPrimaryDetail(state.status)}

{state.status.error && (

diff --git a/test/renderer/features/agent-graph/activityLane.test.ts b/test/renderer/features/agent-graph/activityLane.test.ts index 6a4c6b60..3883211a 100644 --- a/test/renderer/features/agent-graph/activityLane.test.ts +++ b/test/renderer/features/agent-graph/activityLane.test.ts @@ -6,6 +6,8 @@ import { getActivityAnchorScreenPlacement, getActivityAnchorTarget, getActivityLaneBounds, + packActivityLaneScreenRects, + packActivityLaneWorldRects, getVisibleActivityWindow, } from '../../../../packages/agent-graph/src/layout/activityLane'; @@ -28,7 +30,7 @@ describe('activity lane helpers', () => { expect(window.overflowCount).toBe(3); }); - it('places the lead lane to the left and member lane to the right', () => { + it('places activity lanes above their owners', () => { const leadTarget = getActivityAnchorTarget({ nodeX: 100, nodeY: 80, nodeKind: 'lead' }); const memberTarget = getActivityAnchorTarget({ nodeX: 100, nodeY: 80, nodeKind: 'member' }); const memberLeftOfLeadTarget = getActivityAnchorTarget({ @@ -38,13 +40,21 @@ describe('activity lane helpers', () => { leadX: 100, }); - expect(leadTarget.x).toBeLessThan(100); - expect(memberTarget.x).toBeGreaterThan(100); - expect(memberLeftOfLeadTarget.x).toBeLessThan(80); + expect(leadTarget.x).toBe(100 - ACTIVITY_LANE.width / 2); + expect(memberTarget.x).toBe(100 - ACTIVITY_LANE.width / 2); + expect(memberLeftOfLeadTarget.x).toBe(80 - ACTIVITY_LANE.width / 2); expect(leadTarget.y).toBeLessThan(80); expect(memberTarget.y).toBeLessThan(80); }); + it('keeps the activity lane fully above the owner node', () => { + const ownerY = 120; + const memberTarget = getActivityAnchorTarget({ nodeX: 100, nodeY: ownerY, nodeKind: 'member' }); + const bounds = getActivityLaneBounds(memberTarget.x, memberTarget.y); + + expect(bounds.bottom).toBeLessThan(ownerY); + }); + it('hits visible activity pills in the owner lane', () => { const node: GraphNode = { id: 'member:team:alice', @@ -80,8 +90,8 @@ describe('activity lane helpers', () => { viewportHeight: 600, }); - expect(placement.x).toBe(40 - ACTIVITY_LANE.width / 2); - expect(placement.y).toBe(60 - (ACTIVITY_LANE.headerHeight + ACTIVITY_LANE.maxVisibleItems * ACTIVITY_LANE.rowHeight + ACTIVITY_LANE.overflowHeight) / 2); + expect(placement.x).toBe(40); + expect(placement.y).toBe(60); expect(placement.visible).toBe(true); }); @@ -99,4 +109,40 @@ describe('activity lane helpers', () => { expect(placement.x).toBeLessThan(0); expect(placement.visible).toBe(true); }); + + it('packs overlapping lanes on the same side without moving independent lanes', () => { + const placements = packActivityLaneScreenRects([ + { id: 'lane-a', side: 'right', x: 400, y: 100, width: 296, height: 220 }, + { id: 'lane-b', side: 'right', x: 420, y: 150, width: 296, height: 220 }, + { id: 'lane-c', side: 'left', x: 120, y: 150, width: 296, height: 220 }, + ]); + + expect(placements.get('lane-a')).toEqual({ x: 400, y: 100 }); + expect(placements.get('lane-b')).toEqual({ x: 420, y: 328 }); + expect(placements.get('lane-c')).toEqual({ x: 120, y: 150 }); + }); + + it('packs world lanes globally even when they came from different legacy sides', () => { + const placements = packActivityLaneWorldRects([ + { id: 'lane-a', side: 'left', x: 100, y: 100, width: 296, height: 220 }, + { id: 'lane-b', side: 'right', x: 120, y: 140, width: 296, height: 220 }, + ]); + + expect(placements.get('lane-a')).toEqual({ x: 100, y: 100 }); + expect(placements.get('lane-b')).toEqual({ x: 120, y: 328 }); + }); + + it('tracks graph zoom so activity lanes behave like world elements', () => { + const placement = getActivityAnchorScreenPlacement({ + anchorX: 40, + anchorY: 60, + cameraX: 0, + cameraY: 0, + zoom: 4, + viewportWidth: 800, + viewportHeight: 600, + }); + + expect(placement.scale).toBe(4); + }); }); diff --git a/test/renderer/features/agent-graph/kanbanLayout.test.ts b/test/renderer/features/agent-graph/kanbanLayout.test.ts index 28a39e4d..930a615b 100644 --- a/test/renderer/features/agent-graph/kanbanLayout.test.ts +++ b/test/renderer/features/agent-graph/kanbanLayout.test.ts @@ -6,6 +6,7 @@ import { getOwnerKanbanBaseX, } from '../../../../packages/agent-graph/src/layout/kanbanLayout'; import { + ACTIVITY_LANE, getActivityAnchorTarget, getActivityLaneBounds, } from '../../../../packages/agent-graph/src/layout/activityLane'; @@ -78,7 +79,7 @@ describe('kanban layout activity-lane avoidance', () => { expect(baseX).toBe(-220); }); - it('keeps member task pills out of the reserved right-side activity lane', () => { + it('keeps member task pills below the reserved activity lane', () => { const lead = createLeadNode(0, 0); const member = createMemberNode('member:jack', 220, 40, 'jack'); const tasks = [ @@ -96,12 +97,12 @@ describe('kanban layout activity-lane avoidance', () => { leadX: lead.x ?? null, }); const laneBounds = getActivityLaneBounds(anchor.x, anchor.y); - const rightmostTaskEdge = Math.max(...tasks.map((task) => (task.x ?? 0) + TASK_PILL.width / 2)); + const topmostTaskEdge = Math.min(...tasks.map((task) => (task.y ?? 0) - TASK_PILL.height / 2)); - expect(rightmostTaskEdge).toBeLessThan(laneBounds.left); + expect(topmostTaskEdge).toBeGreaterThan(laneBounds.bottom); }); - it('keeps member task pills out of the reserved left-side activity lane', () => { + it('keeps left-side member task pills below the reserved activity lane', () => { const lead = createLeadNode(0, 0); const member = createMemberNode('member:alice', -220, 40, 'alice'); const tasks = [ @@ -119,8 +120,33 @@ describe('kanban layout activity-lane avoidance', () => { leadX: lead.x ?? null, }); const laneBounds = getActivityLaneBounds(anchor.x, anchor.y); - const leftmostTaskEdge = Math.min(...tasks.map((task) => (task.x ?? 0) - TASK_PILL.width / 2)); + const topmostTaskEdge = Math.min(...tasks.map((task) => (task.y ?? 0) - TASK_PILL.height / 2)); - expect(leftmostTaskEdge).toBeGreaterThan(laneBounds.right); + expect(topmostTaskEdge).toBeGreaterThan(laneBounds.bottom); + }); + + it('pushes task zones below overlapping activity lanes from nearby owners', () => { + const lead = createLeadNode(0, 0); + const member = createMemberNode('member:alice', 120, 120, 'alice'); + const tasks = [ + createTaskNode('task:todo', member.id, 'pending'), + createTaskNode('task:wip', member.id, 'in_progress'), + ]; + + const nearbyLane = { + ownerId: 'member:tom', + left: 20, + top: -120, + right: 20 + ACTIVITY_LANE.width, + bottom: 180, + }; + + KanbanLayoutEngine.layout([lead, member, ...tasks], { + activityLaneBounds: [nearbyLane], + }); + + const topmostTaskEdge = Math.min(...tasks.map((task) => (task.y ?? 0) - TASK_PILL.height / 2)); + + expect(topmostTaskEdge).toBeGreaterThan(nearbyLane.bottom); }); }); From ff9344c85a8076641ea25dab309a8758ac0d8fe9 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 14 Apr 2026 22:08:10 +0300 Subject: [PATCH 036/121] fix(tmux): polish installer banner state --- .../adapters/TmuxInstallerBannerAdapter.ts | 58 +++++++++++++------ .../TmuxInstallerBannerAdapter.test.ts | 43 ++++++++++++++ .../renderer/ui/TmuxInstallerBannerView.tsx | 31 ++++++---- 3 files changed, 102 insertions(+), 30 deletions(-) diff --git a/src/features/tmux-installer/renderer/adapters/TmuxInstallerBannerAdapter.ts b/src/features/tmux-installer/renderer/adapters/TmuxInstallerBannerAdapter.ts index 1dd97aca..5bc1c58d 100644 --- a/src/features/tmux-installer/renderer/adapters/TmuxInstallerBannerAdapter.ts +++ b/src/features/tmux-installer/renderer/adapters/TmuxInstallerBannerAdapter.ts @@ -50,6 +50,8 @@ interface AdaptInput { detailsOpen: boolean; } +const RESTART_REQUIRED_PATTERNS = ['restart', 'reboot', 'перезагруз', 'требуется перезагрузка']; + export class TmuxInstallerBannerAdapter { static create(): TmuxInstallerBannerAdapter { return new TmuxInstallerBannerAdapter(); @@ -58,19 +60,20 @@ export class TmuxInstallerBannerAdapter { adapt(input: AdaptInput): TmuxInstallerBannerViewModel { const status = input.status; const snapshot = input.snapshot; + const displayPhase = this.#resolveDisplayPhase(snapshot, status); const hasActiveInstallFlow = - snapshot.phase !== 'idle' && snapshot.phase !== 'completed' && snapshot.phase !== 'cancelled'; + displayPhase !== 'idle' && displayPhase !== 'completed' && displayPhase !== 'cancelled'; const tmuxMissing = status ? !status.effective.available : !input.loading; const visible = - hasActiveInstallFlow || (snapshot.phase !== 'completed' && !input.loading && tmuxMissing); + hasActiveInstallFlow || (displayPhase !== 'completed' && !input.loading && tmuxMissing); const title = snapshot.message && - (snapshot.phase === 'pending_external_elevation' || - snapshot.phase === 'waiting_for_external_step' || - snapshot.phase === 'needs_restart' || - snapshot.phase === 'needs_manual_step') + (displayPhase === 'pending_external_elevation' || + displayPhase === 'waiting_for_external_step' || + displayPhase === 'needs_restart' || + displayPhase === 'needs_manual_step') ? snapshot.message - : formatTmuxInstallerTitle(snapshot.phase); + : formatTmuxInstallerTitle(displayPhase); const primaryGuideUrl = status?.autoInstall.manualHints.find((hint) => typeof hint.url === 'string')?.url ?? null; const body = @@ -95,7 +98,7 @@ export class TmuxInstallerBannerAdapter { const manualHints = status?.autoInstall.manualHints ?? []; const manualHintsCollapsible = status?.platform === 'win32' && manualHints.length > 0; const installLabel = - snapshot.phase === 'idle' && + displayPhase === 'idle' && status?.platform === 'win32' && status.autoInstall.strategy === 'wsl' && status.autoInstall.supported @@ -104,16 +107,16 @@ export class TmuxInstallerBannerAdapter { : !status.wsl?.distroName ? 'Install Ubuntu in WSL' : 'Install tmux in WSL' - : formatInstallButtonLabel(snapshot.phase); + : formatInstallButtonLabel(displayPhase); const installDisabled = input.loading || - snapshot.phase === 'preparing' || - snapshot.phase === 'checking' || - snapshot.phase === 'requesting_privileges' || - snapshot.phase === 'pending_external_elevation' || - snapshot.phase === 'waiting_for_external_step' || - snapshot.phase === 'installing' || - snapshot.phase === 'verifying'; + 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 = @@ -131,8 +134,8 @@ export class TmuxInstallerBannerAdapter { locationLabel: formatTmuxLocationLabel(status?.effective.location ?? null), runtimeReadyLabel, versionLabel, - phase: snapshot.phase, - progressPercent: formatTmuxInstallerProgress(snapshot.phase), + phase: displayPhase, + progressPercent: formatTmuxInstallerProgress(displayPhase), logs: snapshot.logs, manualHints, manualHintsCollapsible, @@ -149,4 +152,23 @@ export class TmuxInstallerBannerAdapter { 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 index 2dbb9b7e..1939f8ea 100644 --- a/src/features/tmux-installer/renderer/adapters/__tests__/TmuxInstallerBannerAdapter.test.ts +++ b/src/features/tmux-installer/renderer/adapters/__tests__/TmuxInstallerBannerAdapter.test.ts @@ -350,4 +350,47 @@ describe('TmuxInstallerBannerAdapter', () => { 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/ui/TmuxInstallerBannerView.tsx b/src/features/tmux-installer/renderer/ui/TmuxInstallerBannerView.tsx index 81469306..e40849f9 100644 --- a/src/features/tmux-installer/renderer/ui/TmuxInstallerBannerView.tsx +++ b/src/features/tmux-installer/renderer/ui/TmuxInstallerBannerView.tsx @@ -79,7 +79,7 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null { return (

setExpanded((current) => !current)} - className="flex min-h-[1.75rem] w-full items-center justify-between gap-3 text-left" + className="group flex w-full items-center justify-between gap-3 rounded-md px-1 py-0.5 text-left transition-colors hover:bg-white/[0.03]" > - - + + {viewModel.error ? ( - + ) : ( - + )} {SUMMARY_TITLE} - {expanded ? ( - - ) : ( - - )} + + {expanded ? ( + + ) : ( + + )} + {expanded && ( From 363fef224ddda2eea825a5ed1910b601a4e796ca Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 15 Apr 2026 13:24:04 +0300 Subject: [PATCH 037/121] fix: allow slower codex app-server initialization --- .../CodexRecentProjectsSourceAdapter.ts | 7 ++-- .../codex/CodexAppServerClient.ts | 20 +++++++++-- .../CodexAppServerClient.test.ts | 34 +++++++++++++++++-- 3 files changed, 53 insertions(+), 8 deletions(-) diff --git a/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts b/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts index b8fd002e..fa0af4a9 100644 --- a/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts +++ b/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts @@ -13,14 +13,15 @@ import type { RecentProjectIdentityResolver } from '@features/recent-projects/ma 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_LIVE_FETCH_TIMEOUT_MS + CODEX_ARCHIVED_FETCH_TIMEOUT_MS + CODEX_SESSION_OVERHEAD_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_LIVE_FETCH_TIMEOUT_MS + CODEX_SESSION_OVERHEAD_TIMEOUT_MS + 1_500; + CODEX_INITIALIZE_TIMEOUT_MS + CODEX_LIVE_FETCH_TIMEOUT_MS + CODEX_SESSION_OVERHEAD_TIMEOUT_MS; function isInteractiveSource(source: unknown): boolean { return source === 'vscode' || source === 'cli'; @@ -88,6 +89,7 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor 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, }); @@ -137,6 +139,7 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor 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, }); diff --git a/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts b/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts index 10013c01..56985538 100644 --- a/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts +++ b/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts @@ -2,6 +2,7 @@ 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', @@ -52,6 +53,7 @@ export interface CodexRecentThreadsResult { interface ThreadListSessionOptions { binaryPath: string; requestTimeoutMs: number; + initializeTimeoutMs: number; totalTimeoutMs: number; label: string; } @@ -64,19 +66,25 @@ export class CodexAppServerClient { 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, - requestTimeoutMs + MIN_SESSION_OVERHEAD_TIMEOUT_MS + initializeTimeoutMs + requestTimeoutMs + MIN_SESSION_OVERHEAD_TIMEOUT_MS ); return this.#withThreadListSession( { binaryPath, requestTimeoutMs, + initializeTimeoutMs, totalTimeoutMs, label: 'codex app-server thread/list live', }, @@ -104,21 +112,27 @@ export class CodexAppServerClient { 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, - liveRequestTimeoutMs + archivedRequestTimeoutMs + MIN_SESSION_OVERHEAD_TIMEOUT_MS + initializeTimeoutMs + sessionRequestTimeoutMs + MIN_SESSION_OVERHEAD_TIMEOUT_MS ); return this.#withThreadListSession( { binaryPath, requestTimeoutMs: sessionRequestTimeoutMs, + initializeTimeoutMs, totalTimeoutMs, label: 'codex app-server thread/list', }, @@ -193,7 +207,7 @@ export class CodexAppServerClient { optOutNotificationMethods: SUPPRESSED_NOTIFICATION_METHODS, }, }, - options.requestTimeoutMs + options.initializeTimeoutMs ); await session.notify('initialized'); diff --git a/test/features/recent-projects/main/infrastructure/CodexAppServerClient.test.ts b/test/features/recent-projects/main/infrastructure/CodexAppServerClient.test.ts index 549c8845..3dbc8b74 100644 --- a/test/features/recent-projects/main/infrastructure/CodexAppServerClient.test.ts +++ b/test/features/recent-projects/main/infrastructure/CodexAppServerClient.test.ts @@ -53,7 +53,7 @@ describe('CodexAppServerClient', () => { expect.objectContaining({ binaryPath: '/usr/local/bin/codex', requestTimeoutMs: 4500, - totalTimeoutMs: 8500, + totalTimeoutMs: 12000, }), expect.any(Function) ); @@ -135,7 +135,7 @@ describe('CodexAppServerClient', () => { expect(withSession).toHaveBeenCalledWith( expect.objectContaining({ - totalTimeoutMs: 8500, + totalTimeoutMs: 12000, }), expect.any(Function) ); @@ -171,7 +171,7 @@ describe('CodexAppServerClient', () => { expect.objectContaining({ binaryPath: '/usr/local/bin/codex', requestTimeoutMs: 4500, - totalTimeoutMs: 6000, + totalTimeoutMs: 12000, label: 'codex app-server thread/list live', }), expect.any(Function) @@ -180,4 +180,32 @@ describe('CodexAppServerClient', () => { threads: [{ id: 'live-1', cwd: '/Users/test/live-project', source: 'cli' }], }); }); + + it('uses the longer initialize timeout for app-server startup', async () => { + const request = vi.fn().mockImplementation((method: string, _params?: unknown, timeoutMs?: number) => { + if (method === 'initialize') { + expect(timeoutMs).toBe(6000); + return Promise.resolve({}); + } + + if (method === 'thread/list') { + return Promise.resolve({ data: [] }); + } + + return Promise.reject(new Error(`Unexpected method: ${method}`)); + }); + + const session = createSession(request); + const withSession = vi.fn().mockImplementation((_options, handler) => handler(session)); + const client = new CodexAppServerClient({ withSession } as unknown as JsonRpcStdioClient); + + await client.listRecentThreads('/usr/local/bin/codex', { + limit: 40, + liveRequestTimeoutMs: 4500, + archivedRequestTimeoutMs: 2500, + totalTimeoutMs: 4500, + }); + + expect(request).toHaveBeenCalled(); + }); }); From aed08113e672cb7594324be8bfe078cb8378ed42 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 15 Apr 2026 16:18:11 +0300 Subject: [PATCH 038/121] feat(agent-graph): integrate stable slot layout for improved node positioning and interaction - Added stable slot layout support in various components, enhancing the layout and interaction of nodes. - Updated TypeScript configuration to include new paths for the agent-graph package. - Refactored layout logic in activity lanes and kanban to accommodate stable slot assignments. - Enhanced GraphView and GraphControls to support sidebar visibility toggling and owner slot drop handling. - Introduced new types for layout management in GraphDataPort and related files. - Updated README to include stable slot layout documentation. --- .../src/constants/canvas-constants.ts | 6 +- .../src/hooks/useGraphInteraction.ts | 4 +- .../src/hooks/useGraphSimulation.ts | 978 +++--- packages/agent-graph/src/index.ts | 4 + .../agent-graph/src/layout/activityLane.ts | 102 +- .../agent-graph/src/layout/kanbanLayout.ts | 129 +- .../agent-graph/src/layout/launchAnchor.ts | 15 +- .../src/layout/stableSlotGeometry.ts | 158 + .../agent-graph/src/layout/stableSlots.ts | 1291 ++++++++ .../agent-graph/src/ports/GraphDataPort.ts | 4 +- packages/agent-graph/src/ports/index.ts | 4 + packages/agent-graph/src/ports/types.ts | 13 + packages/agent-graph/src/ui/GraphControls.tsx | 28 + packages/agent-graph/src/ui/GraphView.tsx | 136 +- src/features/agent-graph/README.md | 1 + .../agent-graph/STABLE_SLOT_LAYOUT_PLAN.md | 2846 +++++++++++++++++ .../core/domain/buildInlineActivityEntries.ts | 78 +- .../core/domain/graphOwnerIdentity.ts | 30 + .../renderer/adapters/TeamGraphAdapter.ts | 262 +- .../renderer/hooks/useGraphActivityContext.ts | 17 + .../hooks/useGraphSidebarVisibility.ts | 52 + .../renderer/hooks/useTeamGraphAdapter.ts | 17 +- .../hooks/useTeamGraphSurfaceActions.ts | 56 + .../renderer/ui/GraphActivityHud.tsx | 49 +- .../renderer/ui/TeamGraphOverlay.tsx | 23 +- .../agent-graph/renderer/ui/TeamGraphTab.tsx | 27 +- .../TmuxInstallerRunnerAdapter.test.ts | 16 +- .../__tests__/TmuxStatusSourceAdapter.test.ts | 39 +- .../installer/TmuxCommandRunner.ts | 2 +- .../renderer/ui/TmuxInstallerBannerView.tsx | 5 +- .../infrastructure/NotificationManager.ts | 36 +- src/main/services/team/TeamMemberResolver.ts | 31 +- src/renderer/api/httpClient.ts | 2 + .../team/members/MemberDetailDialog.tsx | 4 +- src/renderer/store/slices/teamSlice.ts | 260 ++ src/shared/types/team.ts | 2 + src/shared/utils/teamStableOwnerId.ts | 12 + .../utils/recentProjectsClientCache.test.ts | 18 +- .../dateGroupedSessionsSelection.test.ts | 87 +- .../team/teamProjectSelection.test.ts | 9 +- .../agent-graph/GraphActivityHud.test.ts | 119 + .../agent-graph/GraphControls.test.ts | 115 + .../agent-graph/TeamGraphAdapter.test.ts | 306 ++ .../buildInlineActivityEntries.test.ts | 37 + .../features/agent-graph/drawAgents.test.ts | 111 + .../features/agent-graph/kanbanLayout.test.ts | 165 +- .../useGraphSidebarVisibility.test.ts | 105 + .../agent-graph/useGraphSimulation.test.ts | 387 ++- test/renderer/store/teamSlice.test.ts | 82 + tsconfig.json | 1 + tsconfig.node.json | 1 + vitest.config.ts | 3 + 52 files changed, 7258 insertions(+), 1027 deletions(-) create mode 100644 packages/agent-graph/src/layout/stableSlotGeometry.ts create mode 100644 packages/agent-graph/src/layout/stableSlots.ts create mode 100644 src/features/agent-graph/STABLE_SLOT_LAYOUT_PLAN.md create mode 100644 src/features/agent-graph/core/domain/graphOwnerIdentity.ts create mode 100644 src/features/agent-graph/renderer/hooks/useGraphActivityContext.ts create mode 100644 src/features/agent-graph/renderer/hooks/useGraphSidebarVisibility.ts create mode 100644 src/features/agent-graph/renderer/hooks/useTeamGraphSurfaceActions.ts create mode 100644 src/shared/utils/teamStableOwnerId.ts create mode 100644 test/renderer/features/agent-graph/GraphControls.test.ts create mode 100644 test/renderer/features/agent-graph/drawAgents.test.ts create mode 100644 test/renderer/features/agent-graph/useGraphSidebarVisibility.test.ts diff --git a/packages/agent-graph/src/constants/canvas-constants.ts b/packages/agent-graph/src/constants/canvas-constants.ts index 59d32dcc..e45353da 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). @@ -262,8 +264,8 @@ export const KANBAN_ZONE = { rowHeight: 46, /** 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 2955c737..ae2e8e39 100644 --- a/packages/agent-graph/src/hooks/useGraphSimulation.ts +++ b/packages/agent-graph/src/hooks/useGraphSimulation.ts @@ -1,64 +1,21 @@ -/** - * 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 { + buildStableSlotLayoutSnapshot, + resolveNearestSlotAssignment, + snapshotToWorldBounds, + translateSlotFrame, + validateStableSlotLayout, + type StableSlotLayoutSnapshot, + type SlotFrame, +} from '../layout/stableSlots'; import { KanbanLayoutEngine } from '../layout/kanbanLayout'; -import { - LAUNCH_ANCHOR_LAYOUT, - getActivityAnchorId, - getLaunchAnchorBounds, - getLaunchAnchorId, - getLaunchAnchorTarget, - isActivityAnchorId, - isLaunchAnchorId, - type WorldBounds, -} from '../layout/launchAnchor'; -import { - ACTIVITY_ANCHOR_LAYOUT, - buildVisibleActivityLaneBounds, - getActivityLaneBounds, - getActivityAnchorTarget, - packActivityLaneWorldRects, - resolveActivityLaneSide, -} from '../layout/activityLane'; -// ─── Force Node/Link types (properly typed, no loose `string`) ────────────── - -type InternalNodeKind = GraphNodeKind | 'launch-anchor' | 'activity-anchor'; - -interface ForceNode extends SimulationNodeDatum { - id: string; - kind: 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[]; @@ -69,133 +26,31 @@ 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; + resolveNearestOwnerSlot: ( + nodeId: string, + x: number, + y: number + ) => { + assignment: GraphOwnerSlotAssignment; + displacedOwnerId?: string; + displacedAssignment?: GraphOwnerSlotAssignment; + } | null; getLaunchAnchorWorldPosition: (leadNodeId: string) => { x: number; y: number } | null; getActivityAnchorWorldPosition: (nodeId: string) => { x: number; y: number } | 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; - const pendingActivityAnchors: Array<{ - node: ForceNode; - target: { x: number; y: number }; - side: 'left' | 'right'; - }> = []; - - for (const node of forceNodes) { - if (node.kind === 'launch-anchor' && node.anchorForLeadId) { - const leadNode = forceNodeMap.get(node.anchorForLeadId); - if (!leadNode) continue; - const target = getLaunchAnchorTarget(leadNode.x ?? 0, leadNode.y ?? 0); - node.fx = target.x; - node.fy = target.y; - node.x = target.x; - node.y = target.y; - node.vx = 0; - node.vy = 0; - continue; - } - - if (node.kind === 'activity-anchor' && node.anchorForNodeId) { - const ownerNode = forceNodeMap.get(node.anchorForNodeId); - if (!ownerNode || (ownerNode.kind !== 'lead' && ownerNode.kind !== 'member')) continue; - const target = getActivityAnchorTarget({ - nodeX: ownerNode.x ?? 0, - nodeY: ownerNode.y ?? 0, - nodeKind: ownerNode.kind, - leadX, - }); - pendingActivityAnchors.push({ - node, - target, - side: resolveActivityLaneSide({ - nodeKind: ownerNode.kind, - nodeX: ownerNode.x ?? 0, - leadX, - }), - }); - } - } - - const packedActivityAnchors = packActivityLaneWorldRects( - pendingActivityAnchors.map(({ node, target, side }) => ({ - id: node.id, - side, - x: target.x, - y: target.y, - width: ACTIVITY_ANCHOR_LAYOUT.reservedWidth, - height: ACTIVITY_ANCHOR_LAYOUT.reservedHeight, - })), - 18, - ); - - for (const entry of pendingActivityAnchors) { - const packed = packedActivityAnchors.get(entry.node.id); - const centerX = (packed?.x ?? entry.target.x) - + ACTIVITY_ANCHOR_LAYOUT.reservedWidth / 2; - const centerY = (packed?.y ?? entry.target.y) - + ACTIVITY_ANCHOR_LAYOUT.reservedHeight / 2; - entry.node.fx = centerX; - entry.node.fy = centerY; - entry.node.x = centerX; - entry.node.y = centerY; - entry.node.vx = 0; - entry.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) { - const topLeft = { - x: x - ACTIVITY_ANCHOR_LAYOUT.reservedWidth / 2, - y: y - ACTIVITY_ANCHOR_LAYOUT.reservedHeight / 2, - }; - activityPositions.set(node.anchorForNodeId, topLeft); - bounds.push(getActivityLaneBounds(topLeft.x, topLeft.y)); - } - } -} - -// ─── Hook ─────────────────────────────────────────────────────────────────── - export function useGraphSimulation(): UseGraphSimulationResult { const stateRef = useRef({ nodes: [], @@ -204,313 +59,479 @@ 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 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, { - activityLaneBounds: buildVisibleActivityLaneBounds( - nodes, - activityAnchorPositionsRef.current - ), - }); - 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, + activityAnchorPositionsRef, + 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, + activityAnchorPositionsRef, + 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; - } - } + resetToFallbackLayout({ + nodes: state.nodes, + layoutSnapshotRef, + launchAnchorPositionsRef, + activityAnchorPositionsRef, + extraWorldBoundsRef, + }); + }, []); - // 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)); - } + const updateData = useCallback( + ( + nodes: GraphNode[], + edges: GraphEdge[], + particles: GraphParticle[], + teamName: string, + layout?: GraphLayoutPort + ) => { + const state = stateRef.current; + teamNameRef.current = teamName; + layoutRef.current = layout; - // 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))); - } - } + 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])); - // 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); + applyCurrentLayout(); + }, + [applyCurrentLayout] + ); - 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 - ); + 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 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 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 sim = simRef.current; - if (!sim) { - return; - } + const clearNodePosition = useCallback( + (nodeId: string) => { + if (!dragOwnerPositionsRef.current.delete(nodeId)) { + return; + } + 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(); + activityAnchorPositionsRef.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, + clearNodePosition, + resolveNearestOwnerSlot, + getLaunchAnchorWorldPosition: (leadNodeId: string) => + launchAnchorPositionsRef.current.get(leadNodeId) ?? null, getActivityAnchorWorldPosition: (nodeId: string) => activityAnchorPositionsRef.current.get(nodeId) ?? null, - getExtraWorldBounds, + 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 leadId = snapshot.leadNodeId; + + for (const node of nodes) { + if (node.kind === 'lead' && node.id === leadId) { + node.x = 0; + node.y = 0; + node.fx = 0; + node.fy = 0; + 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, translatedFrames); + KanbanLayoutEngine.layout(nodes, { + memberSlotFrames: translatedFrames, + 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 }; + activityAnchorPositionsRef: { current: Map }; + extraWorldBoundsRef: { current: WorldBounds[] }; + fillMissingFallbackPositions?: boolean; +}): void { + const { + nodes, + snapshot, + teamName, + layoutSnapshotRef, + lastValidSnapshotByTeamRef, + dragOwnerPositionsRef, + launchAnchorPositionsRef, + activityAnchorPositionsRef, + extraWorldBoundsRef, + fillMissingFallbackPositions = false, + } = args; + + layoutSnapshotRef.current = snapshot; + lastValidSnapshotByTeamRef.current.set(teamName, snapshot); + applySnapshotToNodes(nodes, snapshot, dragOwnerPositionsRef.current); + if (fillMissingFallbackPositions) { + fallbackPositionNodes(nodes); + } + + launchAnchorPositionsRef.current.clear(); + activityAnchorPositionsRef.current.clear(); + extraWorldBoundsRef.current = snapshotToWorldBounds(snapshot); + + if (snapshot.leadNodeId && snapshot.launchAnchor) { + launchAnchorPositionsRef.current.set(snapshot.leadNodeId, snapshot.launchAnchor); + } + + for (const frame of getTranslatedMemberFrames(snapshot, dragOwnerPositionsRef.current)) { + activityAnchorPositionsRef.current.set(frame.ownerId, { + x: frame.activityRect.left, + y: frame.activityRect.top, + }); + } + + activityAnchorPositionsRef.current.set(`lead:${teamName}`, { + x: snapshot.leadActivityRect.left, + y: snapshot.leadActivityRect.top, + }); +} + +function resetToFallbackLayout(args: { + nodes: GraphNode[]; + layoutSnapshotRef: { current: StableSlotLayoutSnapshot | null }; + launchAnchorPositionsRef: { current: Map }; + activityAnchorPositionsRef: { current: Map }; + extraWorldBoundsRef: { current: WorldBounds[] }; +}): void { + const { + nodes, + layoutSnapshotRef, + launchAnchorPositionsRef, + activityAnchorPositionsRef, + extraWorldBoundsRef, + } = args; + + layoutSnapshotRef.current = null; + launchAnchorPositionsRef.current.clear(); + activityAnchorPositionsRef.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; @@ -523,68 +544,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, { - activityLaneBounds: buildVisibleActivityLaneBounds(state.nodes, activityAnchorPositions), - }); - - // 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 46b1810c..dfae2843 100644 --- a/packages/agent-graph/src/layout/activityLane.ts +++ b/packages/agent-graph/src/layout/activityLane.ts @@ -1,37 +1,20 @@ -import { CAMERA, 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: 72, - rowHeight: 80, - maxVisibleItems: 3, - headerHeight: 20, - overflowHeight: 32, - horizontalGapLead: 76, - horizontalGapMember: 84, - ownerClearanceLead: 92, - ownerClearanceMember: 104, - viewportPadding: 12, - visiblePadding: 80, - minScale: CAMERA.minZoom, - maxScale: CAMERA.maxZoom, -} 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 + NODE.radiusMember + ACTIVITY_LANE.ownerClearanceMember), - leadOffsetX: -(ACTIVITY_LANE.width / 2 + NODE.radiusLead + ACTIVITY_LANE.horizontalGapLead), - leadOffsetY: -(RESERVED_HEIGHT + NODE.radiusLead + ACTIVITY_LANE.ownerClearanceLead), - collisionRadius: Math.ceil(Math.hypot(ACTIVITY_LANE.width / 2, RESERVED_HEIGHT / 2)) + 56, -} as const; +export const ACTIVITY_LANE = STABLE_SLOT_ACTIVITY.lane; +export const ACTIVITY_ANCHOR_LAYOUT = STABLE_SLOT_ACTIVITY.anchor; export interface ActivityLaneWindow { items: GraphActivityItem[]; @@ -226,29 +209,14 @@ function packActivityLaneRects { const placements = new Map(); - - const sideGroups = groupBySide ? (['left', 'right'] as const) : (['left'] as const); - - for (const side of sideGroups) { + 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: Array = []; + const placed: (T & { placedY: number })[] = []; for (const rect of sideRects) { - 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; - } - } - + const placedY = resolvePackedActivityY(rect, placed, gap); placed.push({ ...rect, placedY }); placements.set(rect.id, { x: rect.x, y: placedY }); } @@ -282,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 }; } } } @@ -303,3 +275,33 @@ export function isActivityOwner(node: GraphNode): node is GraphNode & { kind: 'l 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 9618f491..9356f7c0 100644 --- a/packages/agent-graph/src/layout/kanbanLayout.ts +++ b/packages/agent-graph/src/layout/kanbanLayout.ts @@ -12,6 +12,7 @@ import { KANBAN_ZONE, TASK_PILL } from '../constants/canvas-constants'; import { COLORS } from '../constants/colors'; import { resolveActivityLaneSide } from './activityLane'; import type { ActivityLaneWorldBounds } from './activityLane'; +import type { SlotFrame, StableRect } from './stableSlots'; /** Column header info for rendering */ export interface KanbanColumnHeader { @@ -81,7 +82,7 @@ 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. @@ -89,22 +90,42 @@ export class KanbanLayoutEngine { */ static layout( nodes: GraphNode[], - options?: { activityLaneBounds?: readonly ActivityLaneWorldBounds[] } + options?: { + activityLaneBounds?: readonly ActivityLaneWorldBounds[]; + memberSlotFrames?: readonly SlotFrame[]; + 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 activityLaneBounds = options?.activityLaneBounds ?? []; + const memberSlotFrameByOwnerId = new Map( + (options?.memberSlotFrames ?? []).map((frame) => [frame.ownerId, frame] as const) + ); 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 true; + } + if (owner.kind === 'member') { + return memberSlotFrameByOwnerId.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 = []; @@ -117,22 +138,23 @@ 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; + if (owner?.x == null || owner?.y == null) continue; const zoneInfo = KanbanLayoutEngine.#layoutZone( tasks, owner, ownerId, leadX, - activityLaneBounds + activityLaneBounds, + memberSlotFrameByOwnerId.get(ownerId) ?? null ); if (zoneInfo) this.zones.push(zoneInfo); } - KanbanLayoutEngine.#layoutUnassigned(unassigned, nodes); + KanbanLayoutEngine.#layoutUnassigned(unassigned, nodes, options?.unassignedTaskRect ?? null); } // ─── Private ────────────────────────────────────────────────────────────── @@ -142,7 +164,8 @@ export class KanbanLayoutEngine { owner: GraphNode, ownerId: string, leadX: number | null, - activityLaneBounds: readonly ActivityLaneWorldBounds[] + activityLaneBounds: readonly ActivityLaneWorldBounds[], + slotFrame: SlotFrame | null ): KanbanZoneInfo | null { const { columnWidth, rowHeight, offsetY, columns } = KANBAN_ZONE; const headerHeight = 20; // space for column header label @@ -172,31 +195,38 @@ export class KanbanLayoutEngine { // 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, }); - const taskZoneLeft = baseX - TASK_PILL.width / 2; - const taskZoneRight = - baseX + (activeColumns.length - 1) * columnWidth + TASK_PILL.width / 2; - const overlappingActivityBottom = activityLaneBounds.reduce((maxBottom, bounds) => { - if (bounds.ownerId === ownerId) { + let baseY: number; + + if (slotFrame) { + baseX = slotFrame.taskBandRect.left + TASK_PILL.width / 2; + baseY = slotFrame.taskBandRect.top; + } else { + const taskZoneLeft = baseX - TASK_PILL.width / 2; + const taskZoneRight = + baseX + (activeColumns.length - 1) * columnWidth + TASK_PILL.width / 2; + const overlappingActivityBottom = activityLaneBounds.reduce((maxBottom, bounds) => { + if (bounds.ownerId === ownerId) { + return Math.max(maxBottom, bounds.bottom); + } + if (!rangesOverlap(taskZoneLeft, taskZoneRight, bounds.left, bounds.right)) { + return maxBottom; + } return Math.max(maxBottom, bounds.bottom); - } - if (!rangesOverlap(taskZoneLeft, taskZoneRight, bounds.left, bounds.right)) { - return maxBottom; - } - return Math.max(maxBottom, bounds.bottom); - }, -Infinity); - const baseY = Math.max( - ownerY + offsetY, - overlappingActivityBottom > -Infinity - ? overlappingActivityBottom + ACTIVITY_KANBAN_CLEARANCE - : -Infinity - ); + }, -Infinity); + baseY = Math.max( + ownerY + offsetY, + overlappingActivityBottom > -Infinity + ? overlappingActivityBottom + ACTIVITY_KANBAN_CLEARANCE + : -Infinity + ); + } // Build headers + position tasks const headers: KanbanColumnHeader[] = []; @@ -221,8 +251,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; @@ -246,11 +276,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 4d3dad83..01bd4436 100644 --- a/packages/agent-graph/src/layout/launchAnchor.ts +++ b/packages/agent-graph/src/layout/launchAnchor.ts @@ -3,6 +3,7 @@ import { ACTIVITY_ANCHOR_LAYOUT, resolveActivityLaneSide, } from './activityLane'; +import { createStableSlotLaunchAnchorLayout } from './stableSlotGeometry'; export interface WorldBounds { left: number; @@ -18,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__:'; diff --git a/packages/agent-graph/src/layout/stableSlotGeometry.ts b/packages/agent-graph/src/layout/stableSlotGeometry.ts new file mode 100644 index 00000000..a9e58b13 --- /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: 72, + 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..cf11900f --- /dev/null +++ b/packages/agent-graph/src/layout/stableSlots.ts @@ -0,0 +1,1291 @@ +import { KANBAN_ZONE, TASK_PILL } from '../constants/canvas-constants'; +import type { GraphLayoutPort, GraphNode, GraphOwnerSlotAssignment } from '../ports/types'; +import { ACTIVITY_ANCHOR_LAYOUT, ACTIVITY_LANE } from './activityLane'; +import { LAUNCH_ANCHOR_LAYOUT, 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; + activityWidth: number; + activityHeight: number; + processRailWidth: number; + taskBandWidth: number; + taskBandHeight: number; + taskColumnCount: number; + processCount: number; +} + +export interface SlotFrame { + ownerId: string; + ringIndex: number; + sectorIndex: number; + widthBucket: StableSlotWidthBucket; + bounds: StableRect; + ownerX: number; + ownerY: number; + activityRect: StableRect; + processBandRect: StableRect; + taskBandRect: StableRect; + taskColumnCount: number; +} + +export interface StableSlotLayoutSnapshot { + version: GraphLayoutPort['version']; + teamName: string; + leadNodeId: string | null; + leadCoreRect: StableRect; + leadActivityRect: StableRect; + launchHudRect: StableRect; + launchAnchor: { x: number; y: number } | null; + leadCentralReservedBlock: StableRect; + runtimeCentralExclusion: 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; +} + +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, + activityHeight: ACTIVITY_ANCHOR_LAYOUT.reservedHeight, + activityWidth: ACTIVITY_LANE.width, + activityToOwnerGap: STABLE_SLOT_GEOMETRY.slotVerticalGap, + ownerToProcessGap: STABLE_SLOT_GEOMETRY.slotVerticalGap, + processToTaskGap: STABLE_SLOT_GEOMETRY.slotVerticalGap, + taskBandHeight: + ACTIVITY_LANE.headerHeight + + STABLE_SLOT_GEOMETRY.taskMaxVisibleRows * KANBAN_ZONE.rowHeight, + centralPadding: STABLE_SLOT_GEOMETRY.centralSafetyPadding, +} as const; + +const SECTOR_VECTORS = STABLE_SLOT_SECTOR_VECTORS; + +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, 168); + const leadActivityRect = createRect( + leadCoreRect.left - SLOT_GEOMETRY.centralBlockGap - ACTIVITY_LANE.width, + -ACTIVITY_ANCHOR_LAYOUT.reservedHeight / 2, + ACTIVITY_LANE.width, + ACTIVITY_ANCHOR_LAYOUT.reservedHeight + ); + const launchHudRect = createRect( + leadCoreRect.right + SLOT_GEOMETRY.centralBlockGap, + -LAUNCH_ANCHOR_LAYOUT.compactHeight / 2, + LAUNCH_ANCHOR_LAYOUT.compactWidth, + LAUNCH_ANCHOR_LAYOUT.compactHeight + ); + const leadCentralReservedBlock = unionRects([leadCoreRect, leadActivityRect, launchHudRect]); + + const ownerFootprints = computeOwnerFootprints(nodes, layout); + const unassignedTaskRect = buildUnassignedTaskRect(nodes, leadCentralReservedBlock); + const runtimeCentralExclusion = padRect( + unionRects( + unassignedTaskRect + ? [leadCentralReservedBlock, unassignedTaskRect] + : [leadCentralReservedBlock] + ), + SLOT_GEOMETRY.centralPadding + ); + + const memberSlotFrames = planOwnerSlots(ownerFootprints, runtimeCentralExclusion, layout); + const memberSlotFrameByOwnerId = new Map( + memberSlotFrames.map((frame) => [frame.ownerId, frame] as const) + ); + const fitBounds = unionRects( + [ + leadCentralReservedBlock, + leadActivityRect, + launchHudRect, + runtimeCentralExclusion, + ...memberSlotFrames.map((frame) => frame.bounds), + ...(unassignedTaskRect ? [unassignedTaskRect] : []), + ].filter(Boolean) + ); + + return { + version: layout?.version ?? 'stable-slots-v1', + teamName, + leadNodeId: leadNode.id, + leadCoreRect, + leadActivityRect, + launchHudRect, + launchAnchor: { + x: launchHudRect.left + launchHudRect.width / 2, + y: launchHudRect.top + launchHudRect.height / 2, + }, + leadCentralReservedBlock, + runtimeCentralExclusion, + memberSlotFrames, + memberSlotFrameByOwnerId, + unassignedTaskRect, + fitBounds, + }; +} + +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 []; + } + + const taskColumnCount = taskColumnsByOwnerId.get(ownerId)?.size ?? 0; + const taskBandWidth = + taskColumnCount <= 1 + ? TASK_PILL.width + : TASK_PILL.width + (taskColumnCount - 1) * KANBAN_ZONE.columnWidth; + const innerContentWidth = Math.max( + SLOT_GEOMETRY.activityWidth, + SLOT_GEOMETRY.ownerMinWidth, + SLOT_GEOMETRY.processRailWidth, + taskBandWidth + ); + const slotWidth = innerContentWidth + SLOT_GEOMETRY.memberSlotInnerPadding * 2; + const slotHeight = + SLOT_GEOMETRY.memberSlotInnerPadding * 2 + + SLOT_GEOMETRY.activityHeight + + SLOT_GEOMETRY.activityToOwnerGap + + SLOT_GEOMETRY.ownerBandHeight + + SLOT_GEOMETRY.ownerToProcessGap + + SLOT_GEOMETRY.processBandHeight + + SLOT_GEOMETRY.processToTaskGap + + SLOT_GEOMETRY.taskBandHeight; + const radialDepth = Math.max( + SLOT_GEOMETRY.memberSlotInnerPadding + + SLOT_GEOMETRY.activityHeight + + SLOT_GEOMETRY.activityToOwnerGap + + SLOT_GEOMETRY.ownerBandHeight / 2, + SLOT_GEOMETRY.memberSlotInnerPadding + + SLOT_GEOMETRY.ownerBandHeight / 2 + + SLOT_GEOMETRY.ownerToProcessGap + + SLOT_GEOMETRY.processBandHeight + + SLOT_GEOMETRY.processToTaskGap + + SLOT_GEOMETRY.taskBandHeight + ); + + return [ + { + ownerId, + slotWidth, + slotHeight, + widthBucket: classifyWidthBucket(slotWidth), + radialDepth, + activityWidth: SLOT_GEOMETRY.activityWidth, + activityHeight: SLOT_GEOMETRY.activityHeight, + processRailWidth: SLOT_GEOMETRY.processRailWidth, + taskBandWidth, + taskBandHeight: SLOT_GEOMETRY.taskBandHeight, + taskColumnCount, + processCount: processCountByOwnerId.get(ownerId) ?? 0, + } satisfies OwnerFootprint, + ]; + }); +} + +export function classifyWidthBucket(width: number): StableSlotWidthBucket { + if (width <= 340) { + return 'S'; + } + if (width <= 560) { + return 'M'; + } + return 'L'; +} + +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 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, + 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, + } + : null; +} + +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], + ['leadActivityRect', snapshot.leadActivityRect], + ['launchHudRect', snapshot.launchHudRect], + ['leadCentralReservedBlock', snapshot.leadCentralReservedBlock], + ['runtimeCentralExclusion', snapshot.runtimeCentralExclusion], + ['fitBounds', snapshot.fitBounds], + ]; + + 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 { + 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 (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.launchHudRect)) { + return { valid: false, reason: 'launchHudRect must fit inside leadCentralReservedBlock' }; + } + if (!rectContainsRect(snapshot.runtimeCentralExclusion, snapshot.leadCentralReservedBlock)) { + return { valid: false, reason: 'runtimeCentralExclusion must contain leadCentralReservedBlock' }; + } + if ( + snapshot.unassignedTaskRect && + !rectContainsRect(snapshot.runtimeCentralExclusion, snapshot.unassignedTaskRect) + ) { + return { valid: false, reason: 'runtimeCentralExclusion must contain unassignedTaskRect' }; + } + + return null; +} + +function validateMemberSlotFrame( + frame: SlotFrame, + snapshot: StableSlotLayoutSnapshot, + seenOwnerIds: Set, + seenAssignments: Set +): StableSlotLayoutValidationResult | null { + if (!isFiniteRect(frame.bounds)) { + return { valid: false, reason: `slot frame for ${frame.ownerId} contains non-finite bounds` }; + } + if (!Number.isFinite(frame.ownerX) || !Number.isFinite(frame.ownerY)) { + return { valid: false, reason: `slot frame for ${frame.ownerId} contains non-finite anchor` }; + } + 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 (rectsOverlap(frame.bounds, snapshot.runtimeCentralExclusion)) { + return { valid: false, reason: `slot frame for ${frame.ownerId} overlaps runtimeCentralExclusion` }; + } + if (!rectContainsRect(frame.bounds, frame.activityRect)) { + return { valid: false, reason: `activityRect escapes slot bounds for ${frame.ownerId}` }; + } + if (!rectContainsRect(frame.bounds, frame.processBandRect)) { + return { valid: false, reason: `processBandRect escapes slot bounds for ${frame.ownerId}` }; + } + if (!rectContainsRect(frame.bounds, frame.taskBandRect)) { + return { valid: false, reason: `taskBandRect escapes slot bounds for ${frame.ownerId}` }; + } + if (!pointInRect(frame.ownerX, frame.ownerY, frame.bounds)) { + return { valid: false, reason: `owner anchor escapes slot bounds for ${frame.ownerId}` }; + } + if (!rectContainsRect(snapshot.fitBounds, frame.bounds)) { + return { valid: false, reason: `slot frame for ${frame.ownerId} 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, + activityRect: translateRect(frame.activityRect, dx, dy), + processBandRect: translateRect(frame.processBandRect, dx, dy), + taskBandRect: translateRect(frame.taskBandRect, 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.taskBandHeight; + return createRect( + -width / 2, + leadCentralReservedBlock.bottom + SLOT_GEOMETRY.unassignedGap, + width, + height + ); +} + +function planOwnerSlots( + ownerFootprints: OwnerFootprint[], + centralExclusion: StableRect, + layout?: GraphLayoutPort +): SlotFrame[] { + 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, + centralExclusion, + ringStates, + preferredAssignment: preferredAssignments.get(footprint.ownerId), + usedSlotKeys, + placedFrames, + maxRingExclusive, + }); + placedFrames.push(resolvedFrame); + commitRingPlacement(ringStates, resolvedFrame, footprint); + } + + return placedFrames; +} + +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; + centralExclusion: StableRect; + ringStates: RingLayoutStateMap; + preferredAssignment?: GraphOwnerSlotAssignment; + usedSlotKeys: Set; + placedFrames: readonly SlotFrame[]; + maxRingExclusive: number; +}): SlotFrame { + const { + footprint, + centralExclusion, + ringStates, + preferredAssignment, + usedSlotKeys, + placedFrames, + maxRingExclusive, + } = args; + + const candidates = preferredAssignment + ? buildPreferredCandidateAssignments(preferredAssignment, maxRingExclusive) + : buildCandidateAssignments(maxRingExclusive); + const directMatch = findFirstValidSlotFrame({ + candidateAssignments: candidates, + footprint, + centralExclusion, + 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, + centralExclusion, + ringStates, + usedSlotKeys, + placedFrames, + }); + if (spilloverMatch) { + return spilloverMatch; + } + + return buildEmergencyFallbackSlotFrame({ + footprint, + centralExclusion, + ringStates, + usedSlotKeys, + placedOwnerCount: placedFrames.length, + baseRingIndex: maxRingExclusive + ownerFootprintsSpillBudget(placedFrames.length), + }); +} + +function buildSlotFrame( + footprint: OwnerFootprint, + assignment: GraphOwnerSlotAssignment, + centralExclusion: StableRect, + options: { ringStates: RingLayoutStateMap } +): SlotFrame | null { + const vector = SECTOR_VECTORS[assignment.sectorIndex % SECTOR_VECTORS.length] ?? SECTOR_VECTORS[0]; + const radius = resolveRingRadiusForAssignment({ + assignment, + footprint, + centralExclusion, + ringStates: options.ringStates, + }); + if (radius == null) { + return null; + } + const ownerX = vector.x * radius; + const ownerY = vector.y * radius; + const slotTop = + ownerY - + (SLOT_GEOMETRY.memberSlotInnerPadding + + SLOT_GEOMETRY.activityHeight + + SLOT_GEOMETRY.activityToOwnerGap + + SLOT_GEOMETRY.ownerBandHeight / 2); + const bounds = createRect(ownerX - footprint.slotWidth / 2, slotTop, footprint.slotWidth, footprint.slotHeight); + const activityRect = createRect( + bounds.left + (bounds.width - footprint.activityWidth) / 2, + bounds.top + SLOT_GEOMETRY.memberSlotInnerPadding, + footprint.activityWidth, + footprint.activityHeight + ); + const processBandRect = createRect( + bounds.left + (bounds.width - footprint.processRailWidth) / 2, + activityRect.bottom + + SLOT_GEOMETRY.activityToOwnerGap + + SLOT_GEOMETRY.ownerBandHeight + + SLOT_GEOMETRY.ownerToProcessGap, + footprint.processRailWidth, + SLOT_GEOMETRY.processBandHeight + ); + const taskBandRect = createRect( + bounds.left + (bounds.width - footprint.taskBandWidth) / 2, + processBandRect.bottom + SLOT_GEOMETRY.processToTaskGap, + footprint.taskBandWidth, + footprint.taskBandHeight + ); + + return { + ownerId: footprint.ownerId, + ringIndex: assignment.ringIndex, + sectorIndex: assignment.sectorIndex, + widthBucket: footprint.widthBucket, + bounds, + ownerX, + ownerY, + activityRect, + processBandRect, + taskBandRect, + 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; + centralExclusion: 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.centralExclusion, { + 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[]; + runtimeCentralExclusion: StableRect; + ringStates: RingLayoutStateMap; + pointerX: number; + pointerY: number; +}): RankedNearestSlotAssignmentResult | null { + const { + assignment, + occupiedFrame, + footprint, + footprintByOwnerId, + currentFrame, + existingFrames, + runtimeCentralExclusion, + ringStates, + pointerX, + pointerY, + } = args; + const frame = buildSlotFrame(footprint, assignment, runtimeCentralExclusion, { + ringStates, + }); + if (!frame) { + return null; + } + + if (occupiedFrame) { + const displacedFrame = buildDisplacedFrameForNearestAssignment({ + occupiedFrame, + footprintByOwnerId, + currentFrame, + runtimeCentralExclusion, + ringStates, + }); + if (!displacedFrame) { + return null; + } + const otherFrames = existingFrames.filter((existing) => existing.ownerId !== occupiedFrame.ownerId); + if ( + !isSlotFramePlacementValid(frame, otherFrames, runtimeCentralExclusion) || + !isSlotFramePlacementValid(displacedFrame, otherFrames, runtimeCentralExclusion) || + 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, runtimeCentralExclusion)) { + return null; + } + + return buildRankedNearestSlotAssignmentResult({ + assignment, + frame, + pointerX, + pointerY, + }); +} + +function buildDisplacedFrameForNearestAssignment(args: { + occupiedFrame: SlotFrame; + footprintByOwnerId: ReadonlyMap; + currentFrame: SlotFrame; + 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.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, + distanceSquared: dx * dx + dy * dy, + }; +} + +function findFirstValidSlotFrame(args: { + candidateAssignments: readonly GraphOwnerSlotAssignment[]; + footprint: OwnerFootprint; + centralExclusion: 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; + centralExclusion: 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.centralExclusion, { + ringStates: args.ringStates, + }); + if (!frame) { + return null; + } + if ( + args.placedFrames.some((existing) => + rectsOverlapWithGap(existing.bounds, frame.bounds, SLOT_GEOMETRY.ringPadding) + ) + ) { + 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.activityHeight + + SLOT_GEOMETRY.activityToOwnerGap + + 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; + centralExclusion: StableRect; + ringStates: RingLayoutStateMap; +}): number | null { + const vector = + SECTOR_VECTORS[args.assignment.sectorIndex % SECTOR_VECTORS.length] ?? SECTOR_VECTORS[0]; + const minRadius = computeMinimumRingRadius(vector, args.footprint, args.centralExclusion); + 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 computeMinimumRingRadius( + 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 && + inner.right <= outer.right && + inner.top >= outer.top && + inner.bottom <= outer.bottom + ); +} + +function pointInRect(x: number, y: number, rect: StableRect): boolean { + return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; +} + +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[], + runtimeCentralExclusion: StableRect +): boolean { + if (!isFiniteRect(frame.bounds)) { + return false; + } + if (rectsOverlap(frame.bounds, runtimeCentralExclusion)) { + 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..df378f75 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 { diff --git a/packages/agent-graph/src/ui/GraphControls.tsx b/packages/agent-graph/src/ui/GraphControls.tsx index 415b13fd..4d0318f4 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,6 +43,8 @@ export interface GraphControlsProps { onRequestFullscreen?: () => void; onOpenTeamPage?: () => void; onCreateTask?: () => void; + onToggleSidebar?: () => void; + isSidebarVisible?: boolean; teamName: string; teamColor?: string; isAlive?: boolean; @@ -60,6 +64,8 @@ export function GraphControls({ onRequestFullscreen, onOpenTeamPage, onCreateTask, + onToggleSidebar, + isSidebarVisible = true, teamColor, }: GraphControlsProps): React.JSX.Element { const [isSettingsOpen, setIsSettingsOpen] = useState(false); @@ -100,6 +106,28 @@ export function GraphControls({ return ( <>
+ {onToggleSidebar ? ( +
+ + ) : ( + + ) + } + toolbar + title={isSidebarVisible ? 'Hide sidebar' : 'Show sidebar'} + /> +
+ ) : null} {onOpenTeamPage ? (
void; onOpenTeamPage?: () => void; onCreateTask?: () => void; + onToggleSidebar?: () => void; + isSidebarVisible?: boolean; + 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; @@ -90,6 +98,9 @@ export function GraphView({ onRequestFullscreen, onOpenTeamPage, onCreateTask, + onToggleSidebar, + isSidebarVisible = true, + onOwnerSlotDrop, renderOverlay, renderEdgeOverlay, renderHud, @@ -142,18 +153,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( @@ -247,7 +271,7 @@ export function GraphView({ return null; } 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; @@ -261,7 +285,7 @@ export function GraphView({ }, [getViewportSize]); 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; } return { x: node.x, y: node.y }; @@ -285,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, @@ -304,8 +331,14 @@ export function GraphView({ }); 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(() => { @@ -401,8 +434,9 @@ export function GraphView({ 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 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); @@ -433,7 +467,16 @@ export function GraphView({ } } }, - [camera, getInteractiveEdges, getNodeMap, interaction, markUserInteracted, simulation.stateRef] + [ + camera, + getInteractiveEdges, + getNodeMap, + getVisibleEdges, + getVisibleNodes, + interaction, + markUserInteracted, + simulation.stateRef, + ] ); const handleMouseMove = useCallback( @@ -448,7 +491,7 @@ export function GraphView({ 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); + interaction.handleMouseMove(world.x, world.y, getVisibleNodes(simulation.stateRef.current.nodes)); return; } @@ -457,8 +500,9 @@ export function GraphView({ 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 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; @@ -474,11 +518,14 @@ export function GraphView({ hoveredEdgeIdRef.current = findEdgeAt(world.x, world.y, interactiveEdges, nodeMap); canvas.style.cursor = hoveredEdgeIdRef.current ? 'pointer' : 'grab'; }, - [camera, getInteractiveEdges, getNodeMap, interaction, simulation.stateRef] + [camera, getInteractiveEdges, getNodeMap, getVisibleEdges, getVisibleNodes, interaction, simulation.stateRef] ); const handleMouseUp = useCallback( (e: React.MouseEvent) => { + const draggedNodeId = interaction.dragNodeId.current; + const wasDragging = interaction.isDragging.current; + if (isPanningRef.current) { camera.handlePanEnd(); isPanningRef.current = false; @@ -489,6 +536,33 @@ export function GraphView({ } const clickedId = interaction.handleMouseUp(); + if (wasDragging && draggedNodeId) { + 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); + }); + edgeMouseDownRef.current = null; + return; + } + } + simulation.clearNodePosition(draggedNodeId); + edgeMouseDownRef.current = null; + return; + } + if (clickedId) { setSelectedNodeId(clickedId); setSelectedEdgeId(null); @@ -526,7 +600,7 @@ export function GraphView({ } } }, - [interaction, simulation.stateRef, events, camera, data.teamName] + [camera, data.teamName, events, interaction, onOwnerSlotDrop, simulation] ); const handleDoubleClick = useCallback( @@ -538,7 +612,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); @@ -553,7 +627,7 @@ export function GraphView({ } } }, - [camera, interaction, simulation.stateRef, events] + [camera, events, getVisibleNodes, interaction, simulation.stateRef] ); // ─── Keyboard ─────────────────────────────────────────────────────────── @@ -598,10 +672,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] @@ -719,6 +789,8 @@ export function GraphView({ onRequestFullscreen={onRequestFullscreen} onOpenTeamPage={onOpenTeamPage} onCreateTask={onCreateTask} + onToggleSidebar={onToggleSidebar} + isSidebarVisible={isSidebarVisible} teamName={data.teamName} teamColor={data.teamColor} isAlive={data.isAlive} diff --git a/src/features/agent-graph/README.md b/src/features/agent-graph/README.md index 70101e02..0c3a4729 100644 --- a/src/features/agent-graph/README.md +++ b/src/features/agent-graph/README.md @@ -5,6 +5,7 @@ This feature is a thin renderer slice over the reusable graph engine in `package 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` 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/features/agent-graph/core/domain/buildInlineActivityEntries.ts b/src/features/agent-graph/core/domain/buildInlineActivityEntries.ts index 690c6f4f..13304ff3 100644 --- a/src/features/agent-graph/core/domain/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 { buildGraphMemberNodeIdForMember } 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,11 @@ export function buildInlineActivityEntries({ ownerNodeIds, }: BuildInlineActivityEntriesArgs): Map { const entriesByOwnerNodeId = new Map(); + const memberNodeIdByName = new Map( + data.members + .filter((member) => !isLeadMember(member)) + .map((member) => [member.name, buildGraphMemberNodeIdForMember(teamName, member)] as const) + ); const appendEntry = (entry: InlineActivityEntry): void => { const targetOwnerNodeId = ownerNodeIds.has(entry.ownerNodeId) ? entry.ownerNodeId : leadId; @@ -65,6 +74,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 +92,10 @@ export function buildInlineActivityEntries({ const ownerNodeId = resolveMessageOwnerNodeId({ message, - teamName, leadId, leadName, ownerNodeIds, + memberNodeIdByName, }); if (!ownerNodeId) { continue; @@ -113,6 +125,8 @@ export function buildInlineActivityEntries({ ownerNodeId, graphItem, message, + sourceKind: 'message', + sourceOrder: messageSourceOrderByKey.get(getActivityMessageKey(message)) ?? null, }); } @@ -123,10 +137,10 @@ export function buildInlineActivityEntries({ const ownerNodeId = resolveCommentOwnerNodeId({ taskOwner: item.task.owner, author: item.comment.author, - teamName, leadId, leadName, ownerNodeIds, + memberNodeIdByName, }); if (!ownerNodeId) { continue; @@ -154,14 +168,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.toSorted((a, b) => b.graphItem.timestamp.localeCompare(a.graphItem.timestamp)) - ); + entriesByOwnerNodeId.set(ownerNodeId, entries.toSorted(compareInlineActivityEntries)); } return entriesByOwnerNodeId; @@ -169,30 +182,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; + memberNodeIdByName: ReadonlyMap; }): string | null { - const { message, teamName, leadId, leadName, ownerNodeIds } = args; + const { message, leadId, leadName, ownerNodeIds, memberNodeIdByName } = 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, memberNodeIdByName); + const toId = message.to + ? resolveParticipantId(message.to, leadId, leadName, memberNodeIdByName) + : leadId; if (toId !== leadId && ownerNodeIds.has(toId)) { return toId; @@ -206,20 +244,20 @@ function resolveMessageOwnerNodeId(args: { function resolveCommentOwnerNodeId(args: { taskOwner: string | undefined; author: string; - teamName: string; leadId: string; leadName: string; ownerNodeIds: ReadonlySet; + memberNodeIdByName: ReadonlyMap; }): string | null { - const { taskOwner, author, teamName, leadId, leadName, ownerNodeIds } = args; + const { taskOwner, author, leadId, leadName, ownerNodeIds, memberNodeIdByName } = args; if (taskOwner) { - const ownerId = resolveParticipantId(taskOwner, teamName, leadId, leadName); + const ownerId = resolveParticipantId(taskOwner, leadId, leadName, memberNodeIdByName); if (ownerNodeIds.has(ownerId)) { return ownerId; } } - const authorId = resolveParticipantId(author, teamName, leadId, leadName); + const authorId = resolveParticipantId(author, leadId, leadName, memberNodeIdByName); if (ownerNodeIds.has(authorId)) { return authorId; } @@ -327,9 +365,9 @@ function getActivityMessageKey(message: InboxMessage): string { function resolveParticipantId( name: string, - teamName: string, leadId: string, - leadName?: string + leadName: string | undefined, + memberNodeIdByName: ReadonlyMap ): string { const normalized = name.trim().toLowerCase(); if (normalized === 'user' || normalized === 'team-lead') { @@ -338,7 +376,7 @@ function resolveParticipantId( if (normalized === leadName?.trim().toLowerCase()) { return leadId; } - return `member:${teamName}:${name}`; + return memberNodeIdByName.get(name) ?? leadId; } function buildParticipantLabel(name: string | undefined, leadName: string): string { 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..bb050616 --- /dev/null +++ b/src/features/agent-graph/core/domain/graphOwnerIdentity.ts @@ -0,0 +1,30 @@ +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 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/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts index a7fd0bf9..9586f4d0 100644 --- a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts +++ b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts @@ -29,6 +29,11 @@ import { getGraphLeadMemberName, } from '../../core/domain/buildInlineActivityEntries'; import { collapseOverflowStacksWithMeta } from '../../core/domain/collapseOverflowStacks'; +import { + buildGraphMemberNodeIdForMember, + getGraphStableOwnerId, + GRAPH_STABLE_SLOT_LAYOUT_VERSION, +} from '../../core/domain/graphOwnerIdentity'; import { isTaskBlocked, isTaskInReviewCycle, @@ -38,8 +43,10 @@ import { import type { GraphDataPort, GraphEdge, + GraphLayoutPort, GraphNode, GraphNodeState, + GraphOwnerSlotAssignment, GraphParticle, } from '@claude-teams/agent-graph'; import type { @@ -49,6 +56,7 @@ import type { MemberSpawnStatusEntry, MemberSpawnStatusesSnapshot, TeamData, + TeamProcess, TeamProvisioningProgress, } from '@shared/types/team'; import type { LeadContextUsage } from '@shared/types/team'; @@ -90,12 +98,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 +134,7 @@ export class TeamGraphAdapter { const leadId = `lead:${teamName}`; const leadName = TeamGraphAdapter.#getLeadMemberName(teamData, teamName); + const memberNodeIdByName = TeamGraphAdapter.#buildMemberNodeIdByName(teamData, teamName); const provisioningPresentation = buildTeamProvisioningPresentation({ progress: provisioningProgress, members: teamData.members, @@ -144,6 +164,7 @@ export class TeamGraphAdapter { leadId, teamData, teamName, + memberNodeIdByName, spawnStatuses, pendingApprovalAgents, activeTools, @@ -152,8 +173,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, memberNodeIdByName); + this.#buildProcessNodes(nodes, edges, teamData, teamName, memberNodeIdByName); this.#attachActivityFeeds(nodes, teamData, teamName, leadId, leadName); this.#buildMessageParticles( particles, @@ -162,9 +183,18 @@ export class TeamGraphAdapter { teamName, leadId, leadName, - edges + edges, + memberNodeIdByName + ); + this.#buildCommentParticles( + particles, + teamData, + teamName, + leadId, + leadName, + edges, + memberNodeIdByName ); - this.#buildCommentParticles(particles, teamData, teamName, leadId, leadName, edges); return { nodes, @@ -173,6 +203,7 @@ export class TeamGraphAdapter { teamName, teamColor: teamData.config.color ?? undefined, isAlive: teamData.isAlive, + layout: TeamGraphAdapter.#buildLayoutPort(teamData, teamName, slotAssignments), }; } @@ -195,6 +226,115 @@ export class TeamGraphAdapter { return getGraphLeadMemberName(data, teamName); } + static #buildMemberNodeIdByName(data: TeamData, teamName: string): Map { + return new Map( + data.members + .filter((member) => !isLeadMember(member)) + .map((member) => [member.name, buildGraphMemberNodeIdForMember(teamName, member)] as const) + ); + } + + 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 assignedStableOwnerIds = new Set(Object.keys(slotAssignments ?? {})); + const configStableOwnerIds = new Set( + (data.config.members ?? []).map((member) => getGraphStableOwnerId(member)) + ); + + 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); + }; + + const assignedVisibleMembersOutsideConfig = visibleMembers + .filter((member) => { + const stableOwnerId = getGraphStableOwnerId(member); + return ( + assignedStableOwnerIds.has(stableOwnerId) && !configStableOwnerIds.has(stableOwnerId) + ); + }) + .toSorted((left, right) => + getGraphStableOwnerId(left).localeCompare(getGraphStableOwnerId(right)) + ); + + for (const configMember of data.config.members ?? []) { + const stableOwnerId = getGraphStableOwnerId(configMember); + if (!assignedStableOwnerIds.has(stableOwnerId)) { + continue; + } + pushMember(visibleMemberByStableOwnerId.get(stableOwnerId)); + } + + for (const member of assignedVisibleMembersOutsideConfig) { + pushMember(member); + } + + for (const configMember of data.config.members ?? []) { + const stableOwnerId = getGraphStableOwnerId(configMember); + if (assignedStableOwnerIds.has(stableOwnerId)) { + continue; + } + const visibleMember = visibleMemberByStableOwnerId.get(stableOwnerId); + pushMember(visibleMember); + } + + const remainingMembers = visibleMembers.toSorted((left, right) => + getGraphStableOwnerId(left).localeCompare(getGraphStableOwnerId(right)) + ); + + for (const member of remainingMembers) { + pushMember(member); + } + + 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; @@ -324,6 +464,7 @@ export class TeamGraphAdapter { leadId: string, data: TeamData, teamName: string, + memberNodeIdByName: ReadonlyMap, spawnStatuses?: Record, pendingApprovalAgents?: Set, activeTools?: Record>, @@ -336,7 +477,8 @@ export class TeamGraphAdapter { if (member.removedAt) continue; if (isLeadMember(member)) continue; - const memberId = `member:${teamName}:${member.name}`; + const memberId = + memberNodeIdByName.get(member.name) ?? buildGraphMemberNodeIdForMember(teamName, member); const spawn = spawnStatuses?.[member.name]; const activeTool = TeamGraphAdapter.#selectVisibleTool( activeTools?.[member.name], @@ -425,7 +567,8 @@ export class TeamGraphAdapter { edges: GraphEdge[], data: TeamData, teamName: string, - commentReadState?: Record + commentReadState?: Record, + memberNodeIdByName?: ReadonlyMap ): void { const taskStateById = new Map>(); const taskDisplayIds = new Map(); @@ -446,7 +589,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 ? (memberNodeIdByName?.get(task.owner) ?? null) : null; const kanbanTaskState = data.kanbanState.tasks[task.id]; const reviewerName = resolveTaskReviewer(task, kanbanTaskState); const isReviewCycle = isTaskInReviewCycle(task); @@ -496,7 +639,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] : [] @@ -608,18 +751,21 @@ export class TeamGraphAdapter { nodes: GraphNode[], edges: GraphEdge[], data: TeamData, - teamName: string + teamName: string, + memberNodeIdByName?: ReadonlyMap ): void { - for (const proc of data.processes) { - if (proc.stoppedAt) continue; + for (const { process: proc, ownerId } of TeamGraphAdapter.#selectRelevantProcesses( + data.processes, + memberNodeIdByName + )) { 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, @@ -638,6 +784,48 @@ export class TeamGraphAdapter { } } + static #selectRelevantProcesses( + processes: readonly TeamProcess[], + memberNodeIdByName?: ReadonlyMap + ): { process: TeamProcess; ownerId: string }[] { + const selectedByOwnerId = new Map(); + + for (const process of processes) { + const ownerId = process.registeredBy + ? (memberNodeIdByName?.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, @@ -683,7 +871,8 @@ export class TeamGraphAdapter { teamName: string, leadId: string, leadName: string, - edges: GraphEdge[] + edges: GraphEdge[], + memberNodeIdByName: ReadonlyMap ): void { const ordered = [...messages].reverse(); @@ -766,16 +955,22 @@ export class TeamGraphAdapter { continue; } - const edgeId = TeamGraphAdapter.#resolveMessageEdge(msg, teamName, leadId, leadName, edges); + const edgeId = TeamGraphAdapter.#resolveMessageEdge( + msg, + leadId, + leadName, + edges, + memberNodeIdByName + ); 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, + memberNodeIdByName ); const isFromTeammate = fromId !== leadId; @@ -815,7 +1010,8 @@ export class TeamGraphAdapter { teamName: string, leadId: string, leadName: string, - edges: GraphEdge[] + edges: GraphEdge[], + memberNodeIdByName: ReadonlyMap ): void { // First call: record current comment counts without creating particles. // This prevents pre-existing comments from spawning particles when the graph opens. @@ -854,9 +1050,9 @@ export class TeamGraphAdapter { } const authorNodeId = TeamGraphAdapter.#resolveParticipantId( newComment.author, - teamName, leadId, - leadName + leadName, + memberNodeIdByName ); const taskNodeId = `task:${teamName}:${task.id}`; const authorEdge = @@ -988,16 +1184,21 @@ export class TeamGraphAdapter { static #resolveMessageEdge( msg: InboxMessage, - teamName: string, leadId: string, leadName: string, - edges: GraphEdge[] + edges: GraphEdge[], + memberNodeIdByName: 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, + memberNodeIdByName + ); + const toId = TeamGraphAdapter.#resolveParticipantId(to, leadId, leadName, memberNodeIdByName); return ( edges.find((e) => e.source === fromId && e.target === toId)?.id ?? edges.find((e) => e.source === toId && e.target === fromId)?.id ?? @@ -1006,7 +1207,12 @@ export class TeamGraphAdapter { } if (from && !to) { - const fromId = TeamGraphAdapter.#resolveParticipantId(from, teamName, leadId, leadName); + const fromId = TeamGraphAdapter.#resolveParticipantId( + from, + leadId, + leadName, + memberNodeIdByName + ); return ( edges.find( (e) => @@ -1021,14 +1227,14 @@ export class TeamGraphAdapter { static #resolveParticipantId( name: string, - teamName: string, leadId: string, - leadName?: string + leadName: string | undefined, + memberNodeIdByName: 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 memberNodeIdByName.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/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/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts b/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts index 346a7105..88e2127e 100644 --- a/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts +++ b/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts @@ -3,7 +3,7 @@ * Thin wrapper — instantiates the class adapter and calls adapt() with store data. */ -import { useMemo, useRef, useSyncExternalStore } from 'react'; +import { useEffect, useMemo, useRef, useSyncExternalStore } from 'react'; import { getSnapshot, subscribe } from '@renderer/services/commentReadStorage'; import { useStore } from '@renderer/store'; @@ -31,6 +31,8 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { toolHistory, provisioningProgress, memberSpawnSnapshot, + slotAssignments, + ensureTeamGraphSlotAssignments, } = useStore( useShallow((s) => ({ teamData: selectTeamDataForName(s, teamName), @@ -43,6 +45,8 @@ 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, + ensureTeamGraphSlotAssignments: s.ensureTeamGraphSlotAssignments, })) ); @@ -58,6 +62,13 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { const commentReadState = useSyncExternalStore(subscribe, getSnapshot); + useEffect(() => { + if (!teamName || !teamData) { + return; + } + ensureTeamGraphSlotAssignments(teamName, teamData.members); + }, [ensureTeamGraphSlotAssignments, teamData, teamName]); + return useMemo( () => adapterRef.current.adapt( @@ -72,7 +83,8 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { toolHistory, commentReadState, provisioningProgress, - memberSpawnSnapshot + memberSpawnSnapshot, + slotAssignments ), [ teamData, @@ -87,6 +99,7 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { commentReadState, provisioningProgress, memberSpawnSnapshot, + slotAssignments, ] ); } 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/ui/GraphActivityHud.tsx b/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx index 448ff848..b157fb78 100644 --- a/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx +++ b/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { ACTIVITY_ANCHOR_LAYOUT, ACTIVITY_LANE } from '@claude-teams/agent-graph'; import { ActivityItem } from '@renderer/components/team/activity/ActivityItem'; import { buildMessageContext, @@ -8,16 +9,14 @@ import { import { MessageExpandDialog } from '@renderer/components/team/activity/MessageExpandDialog'; import { useStableTeamMentionMeta } from '@renderer/hooks/useStableTeamMentionMeta'; import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; -import { useStore } from '@renderer/store'; -import { selectTeamDataForName } from '@renderer/store/slices/teamSlice'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; -import { useShallow } from 'zustand/react/shallow'; 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'; @@ -66,18 +65,12 @@ export const GraphActivityHud = ({ onOpenTaskDetail, onOpenMemberProfile, }: GraphActivityHudProps): React.JSX.Element | null => { - const ACTIVITY_LANE_WIDTH = 296; 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 } = useStore( - useShallow((state) => ({ - teamData: selectTeamDataForName(state, teamName), - teams: state.teams, - })) - ); + const { teamData, teams } = useGraphActivityContext(teamName); const ownerNodes = useMemo( () => @@ -159,15 +152,14 @@ export const GraphActivityHud = ({ worldLayer.style.transform = `translate(${Math.round(origin.x)}px, ${Math.round(origin.y)}px) scale(${zoom.toFixed(3)})`; } - const measurableLanes: Array<{ + const measurableLanes: { lane: (typeof visibleLanes)[number]; shell: HTMLDivElement; connector: SVGSVGElement | null; connectorPath: SVGPathElement | null; laneTopLeft: { x: number; y: number }; nodeWorld: { x: number; y: number }; - scale: number; - }> = []; + }[] = []; for (const lane of visibleLanes) { const shell = shellRefs.current.get(lane.node.id); @@ -189,7 +181,7 @@ export const GraphActivityHud = ({ } const scale = Math.max(getCameraZoom(), 0.001); - const widthScreen = Math.max(1, (shell.offsetWidth || ACTIVITY_LANE_WIDTH) * scale); + const widthScreen = Math.max(1, (shell.offsetWidth || ACTIVITY_LANE.width) * scale); const heightScreen = Math.max(1, (shell.offsetHeight || 220) * scale); const viewport = getViewportSize?.(); const laneVisible = viewport @@ -215,26 +207,31 @@ export const GraphActivityHud = ({ connectorPath, laneTopLeft, nodeWorld, - scale, }); } for (const entry of measurableLanes) { - const { lane, shell, connector, connectorPath, laneTopLeft, nodeWorld, scale } = entry; + const { lane, shell, connector, connectorPath, laneTopLeft, nodeWorld } = entry; const baseOpacity = focusNodeIds && !focusNodeIds.has(lane.node.id) ? 0.25 : 1; + const widthWorld = shell.offsetWidth || ACTIVITY_LANE.width; + const heightWorld = shell.offsetHeight || 220; + const ownerBottomLimit = + nodeWorld.y + + (lane.node.kind === 'lead' + ? ACTIVITY_ANCHOR_LAYOUT.leadOffsetY + ACTIVITY_ANCHOR_LAYOUT.reservedHeight + : ACTIVITY_ANCHOR_LAYOUT.memberOffsetY + ACTIVITY_ANCHOR_LAYOUT.reservedHeight); + const adjustedLaneTop = Math.min(laneTopLeft.y, ownerBottomLimit - heightWorld); + shell.style.opacity = String(baseOpacity); shell.style.left = `${Math.round(laneTopLeft.x)}px`; - shell.style.top = `${Math.round(laneTopLeft.y)}px`; + shell.style.top = `${Math.round(adjustedLaneTop)}px`; shell.style.transform = ''; if (connector && connectorPath) { - const widthWorld = shell.offsetWidth || ACTIVITY_LANE_WIDTH; - const laneCenterX = laneTopLeft.x + widthWorld / 2; - const laneIsLeft = laneCenterX < nodeWorld.x; - const endX = laneIsLeft ? laneTopLeft.x + widthWorld - 8 : laneTopLeft.x + 8; - const endY = laneTopLeft.y + 10; + const endX = laneTopLeft.x + widthWorld / 2; + const endY = adjustedLaneTop + heightWorld - 6; const startX = nodeWorld.x; - const startY = nodeWorld.y - 10 / scale; + 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)); @@ -367,14 +364,14 @@ export const GraphActivityHud = ({ return; } - const listeners: Array<{ shell: HTMLDivElement; handler: (event: WheelEvent) => void }> = []; + 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) => forwardWheelToGraph(event, shell); + const handler = (event: WheelEvent): void => forwardWheelToGraph(event, shell); shell.addEventListener('wheel', handler, { passive: false }); listeners.push({ shell, handler }); } @@ -421,7 +418,7 @@ export const GraphActivityHud = ({ shellRefs.current.set(lane.node.id, element); }} className="pointer-events-auto absolute z-10 origin-top-left opacity-0" - style={{ width: `${ACTIVITY_LANE_WIDTH}px`, maxWidth: `${ACTIVITY_LANE_WIDTH}px` }} + style={{ width: `${ACTIVITY_LANE.width}px`, maxWidth: `${ACTIVITY_LANE.width}px` }} >
Activity diff --git a/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx b/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx index ee9f8e95..fbf6287e 100644 --- a/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx +++ b/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx @@ -7,10 +7,11 @@ 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 { useGraphCreateTaskDialog } from '../hooks/useGraphCreateTaskDialog'; +import { useGraphSidebarVisibility } from '../hooks/useGraphSidebarVisibility'; import { useTeamGraphAdapter } from '../hooks/useTeamGraphAdapter'; +import { useTeamGraphSurfaceActions } from '../hooks/useTeamGraphSurfaceActions'; import { GraphActivityHud } from './GraphActivityHud'; import { GraphBlockingEdgePopover } from './GraphBlockingEdgePopover'; @@ -27,6 +28,8 @@ export interface TeamGraphOverlayProps { teamName: string; onClose: () => void; onPinAsTab?: () => void; + sidebarVisible?: boolean; + onToggleSidebar?: () => void; onSendMessage?: (memberName: string) => void; onOpenTaskDetail?: (taskId: string) => void; onOpenMemberProfile?: ( @@ -42,12 +45,19 @@ 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 effectiveSidebarVisible = sidebarVisible ?? persistedSidebarVisible; + const handleToggleSidebar = onToggleSidebar ?? toggleSidebarVisible; const leadNodeId = useMemo( () => graphData.nodes.find((node) => node.kind === 'lead')?.id ?? null, [graphData.nodes] @@ -73,9 +83,9 @@ export const TeamGraphOverlay = ({ [dispatchTaskAction] ); const openTeamPage = useCallback(() => { - useStore.getState().openTeamTab(teamName); + openTeamTab(); onClose(); - }, [onClose, teamName]); + }, [onClose, openTeamTab]); const openCreateTask = useCallback(() => { openCreateTaskDialog(''); }, [openCreateTaskDialog]); @@ -104,7 +114,9 @@ export const TeamGraphOverlay = ({ return (
- + {effectiveSidebarVisible ? ( + + ) : null} { const extraHudProps = hudProps as typeof hudProps & { diff --git a/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx b/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx index 8df77c2f..18be6f19 100644 --- a/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx +++ b/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx @@ -7,10 +7,11 @@ import { lazy, Suspense, useCallback, useMemo, useState } from 'react'; import { GraphView } from '@claude-teams/agent-graph'; import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost'; -import { useStore } from '@renderer/store'; import { useGraphCreateTaskDialog } from '../hooks/useGraphCreateTaskDialog'; +import { useGraphSidebarVisibility } from '../hooks/useGraphSidebarVisibility'; import { useTeamGraphAdapter } from '../hooks/useTeamGraphAdapter'; +import { useTeamGraphSurfaceActions } from '../hooks/useTeamGraphSurfaceActions'; import { GraphActivityHud } from './GraphActivityHud'; import { GraphBlockingEdgePopover } from './GraphBlockingEdgePopover'; @@ -44,11 +45,13 @@ export const TeamGraphTab = ({ isPaneFocused = false, }: TeamGraphTabProps): React.JSX.Element => { const graphData = useTeamGraphAdapter(teamName); + const { openTeamPage, commitOwnerSlotDrop } = useTeamGraphSurfaceActions(teamName); const leadNodeId = useMemo( () => graphData.nodes.find((node) => node.kind === 'lead')?.id ?? null, [graphData.nodes] ); const [fullscreen, setFullscreen] = useState(false); + const { sidebarVisible, toggleSidebarVisible } = useGraphSidebarVisibility(); const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName); // Typed event dispatchers (DRY — used in both events + renderOverlay) @@ -73,9 +76,6 @@ export const TeamGraphTab = ({ ), [teamName] ); - const openTeamPage = useCallback(() => { - useStore.getState().openTeamTab(teamName); - }, [teamName]); const openCreateTask = useCallback(() => { openCreateTaskDialog(''); }, [openCreateTaskDialog]); @@ -130,12 +130,14 @@ export const TeamGraphTab = ({ return (
- + {sidebarVisible ? ( + + ) : null}
setFullscreen(true)} onOpenTeamPage={openTeamPage} onCreateTask={openCreateTask} + onToggleSidebar={toggleSidebarVisible} + isSidebarVisible={sidebarVisible} + onOwnerSlotDrop={commitOwnerSlotDrop} renderHud={(hudProps) => { const extraHudProps = hudProps as typeof hudProps & { getViewportSize?: () => { width: number; height: number }; @@ -227,6 +232,8 @@ export const TeamGraphTab = ({ setFullscreen(false)} + sidebarVisible={sidebarVisible} + onToggleSidebar={toggleSidebarVisible} onSendMessage={dispatchSendMessage} onOpenTaskDetail={dispatchOpenTask} onOpenMemberProfile={dispatchOpenProfile} 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 index 3c1fc042..fadaa318 100644 --- 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 @@ -168,16 +168,18 @@ describe('TmuxInstallerRunnerAdapter', () => { retryWithUpdateCommand: null, })), }; - let resolveTerminalRun: ((result: { exitCode: number }) => void) | null = null; + const resolveTerminalRunRef: { current: ((result: { exitCode: number }) => void) | null } = { + current: null, + }; const terminalSession = { run: vi.fn( () => new Promise<{ exitCode: number }>((resolve) => { - resolveTerminalRun = resolve; + resolveTerminalRunRef.current = resolve; }) ), writeLine: vi.fn((input: string) => { - resolveTerminalRun?.({ exitCode: 0 }); + resolveTerminalRunRef.current?.({ exitCode: 0 }); return input; }), cancel: vi.fn(), @@ -208,12 +210,14 @@ describe('TmuxInstallerRunnerAdapter', () => { getStatus: vi.fn(async () => createBaseStatus()), invalidateStatus: vi.fn(), }; - let resolveCommandRun: ((result: { exitCode: number }) => void) | null = null; + const resolveCommandRunRef: { current: ((result: { exitCode: number }) => void) | null } = { + current: null, + }; const commandRunner = { run: vi.fn( () => new Promise<{ exitCode: number }>((resolve) => { - resolveCommandRun = resolve; + resolveCommandRunRef.current = resolve; }) ), cancel: vi.fn(), @@ -244,7 +248,7 @@ describe('TmuxInstallerRunnerAdapter', () => { (snapshot) => snapshot.canCancel ); await runner.cancel(); - resolveCommandRun?.({ exitCode: 1 }); + resolveCommandRunRef.current?.({ exitCode: 1 }); await expect(installPromise).resolves.toBeUndefined(); 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 index d93ab89e..8099e5bd 100644 --- 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 @@ -44,27 +44,30 @@ describe('TmuxStatusSourceAdapter', () => { it('does not reuse or recache a stale in-flight probe after invalidateStatus()', async () => { const childProcess = await import('node:child_process'); - let firstCallback: - | ((error: Error | null, stdout: string | Buffer, stderr: string | Buffer) => void) - | null = null; + 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: string[], - _options: unknown, - callback: (error: Error | null, stdout: string | Buffer, stderr: string | Buffer) => void - ) => { - if (!firstCallback) { - firstCallback = callback; - return {} as never; - } - - callback(null, 'tmux second\n', ''); + 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( { @@ -92,7 +95,7 @@ describe('TmuxStatusSourceAdapter', () => { expect(secondStatus.host.version).toBe('tmux second'); - firstCallback?.(null, 'tmux first\n', ''); + firstCallbackRef.current?.(null, 'tmux first\n', ''); await firstStatusPromise; await Promise.resolve(); diff --git a/src/features/tmux-installer/main/infrastructure/installer/TmuxCommandRunner.ts b/src/features/tmux-installer/main/infrastructure/installer/TmuxCommandRunner.ts index ed909b10..46d567be 100644 --- a/src/features/tmux-installer/main/infrastructure/installer/TmuxCommandRunner.ts +++ b/src/features/tmux-installer/main/infrastructure/installer/TmuxCommandRunner.ts @@ -40,7 +40,7 @@ export class TmuxCommandRunner { flush: () => void; } => { let pending = ''; - let pendingBytes = Buffer.alloc(0); + let pendingBytes: Buffer = Buffer.alloc(0); const emitLine = (line: string): void => { const normalizedLine = line.replace(/\r$/, ''); diff --git a/src/features/tmux-installer/renderer/ui/TmuxInstallerBannerView.tsx b/src/features/tmux-installer/renderer/ui/TmuxInstallerBannerView.tsx index 3ca6a288..4cbb1029 100644 --- a/src/features/tmux-installer/renderer/ui/TmuxInstallerBannerView.tsx +++ b/src/features/tmux-installer/renderer/ui/TmuxInstallerBannerView.tsx @@ -76,6 +76,7 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null { const manualHintsVisible = viewModel.manualHints.length > 0 && (!viewModel.manualHintsCollapsible || manualHintsExpanded); + const primaryGuideUrl = viewModel.primaryGuideUrl; return (
)} - {viewModel.primaryGuideUrl && ( + {primaryGuideUrl && (
); -} +}; -function ActivityDetailPanel({ - detailState, -}: { - detailState: ActivityDetailState; -}): React.JSX.Element { +const ActivityDetailPanel = ({ detailState }: ActivityDetailPanelProps): React.JSX.Element => { if (detailState.status === 'loading') { return ( -
+
Loading activity details...
@@ -182,7 +182,7 @@ function ActivityDetailPanel({ if (detailState.status === 'missing') { return ( -
+
Detailed transcript context is no longer available for this activity.
); @@ -202,7 +202,7 @@ function ActivityDetailPanel({ {linkedTool ? : null}
); -} +}; const Row = ({ detailState, diff --git a/src/renderer/components/team/taskLogs/TaskLogsPanel.tsx b/src/renderer/components/team/taskLogs/TaskLogsPanel.tsx index 5bd7913e..0d364575 100644 --- a/src/renderer/components/team/taskLogs/TaskLogsPanel.tsx +++ b/src/renderer/components/team/taskLogs/TaskLogsPanel.tsx @@ -1,10 +1,11 @@ import { useEffect, useMemo, useState } from 'react'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'; + import { ExecutionSessionsSection } from './ExecutionSessionsSection'; import { isBoardTaskActivityUiEnabled, isBoardTaskExactLogsUiEnabled } from './featureGates'; import { TaskActivitySection } from './TaskActivitySection'; import { TaskLogStreamSection } from './TaskLogStreamSection'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'; import type { TeamTaskWithKanban } from '@shared/types'; diff --git a/src/renderer/store/slices/extensionsSlice.ts b/src/renderer/store/slices/extensionsSlice.ts index 58063575..dbb32224 100644 --- a/src/renderer/store/slices/extensionsSlice.ts +++ b/src/renderer/store/slices/extensionsSlice.ts @@ -6,8 +6,9 @@ import { api } from '@renderer/api'; import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli'; -import type { AppState } from '../types'; import { findPaneByTabId, updatePane } from '../utils/paneHelpers'; + +import type { AppState } from '../types'; import type { ApiKeyEntry, ApiKeySaveRequest, diff --git a/test/main/services/team/TeamMemberResolver.test.ts b/test/main/services/team/TeamMemberResolver.test.ts index e0718abf..1a0b855c 100644 --- a/test/main/services/team/TeamMemberResolver.test.ts +++ b/test/main/services/team/TeamMemberResolver.test.ts @@ -33,7 +33,8 @@ describe('TeamMemberResolver', () => { const members = resolver.resolveMembers(config, metaMembers, inboxNames, tasks, messages); const names = members.map((member) => member.name); - expect(names).toEqual(['alice', 'bob', 'team-lead']); + expect(names).toHaveLength(3); + expect(names).toEqual(expect.arrayContaining(['alice', 'bob', 'team-lead'])); expect(names).not.toContain('stranger'); expect(names).not.toContain('user'); From 8398d29fc050f0a7b4c673def06f2376ef14cf99 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 15 Apr 2026 17:38:21 +0300 Subject: [PATCH 040/121] fix(team): recover root member session logs --- src/main/services/team/TeamConfigReader.ts | 53 +- .../services/team/TeamMemberLogsFinder.ts | 652 ++++++++++++------ .../team/TeamTranscriptProjectResolver.ts | 297 ++++++++ src/main/services/team/TeammateToolTracker.ts | 2 +- .../discovery/TeamTranscriptSourceLocator.ts | 111 +-- .../components/team/members/MemberLogsTab.tsx | 31 +- src/shared/types/team.ts | 11 +- .../team/TeamMemberLogsFinder.test.ts | 114 ++- .../team/TeamTranscriptSourceLocator.test.ts | 114 +++ 9 files changed, 1066 insertions(+), 319 deletions(-) create mode 100644 src/main/services/team/TeamTranscriptProjectResolver.ts create mode 100644 test/main/services/team/TeamTranscriptSourceLocator.test.ts 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/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/TeammateToolTracker.ts b/src/main/services/team/TeammateToolTracker.ts index 5a4416b9..c19e9ced 100644 --- a/src/main/services/team/TeammateToolTracker.ts +++ b/src/main/services/team/TeammateToolTracker.ts @@ -145,7 +145,7 @@ export class TeammateToolTracker { const state = this.stateByTeam.get(teamName); if (!state?.enabled || state.epoch !== expectedEpoch) return; - const attributedFiles = await this.logsFinder.listAttributedSubagentFiles(teamName); + const attributedFiles = await this.logsFinder.listAttributedMemberFiles(teamName); const currentState = this.stateByTeam.get(teamName); if (!currentState?.enabled || currentState.epoch !== expectedEpoch) return; 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/renderer/components/team/members/MemberLogsTab.tsx b/src/renderer/components/team/members/MemberLogsTab.tsx index 316c2c17..d559cc70 100644 --- a/src/renderer/components/team/members/MemberLogsTab.tsx +++ b/src/renderer/components/team/members/MemberLogsTab.tsx @@ -183,9 +183,13 @@ export const MemberLogsTab = ({ }, []); const getRowId = useCallback((log: MemberLogSummary): string => { - return log.kind === 'subagent' - ? `subagent:${log.sessionId}:${log.subagentId}` - : `lead:${log.sessionId}`; + if (log.kind === 'subagent') { + return `subagent:${log.sessionId}:${log.subagentId}`; + } + if (log.kind === 'member_session') { + return `member:${log.sessionId}`; + } + return `lead:${log.sessionId}`; }, []); const sortedLogs = useMemo(() => { @@ -250,7 +254,9 @@ export const MemberLogsTab = ({ if (!shouldShowPreview) return null; if (showSubagentPreview) { - const candidates = sortedLogs.filter((l) => l.kind === 'subagent'); + const candidates = sortedLogs.filter( + (l) => l.kind === 'subagent' || l.kind === 'member_session' + ); if (candidates.length === 0) return null; if (taskOwner) { @@ -515,6 +521,10 @@ export const MemberLogsTab = ({ ); return d?.chunks ?? null; } + if (log.kind === 'member_session') { + const d = await api.getSessionDetail(log.projectId, log.sessionId, options); + return d ? asEnhancedChunkArray(d.chunks) : null; + } const d = await api.getSessionDetail(log.projectId, log.sessionId, options); return d ? asEnhancedChunkArray(d.chunks) : null; }, @@ -766,7 +776,7 @@ const LogCard = ({ {log.description} - {log.kind === 'lead_session' && ( + {(log.kind === 'lead_session' || log.kind === 'member_session') && ( - Full team lead session logs — useful for global orchestration context, not - specific to this agent + {log.kind === 'lead_session' + ? 'Full team lead session logs - useful for global orchestration context, not specific to this agent' + : 'Full persistent teammate session logs - useful when work runs in a root member session instead of a subagent file'} )} @@ -838,7 +849,11 @@ const LogCard = ({
)} diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index d2f3935f..dcc8dfdf 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -1040,7 +1040,7 @@ export interface MemberSubagentSummary { isOngoing: boolean; } -export type MemberLogKind = 'subagent' | 'lead_session'; +export type MemberLogKind = 'subagent' | 'lead_session' | 'member_session'; export interface MemberLogSummaryBase { kind: MemberLogKind; @@ -1071,7 +1071,14 @@ export interface MemberLeadSessionLogSummary extends MemberLogSummaryBase { kind: 'lead_session'; } -export type MemberLogSummary = MemberSubagentLogSummary | MemberLeadSessionLogSummary; +export interface MemberSessionLogSummary extends MemberLogSummaryBase { + kind: 'member_session'; +} + +export type MemberLogSummary = + | MemberSubagentLogSummary + | MemberLeadSessionLogSummary + | MemberSessionLogSummary; export interface FileLineStats { added: number; diff --git a/test/main/services/team/TeamMemberLogsFinder.test.ts b/test/main/services/team/TeamMemberLogsFinder.test.ts index 9c7d559e..a8c02313 100644 --- a/test/main/services/team/TeamMemberLogsFinder.test.ts +++ b/test/main/services/team/TeamMemberLogsFinder.test.ts @@ -97,6 +97,118 @@ describe('TeamMemberLogsFinder', () => { expect(lead?.projectId).toBe(projectId); }); + it('returns root member sessions when config.projectPath is missing but member cwd is present', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-')); + setClaudeBasePathOverride(tmpDir); + + const teamName = 'signal-ops-root'; + const projectPath = '/Users/test/signal-ops-root'; + const projectId = '-Users-test-signal-ops-root'; + const leadSessionId = 'lead-root'; + const memberSessionId = 'member-bob-root'; + + await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true }); + await fs.writeFile( + path.join(tmpDir, 'teams', teamName, 'config.json'), + JSON.stringify( + { + name: teamName, + leadSessionId, + members: [ + { name: 'team-lead', agentType: 'team-lead', cwd: projectPath }, + { name: 'bob', agentType: 'general-purpose', cwd: projectPath }, + ], + }, + null, + 2 + ), + 'utf8' + ); + + const projectRoot = path.join(tmpDir, 'projects', projectId); + await fs.mkdir(projectRoot, { recursive: true }); + + await fs.writeFile( + path.join(projectRoot, `${leadSessionId}.jsonl`), + JSON.stringify({ + timestamp: '2026-04-15T14:02:00.000Z', + type: 'user', + teamName, + agentName: 'team-lead', + message: { role: 'user', content: `Lead for team "${teamName}" (${teamName})` }, + }) + '\n', + 'utf8' + ); + + await fs.writeFile( + path.join(projectRoot, `${memberSessionId}.jsonl`), + [ + JSON.stringify({ + timestamp: '2026-04-15T14:02:01.000Z', + type: 'user', + teamName, + agentName: 'bob', + message: { + role: 'user', + content: `You are bootstrapping into team "${teamName}" as member "bob".`, + }, + }), + JSON.stringify({ + timestamp: '2026-04-15T14:02:05.000Z', + type: 'assistant', + teamName, + agentName: 'bob', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'call-task-start', + name: 'mcp__agent-teams__task_start', + input: { + teamName, + taskId: 'task-root-1', + }, + }, + ], + }, + }), + ].join('\n') + '\n', + 'utf8' + ); + + const finder = new TeamMemberLogsFinder(); + const bobLogs = await finder.findMemberLogs(teamName, 'bob'); + const taskLogs = await finder.findLogsForTask(teamName, 'task-root-1'); + const attributedFiles = await finder.listAttributedMemberFiles(teamName); + + expect(bobLogs).toHaveLength(1); + expect(bobLogs[0]?.kind).toBe('member_session'); + if (bobLogs[0]?.kind === 'member_session') { + expect(bobLogs[0].sessionId).toBe(memberSessionId); + expect(bobLogs[0].projectId).toBe(projectId); + expect(bobLogs[0].memberName?.toLowerCase()).toBe('bob'); + expect(bobLogs[0].filePath).toBe(path.join(projectRoot, `${memberSessionId}.jsonl`)); + } + + expect( + taskLogs.some( + (log) => + log.kind === 'member_session' && + log.sessionId === memberSessionId && + log.memberName?.toLowerCase() === 'bob' + ) + ).toBe(true); + expect(attributedFiles).toEqual([ + { + memberName: 'bob', + sessionId: memberSessionId, + filePath: path.join(projectRoot, `${memberSessionId}.jsonl`), + mtimeMs: expect.any(Number), + }, + ]); + }); + it('listAttributedSubagentFiles only returns files from the current lead session for live tracking', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-')); setClaudeBasePathOverride(tmpDir); @@ -841,7 +953,7 @@ describe('TeamMemberLogsFinder', () => { { type: 'tool_use', name: 'Bash', - input: { command: 'node \"teamctl.js\" --team demo task start task-42' }, + input: { command: 'node "teamctl.js" --team demo task start task-42' }, }, ], }, diff --git a/test/main/services/team/TeamTranscriptSourceLocator.test.ts b/test/main/services/team/TeamTranscriptSourceLocator.test.ts new file mode 100644 index 00000000..254ecb8f --- /dev/null +++ b/test/main/services/team/TeamTranscriptSourceLocator.test.ts @@ -0,0 +1,114 @@ +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import * as fs from 'fs/promises'; + +import { TeamTranscriptSourceLocator } from '../../../../src/main/services/team/taskLogs/discovery/TeamTranscriptSourceLocator'; +import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder'; + +describe('TeamTranscriptSourceLocator', () => { + let tmpDir: string | null = null; + + afterEach(async () => { + setClaudeBasePathOverride(null); + if (tmpDir) { + await fs.rm(tmpDir, { recursive: true, force: true }); + tmpDir = null; + } + }); + + it('recovers projectPath from member cwd and includes only team-related root sessions', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-transcripts-')); + setClaudeBasePathOverride(tmpDir); + + const teamName = 'signal-ops-test'; + const projectPath = '/Users/test/signal-ops'; + const projectId = '-Users-test-signal-ops'; + const leadSessionId = 'lead-session'; + const memberSessionId = 'member-bob'; + + await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true }); + await fs.writeFile( + path.join(tmpDir, 'teams', teamName, 'config.json'), + JSON.stringify( + { + name: teamName, + leadSessionId, + members: [ + { name: 'team-lead', agentType: 'team-lead', cwd: projectPath }, + { name: 'bob', agentType: 'general-purpose', cwd: projectPath }, + ], + }, + null, + 2 + ), + 'utf8' + ); + + const projectRoot = path.join(tmpDir, 'projects', projectId); + await fs.mkdir(path.join(projectRoot, leadSessionId, 'subagents'), { recursive: true }); + + await fs.writeFile( + path.join(projectRoot, `${leadSessionId}.jsonl`), + JSON.stringify({ + timestamp: '2026-04-15T14:02:00.000Z', + type: 'user', + teamName, + agentName: 'team-lead', + message: { role: 'user', content: `Lead for team "${teamName}" (${teamName})` }, + }) + '\n', + 'utf8' + ); + await fs.writeFile( + path.join(projectRoot, `${memberSessionId}.jsonl`), + JSON.stringify({ + timestamp: '2026-04-15T14:02:01.000Z', + type: 'user', + teamName, + agentName: 'bob', + message: { + role: 'user', + content: `You are bootstrapping into team "${teamName}" as member "bob".`, + }, + }) + '\n', + 'utf8' + ); + await fs.writeFile( + path.join(projectRoot, 'unrelated-session.jsonl'), + JSON.stringify({ + timestamp: '2026-04-15T14:02:02.000Z', + type: 'user', + message: { role: 'user', content: 'Unrelated solo session' }, + }) + '\n', + 'utf8' + ); + await fs.writeFile( + path.join(projectRoot, leadSessionId, 'subagents', 'agent-worker.jsonl'), + JSON.stringify({ + timestamp: '2026-04-15T14:02:03.000Z', + type: 'user', + message: { role: 'user', content: `You are bob, a developer on team "${teamName}".` }, + }) + '\n', + 'utf8' + ); + + const locator = new TeamTranscriptSourceLocator(); + const context = await locator.getContext(teamName); + const transcriptFiles = await locator.listTranscriptFiles(teamName); + + expect(context).not.toBeNull(); + expect(context?.projectId).toBe(projectId); + expect(context?.config.projectPath).toBe(projectPath); + expect(context?.sessionIds).toEqual(expect.arrayContaining([leadSessionId, memberSessionId])); + expect(context?.sessionIds).not.toContain('unrelated-session'); + expect(transcriptFiles).toEqual( + expect.arrayContaining([ + path.join(projectRoot, `${leadSessionId}.jsonl`), + path.join(projectRoot, `${memberSessionId}.jsonl`), + path.join(projectRoot, leadSessionId, 'subagents', 'agent-worker.jsonl'), + ]) + ); + expect(transcriptFiles).not.toContain(path.join(projectRoot, 'unrelated-session.jsonl')); + }); +}); From 77d3e9f7d8089f52a1a2d94146a70166df251dae Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 15 Apr 2026 22:40:15 +0300 Subject: [PATCH 041/121] fix(agent-graph): stabilize member slot layout --- .../src/constants/canvas-constants.ts | 2 + .../src/hooks/useGraphSimulation.ts | 40 ++- .../agent-graph/src/layout/kanbanLayout.ts | 39 +-- .../agent-graph/src/layout/stableSlots.ts | 196 +++++++----- packages/agent-graph/src/ui/GraphView.tsx | 62 +--- .../core/domain/buildInlineActivityEntries.ts | 33 +-- .../core/domain/graphOwnerIdentity.ts | 25 ++ .../renderer/adapters/TeamGraphAdapter.ts | 65 ++-- .../renderer/ui/GraphActivityHud.tsx | 279 ++++++++++-------- .../renderer/ui/TeamGraphOverlay.tsx | 23 +- .../agent-graph/renderer/ui/TeamGraphTab.tsx | 23 +- .../agent-graph/GraphActivityHud.test.ts | 38 ++- .../agent-graph/TeamGraphAdapter.test.ts | 74 +++++ .../buildInlineActivityEntries.test.ts | 68 +++++ .../agent-graph/useGraphSimulation.test.ts | 191 +++++++++--- 15 files changed, 724 insertions(+), 434 deletions(-) diff --git a/packages/agent-graph/src/constants/canvas-constants.ts b/packages/agent-graph/src/constants/canvas-constants.ts index e45353da..30f6363f 100644 --- a/packages/agent-graph/src/constants/canvas-constants.ts +++ b/packages/agent-graph/src/constants/canvas-constants.ts @@ -262,6 +262,8 @@ 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 sequence: pending → wip → done → review → approved */ diff --git a/packages/agent-graph/src/hooks/useGraphSimulation.ts b/packages/agent-graph/src/hooks/useGraphSimulation.ts index ae2e8e39..e1eed487 100644 --- a/packages/agent-graph/src/hooks/useGraphSimulation.ts +++ b/packages/agent-graph/src/hooks/useGraphSimulation.ts @@ -9,6 +9,7 @@ import { translateSlotFrame, validateStableSlotLayout, type StableSlotLayoutSnapshot, + type StableRect, type SlotFrame, } from '../layout/stableSlots'; import { KanbanLayoutEngine } from '../layout/kanbanLayout'; @@ -47,7 +48,7 @@ export interface UseGraphSimulationResult { displacedAssignment?: GraphOwnerSlotAssignment; } | null; getLaunchAnchorWorldPosition: (leadNodeId: string) => { x: number; y: number } | null; - getActivityAnchorWorldPosition: (nodeId: string) => { x: number; y: number } | null; + getActivityWorldRect: (nodeId: string) => StableRect | null; getExtraWorldBounds: () => WorldBounds[]; } @@ -65,7 +66,7 @@ export function useGraphSimulation(): UseGraphSimulationResult { 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([]); const prevNodeIdsRef = useRef(new Set()); @@ -91,7 +92,7 @@ export function useGraphSimulation(): UseGraphSimulationResult { lastValidSnapshotByTeamRef, dragOwnerPositionsRef, launchAnchorPositionsRef, - activityAnchorPositionsRef, + activityRectByNodeIdRef, extraWorldBoundsRef, }); return; @@ -111,7 +112,7 @@ export function useGraphSimulation(): UseGraphSimulationResult { lastValidSnapshotByTeamRef, dragOwnerPositionsRef, launchAnchorPositionsRef, - activityAnchorPositionsRef, + activityRectByNodeIdRef, extraWorldBoundsRef, fillMissingFallbackPositions: true, }); @@ -123,7 +124,7 @@ export function useGraphSimulation(): UseGraphSimulationResult { nodes: state.nodes, layoutSnapshotRef, launchAnchorPositionsRef, - activityAnchorPositionsRef, + activityRectByNodeIdRef, extraWorldBoundsRef, }); }, []); @@ -220,7 +221,7 @@ export function useGraphSimulation(): UseGraphSimulationResult { return () => { dragOwnerPositionsRef.current.clear(); launchAnchorPositionsRef.current.clear(); - activityAnchorPositionsRef.current.clear(); + activityRectByNodeIdRef.current.clear(); extraWorldBoundsRef.current = []; layoutSnapshotRef.current = null; lastValidSnapshotByTeamRef.current.clear(); @@ -236,8 +237,7 @@ export function useGraphSimulation(): UseGraphSimulationResult { resolveNearestOwnerSlot, getLaunchAnchorWorldPosition: (leadNodeId: string) => launchAnchorPositionsRef.current.get(leadNodeId) ?? null, - getActivityAnchorWorldPosition: (nodeId: string) => - activityAnchorPositionsRef.current.get(nodeId) ?? null, + getActivityWorldRect: (nodeId: string) => activityRectByNodeIdRef.current.get(nodeId) ?? null, getExtraWorldBounds: () => extraWorldBoundsRef.current, }; } @@ -294,7 +294,7 @@ function commitSnapshotGeometry(args: { lastValidSnapshotByTeamRef: { current: Map }; dragOwnerPositionsRef: { current: ReadonlyMap }; launchAnchorPositionsRef: { current: Map }; - activityAnchorPositionsRef: { current: Map }; + activityRectByNodeIdRef: { current: Map }; extraWorldBoundsRef: { current: WorldBounds[] }; fillMissingFallbackPositions?: boolean; }): void { @@ -306,7 +306,7 @@ function commitSnapshotGeometry(args: { lastValidSnapshotByTeamRef, dragOwnerPositionsRef, launchAnchorPositionsRef, - activityAnchorPositionsRef, + activityRectByNodeIdRef, extraWorldBoundsRef, fillMissingFallbackPositions = false, } = args; @@ -319,7 +319,7 @@ function commitSnapshotGeometry(args: { } launchAnchorPositionsRef.current.clear(); - activityAnchorPositionsRef.current.clear(); + activityRectByNodeIdRef.current.clear(); extraWorldBoundsRef.current = snapshotToWorldBounds(snapshot); if (snapshot.leadNodeId && snapshot.launchAnchor) { @@ -327,36 +327,32 @@ function commitSnapshotGeometry(args: { } for (const frame of getTranslatedMemberFrames(snapshot, dragOwnerPositionsRef.current)) { - activityAnchorPositionsRef.current.set(frame.ownerId, { - x: frame.activityRect.left, - y: frame.activityRect.top, - }); + activityRectByNodeIdRef.current.set(frame.ownerId, frame.activityColumnRect); } - activityAnchorPositionsRef.current.set(`lead:${teamName}`, { - x: snapshot.leadActivityRect.left, - y: snapshot.leadActivityRect.top, - }); + if (snapshot.leadNodeId) { + activityRectByNodeIdRef.current.set(snapshot.leadNodeId, snapshot.leadActivityRect); + } } function resetToFallbackLayout(args: { nodes: GraphNode[]; layoutSnapshotRef: { current: StableSlotLayoutSnapshot | null }; launchAnchorPositionsRef: { current: Map }; - activityAnchorPositionsRef: { current: Map }; + activityRectByNodeIdRef: { current: Map }; extraWorldBoundsRef: { current: WorldBounds[] }; }): void { const { nodes, layoutSnapshotRef, launchAnchorPositionsRef, - activityAnchorPositionsRef, + activityRectByNodeIdRef, extraWorldBoundsRef, } = args; layoutSnapshotRef.current = null; launchAnchorPositionsRef.current.clear(); - activityAnchorPositionsRef.current.clear(); + activityRectByNodeIdRef.current.clear(); extraWorldBoundsRef.current = []; fallbackPositionNodes(nodes); KanbanLayoutEngine.layout(nodes); diff --git a/packages/agent-graph/src/layout/kanbanLayout.ts b/packages/agent-graph/src/layout/kanbanLayout.ts index 9356f7c0..ce0fb704 100644 --- a/packages/agent-graph/src/layout/kanbanLayout.ts +++ b/packages/agent-graph/src/layout/kanbanLayout.ts @@ -11,7 +11,6 @@ import type { GraphNode } from '../ports/types'; import { KANBAN_ZONE, TASK_PILL } from '../constants/canvas-constants'; import { COLORS } from '../constants/colors'; import { resolveActivityLaneSide } from './activityLane'; -import type { ActivityLaneWorldBounds } from './activityLane'; import type { SlotFrame, StableRect } from './stableSlots'; /** Column header info for rendering */ @@ -43,8 +42,6 @@ const COLUMN_LABELS: Record = { approved: { label: 'Approved', color: COLORS.reviewApproved }, }; -const ACTIVITY_KANBAN_CLEARANCE = 24; - export function getOwnerKanbanBaseX(args: { ownerX: number; ownerKind: GraphNode['kind']; @@ -91,7 +88,6 @@ export class KanbanLayoutEngine { static layout( nodes: GraphNode[], options?: { - activityLaneBounds?: readonly ActivityLaneWorldBounds[]; memberSlotFrames?: readonly SlotFrame[]; unassignedTaskRect?: StableRect | null; } @@ -100,7 +96,6 @@ export class KanbanLayoutEngine { nodeMap.clear(); for (const n of nodes) nodeMap.set(n.id, n); const leadX = nodes.find((node) => node.kind === 'lead')?.x ?? null; - const activityLaneBounds = options?.activityLaneBounds ?? []; const memberSlotFrameByOwnerId = new Map( (options?.memberSlotFrames ?? []).map((frame) => [frame.ownerId, frame] as const) ); @@ -148,7 +143,6 @@ export class KanbanLayoutEngine { owner, ownerId, leadX, - activityLaneBounds, memberSlotFrameByOwnerId.get(ownerId) ?? null ); if (zoneInfo) this.zones.push(zoneInfo); @@ -164,11 +158,9 @@ export class KanbanLayoutEngine { owner: GraphNode, ownerId: string, leadX: number | null, - activityLaneBounds: readonly ActivityLaneWorldBounds[], 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; @@ -193,8 +185,6 @@ 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. let baseX = getOwnerKanbanBaseX({ ownerX, ownerKind: owner.kind, @@ -205,27 +195,10 @@ export class KanbanLayoutEngine { let baseY: number; if (slotFrame) { - baseX = slotFrame.taskBandRect.left + TASK_PILL.width / 2; - baseY = slotFrame.taskBandRect.top; + baseX = slotFrame.kanbanBandRect.left + TASK_PILL.width / 2; + baseY = slotFrame.kanbanBandRect.top; } else { - const taskZoneLeft = baseX - TASK_PILL.width / 2; - const taskZoneRight = - baseX + (activeColumns.length - 1) * columnWidth + TASK_PILL.width / 2; - const overlappingActivityBottom = activityLaneBounds.reduce((maxBottom, bounds) => { - if (bounds.ownerId === ownerId) { - return Math.max(maxBottom, bounds.bottom); - } - if (!rangesOverlap(taskZoneLeft, taskZoneRight, bounds.left, bounds.right)) { - return maxBottom; - } - return Math.max(maxBottom, bounds.bottom); - }, -Infinity); - baseY = Math.max( - ownerY + offsetY, - overlappingActivityBottom > -Infinity - ? overlappingActivityBottom + ACTIVITY_KANBAN_CLEARANCE - : -Infinity - ); + baseY = ownerY + offsetY; } // Build headers + position tasks @@ -376,7 +349,3 @@ export class KanbanLayoutEngine { } } } - -function rangesOverlap(aStart: number, aEnd: number, bStart: number, bEnd: number): boolean { - return aStart < bEnd && bStart < aEnd; -} diff --git a/packages/agent-graph/src/layout/stableSlots.ts b/packages/agent-graph/src/layout/stableSlots.ts index cf11900f..448095e2 100644 --- a/packages/agent-graph/src/layout/stableSlots.ts +++ b/packages/agent-graph/src/layout/stableSlots.ts @@ -1,6 +1,6 @@ import { KANBAN_ZONE, TASK_PILL } from '../constants/canvas-constants'; import type { GraphLayoutPort, GraphNode, GraphOwnerSlotAssignment } from '../ports/types'; -import { ACTIVITY_ANCHOR_LAYOUT, ACTIVITY_LANE } from './activityLane'; +import { ACTIVITY_LANE } from './activityLane'; import { LAUNCH_ANCHOR_LAYOUT, type WorldBounds } from './launchAnchor'; import { STABLE_SLOT_GEOMETRY, @@ -24,11 +24,13 @@ export interface OwnerFootprint { slotHeight: number; widthBucket: StableSlotWidthBucket; radialDepth: number; - activityWidth: number; - activityHeight: number; - processRailWidth: number; - taskBandWidth: number; - taskBandHeight: number; + activityColumnWidth: number; + activityColumnHeight: number; + processBandWidth: number; + kanbanBandWidth: number; + kanbanBandHeight: number; + boardBandWidth: number; + boardBandHeight: number; taskColumnCount: number; processCount: number; } @@ -41,9 +43,10 @@ export interface SlotFrame { bounds: StableRect; ownerX: number; ownerY: number; - activityRect: StableRect; + boardBandRect: StableRect; + activityColumnRect: StableRect; processBandRect: StableRect; - taskBandRect: StableRect; + kanbanBandRect: StableRect; taskColumnCount: number; } @@ -93,17 +96,24 @@ type RingLayoutStateMap = ReadonlyMap; const SLOT_GEOMETRY = { ...STABLE_SLOT_GEOMETRY, - activityHeight: ACTIVITY_ANCHOR_LAYOUT.reservedHeight, - activityWidth: ACTIVITY_LANE.width, - activityToOwnerGap: STABLE_SLOT_GEOMETRY.slotVerticalGap, - ownerToProcessGap: STABLE_SLOT_GEOMETRY.slotVerticalGap, - processToTaskGap: STABLE_SLOT_GEOMETRY.slotVerticalGap, - taskBandHeight: + 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 SECTOR_VECTORS = STABLE_SLOT_SECTOR_VECTORS; export function buildStableSlotLayoutSnapshot({ @@ -119,9 +129,9 @@ export function buildStableSlotLayoutSnapshot({ const leadCoreRect = createCenteredRect(0, 0, 200, 168); const leadActivityRect = createRect( leadCoreRect.left - SLOT_GEOMETRY.centralBlockGap - ACTIVITY_LANE.width, - -ACTIVITY_ANCHOR_LAYOUT.reservedHeight / 2, + -SLOT_GEOMETRY.activityColumnHeight / 2, ACTIVITY_LANE.width, - ACTIVITY_ANCHOR_LAYOUT.reservedHeight + SLOT_GEOMETRY.activityColumnHeight ); const launchHudRect = createRect( leadCoreRect.right + SLOT_GEOMETRY.centralBlockGap, @@ -211,37 +221,42 @@ export function computeOwnerFootprints( } const taskColumnCount = taskColumnsByOwnerId.get(ownerId)?.size ?? 0; - const taskBandWidth = + const kanbanBandWidth = taskColumnCount <= 1 ? TASK_PILL.width : TASK_PILL.width + (taskColumnCount - 1) * KANBAN_ZONE.columnWidth; + const processCount = processCountByOwnerId.get(ownerId) ?? 0; + const processBandWidth = computeProcessBandWidth(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.activityWidth, SLOT_GEOMETRY.ownerMinWidth, - SLOT_GEOMETRY.processRailWidth, - taskBandWidth + processBandWidth, + boardBandWidth ); const slotWidth = innerContentWidth + SLOT_GEOMETRY.memberSlotInnerPadding * 2; const slotHeight = SLOT_GEOMETRY.memberSlotInnerPadding * 2 + - SLOT_GEOMETRY.activityHeight + - SLOT_GEOMETRY.activityToOwnerGap + SLOT_GEOMETRY.ownerBandHeight + SLOT_GEOMETRY.ownerToProcessGap + SLOT_GEOMETRY.processBandHeight + - SLOT_GEOMETRY.processToTaskGap + - SLOT_GEOMETRY.taskBandHeight; + SLOT_GEOMETRY.processToBoardGap + + boardBandHeight; const radialDepth = Math.max( SLOT_GEOMETRY.memberSlotInnerPadding + - SLOT_GEOMETRY.activityHeight + - SLOT_GEOMETRY.activityToOwnerGap + SLOT_GEOMETRY.ownerBandHeight / 2, SLOT_GEOMETRY.memberSlotInnerPadding + SLOT_GEOMETRY.ownerBandHeight / 2 + SLOT_GEOMETRY.ownerToProcessGap + SLOT_GEOMETRY.processBandHeight + - SLOT_GEOMETRY.processToTaskGap + - SLOT_GEOMETRY.taskBandHeight + SLOT_GEOMETRY.processToBoardGap + + boardBandHeight ); return [ @@ -251,13 +266,15 @@ export function computeOwnerFootprints( slotHeight, widthBucket: classifyWidthBucket(slotWidth), radialDepth, - activityWidth: SLOT_GEOMETRY.activityWidth, - activityHeight: SLOT_GEOMETRY.activityHeight, - processRailWidth: SLOT_GEOMETRY.processRailWidth, - taskBandWidth, - taskBandHeight: SLOT_GEOMETRY.taskBandHeight, + activityColumnWidth: SLOT_GEOMETRY.activityColumnWidth, + activityColumnHeight: SLOT_GEOMETRY.activityColumnHeight, + processBandWidth, + kanbanBandWidth, + kanbanBandHeight: SLOT_GEOMETRY.kanbanBandHeight, + boardBandWidth, + boardBandHeight, taskColumnCount, - processCount: processCountByOwnerId.get(ownerId) ?? 0, + processCount, } satisfies OwnerFootprint, ]; }); @@ -273,6 +290,16 @@ export function classifyWidthBucket(width: number): StableSlotWidthBucket { 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; @@ -461,14 +488,35 @@ function validateMemberSlotFrame( if (rectsOverlap(frame.bounds, snapshot.runtimeCentralExclusion)) { return { valid: false, reason: `slot frame for ${frame.ownerId} overlaps runtimeCentralExclusion` }; } - if (!rectContainsRect(frame.bounds, frame.activityRect)) { - return { valid: false, reason: `activityRect escapes slot bounds for ${frame.ownerId}` }; + if (!rectContainsRect(frame.bounds, frame.boardBandRect)) { + return { valid: false, reason: `boardBandRect escapes slot bounds for ${frame.ownerId}` }; + } + if (!rectContainsRect(frame.bounds, frame.activityColumnRect)) { + return { valid: false, reason: `activityColumnRect escapes slot bounds for ${frame.ownerId}` }; } if (!rectContainsRect(frame.bounds, frame.processBandRect)) { return { valid: false, reason: `processBandRect escapes slot bounds for ${frame.ownerId}` }; } - if (!rectContainsRect(frame.bounds, frame.taskBandRect)) { - return { valid: false, reason: `taskBandRect escapes slot bounds for ${frame.ownerId}` }; + if (!rectContainsRect(frame.bounds, frame.kanbanBandRect)) { + return { valid: false, reason: `kanbanBandRect escapes slot bounds for ${frame.ownerId}` }; + } + if (!rectContainsRect(frame.boardBandRect, frame.activityColumnRect)) { + return { + valid: false, + reason: `activityColumnRect escapes boardBandRect for ${frame.ownerId}`, + }; + } + if (!rectContainsRect(frame.boardBandRect, frame.kanbanBandRect)) { + return { + valid: false, + reason: `kanbanBandRect escapes boardBandRect for ${frame.ownerId}`, + }; + } + if (rectsOverlap(frame.activityColumnRect, frame.kanbanBandRect)) { + return { + valid: false, + reason: `activityColumnRect overlaps kanbanBandRect for ${frame.ownerId}`, + }; } if (!pointInRect(frame.ownerX, frame.ownerY, frame.bounds)) { return { valid: false, reason: `owner anchor escapes slot bounds for ${frame.ownerId}` }; @@ -502,9 +550,10 @@ export function translateSlotFrame(frame: SlotFrame, dx: number, dy: number): Sl bounds: translateRect(frame.bounds, dx, dy), ownerX: frame.ownerX + dx, ownerY: frame.ownerY + dy, - activityRect: translateRect(frame.activityRect, dx, dy), + boardBandRect: translateRect(frame.boardBandRect, dx, dy), + activityColumnRect: translateRect(frame.activityColumnRect, dx, dy), processBandRect: translateRect(frame.processBandRect, dx, dy), - taskBandRect: translateRect(frame.taskBandRect, dx, dy), + kanbanBandRect: translateRect(frame.kanbanBandRect, dx, dy), }; } @@ -554,7 +603,7 @@ function buildUnassignedTaskRect( columnCount <= 1 ? TASK_PILL.width : TASK_PILL.width + (columnCount - 1) * KANBAN_ZONE.columnWidth; - const height = SLOT_GEOMETRY.taskBandHeight; + const height = SLOT_GEOMETRY.kanbanBandHeight; return createRect( -width / 2, leadCentralReservedBlock.bottom + SLOT_GEOMETRY.unassignedGap, @@ -695,32 +744,36 @@ function buildSlotFrame( const ownerX = vector.x * radius; const ownerY = vector.y * radius; const slotTop = - ownerY - - (SLOT_GEOMETRY.memberSlotInnerPadding + - SLOT_GEOMETRY.activityHeight + - SLOT_GEOMETRY.activityToOwnerGap + - SLOT_GEOMETRY.ownerBandHeight / 2); - const bounds = createRect(ownerX - footprint.slotWidth / 2, slotTop, footprint.slotWidth, footprint.slotHeight); - const activityRect = createRect( - bounds.left + (bounds.width - footprint.activityWidth) / 2, - bounds.top + SLOT_GEOMETRY.memberSlotInnerPadding, - footprint.activityWidth, - footprint.activityHeight + 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.processRailWidth) / 2, - activityRect.bottom + - SLOT_GEOMETRY.activityToOwnerGap + - SLOT_GEOMETRY.ownerBandHeight + - SLOT_GEOMETRY.ownerToProcessGap, - footprint.processRailWidth, + bounds.left + (bounds.width - footprint.processBandWidth) / 2, + ownerY + SLOT_GEOMETRY.ownerBandHeight / 2 + SLOT_GEOMETRY.ownerToProcessGap, + footprint.processBandWidth, SLOT_GEOMETRY.processBandHeight ); - const taskBandRect = createRect( - bounds.left + (bounds.width - footprint.taskBandWidth) / 2, - processBandRect.bottom + SLOT_GEOMETRY.processToTaskGap, - footprint.taskBandWidth, - footprint.taskBandHeight + 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 { @@ -731,9 +784,10 @@ function buildSlotFrame( bounds, ownerX, ownerY, - activityRect, + boardBandRect, + activityColumnRect, processBandRect, - taskBandRect, + kanbanBandRect, taskColumnCount: footprint.taskColumnCount, }; } @@ -964,11 +1018,7 @@ function tryBuildValidSlotFrame( if (!frame) { return null; } - if ( - args.placedFrames.some((existing) => - rectsOverlapWithGap(existing.bounds, frame.bounds, SLOT_GEOMETRY.ringPadding) - ) - ) { + if (!isSlotFramePlacementValid(frame, args.placedFrames, args.centralExclusion)) { return null; } args.usedSlotKeys.add(slotKey); @@ -1079,11 +1129,7 @@ function computeSlotDirectionalDepths( footprint: OwnerFootprint, vector: { x: number; y: number } ): { outwardDepth: number; inwardDepth: number } { - const ownerLocalY = - SLOT_GEOMETRY.memberSlotInnerPadding + - SLOT_GEOMETRY.activityHeight + - SLOT_GEOMETRY.activityToOwnerGap + - SLOT_GEOMETRY.ownerBandHeight / 2; + const ownerLocalY = SLOT_GEOMETRY.memberSlotInnerPadding + SLOT_GEOMETRY.ownerBandHeight / 2; const topOffset = -ownerLocalY; const bottomOffset = footprint.slotHeight - ownerLocalY; const halfWidth = footprint.slotWidth / 2; diff --git a/packages/agent-graph/src/ui/GraphView.tsx b/packages/agent-graph/src/ui/GraphView.tsx index 807238ac..052c1465 100644 --- a/packages/agent-graph/src/ui/GraphView.tsx +++ b/packages/agent-graph/src/ui/GraphView.tsx @@ -16,6 +16,7 @@ import type { GraphDataPort } from '../ports/GraphDataPort'; import type { GraphEventPort } from '../ports/GraphEventPort'; import type { GraphConfigPort } from '../ports/GraphConfigPort'; 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'; @@ -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 { @@ -70,19 +70,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; - getActivityAnchorWorldPosition: ( - ownerNodeId: string, - ) => { x: number; y: number } | 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 }; - getNodeScreenPosition: ( - nodeId: string, - ) => { x: number; y: number; visible: boolean } | null; focusNodeIds: ReadonlySet | null; }) => React.ReactNode; } @@ -240,49 +232,11 @@ 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 getActivityAnchorWorldPosition = useCallback( - (ownerNodeId: string) => simulationRef.current.getActivityAnchorWorldPosition(ownerNodeId), - [], - ); const getCameraZoom = useCallback(() => cameraRef.current.transformRef.current.zoom, []); - const getNodeScreenPosition = useCallback((nodeId: string) => { - const viewport = getViewportSize(); - if (viewport.width <= 0 || viewport.height <= 0) { - return null; - } - const node = simulationRef.current.stateRef.current.nodes.find((candidate) => candidate.id === nodeId); - if (node?.x == null || node?.y == null) { - return null; - } - const transform = cameraRef.current.transformRef.current; - const x = node.x * transform.zoom + transform.x; - const y = node.y * transform.zoom + transform.y; - return { - x, - y, - visible: x > -80 && x < viewport.width + 80 && y > -80 && y < viewport.height + 80, - }; - }, [getViewportSize]); + const 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?.x == null || node?.y == null) { @@ -800,13 +754,11 @@ export function GraphView({
{renderHud({ getLaunchAnchorScreenPlacement, - getActivityAnchorScreenPlacement, - getActivityAnchorWorldPosition, + getActivityWorldRect, getCameraZoom, worldToScreen: camera.worldToScreen, getNodeWorldPosition, getViewportSize, - getNodeScreenPosition, focusNodeIds: focusState.focusNodeIds, })}
diff --git a/src/features/agent-graph/core/domain/buildInlineActivityEntries.ts b/src/features/agent-graph/core/domain/buildInlineActivityEntries.ts index 13304ff3..a5ddfa5c 100644 --- a/src/features/agent-graph/core/domain/buildInlineActivityEntries.ts +++ b/src/features/agent-graph/core/domain/buildInlineActivityEntries.ts @@ -3,7 +3,7 @@ import { getIdleGraphLabel } from '@shared/utils/idleNotificationSemantics'; import { isInboxNoiseMessage } from '@shared/utils/inboxNoise'; import { isLeadMember } from '@shared/utils/leadDetection'; -import { buildGraphMemberNodeIdForMember } from './graphOwnerIdentity'; +import { buildGraphMemberNodeIdAliasMap } from './graphOwnerIdentity'; import type { GraphActivityItem } from '@claude-teams/agent-graph'; import type { @@ -53,10 +53,9 @@ export function buildInlineActivityEntries({ ownerNodeIds, }: BuildInlineActivityEntriesArgs): Map { const entriesByOwnerNodeId = new Map(); - const memberNodeIdByName = new Map( - data.members - .filter((member) => !isLeadMember(member)) - .map((member) => [member.name, buildGraphMemberNodeIdForMember(teamName, member)] as const) + const memberNodeIdByAlias = buildGraphMemberNodeIdAliasMap( + teamName, + data.members.filter((member) => !isLeadMember(member)) ); const appendEntry = (entry: InlineActivityEntry): void => { @@ -95,7 +94,7 @@ export function buildInlineActivityEntries({ leadId, leadName, ownerNodeIds, - memberNodeIdByName, + memberNodeIdByAlias, }); if (!ownerNodeId) { continue; @@ -140,7 +139,7 @@ export function buildInlineActivityEntries({ leadId, leadName, ownerNodeIds, - memberNodeIdByName, + memberNodeIdByAlias, }); if (!ownerNodeId) { continue; @@ -220,16 +219,16 @@ function resolveMessageOwnerNodeId(args: { leadId: string; leadName: string; ownerNodeIds: ReadonlySet; - memberNodeIdByName: ReadonlyMap; + memberNodeIdByAlias: ReadonlyMap; }): string | null { - const { message, leadId, leadName, ownerNodeIds, memberNodeIdByName } = 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 ?? '', leadId, leadName, memberNodeIdByName); + const fromId = resolveParticipantId(message.from ?? '', leadId, leadName, memberNodeIdByAlias); const toId = message.to - ? resolveParticipantId(message.to, leadId, leadName, memberNodeIdByName) + ? resolveParticipantId(message.to, leadId, leadName, memberNodeIdByAlias) : leadId; if (toId !== leadId && ownerNodeIds.has(toId)) { @@ -247,17 +246,17 @@ function resolveCommentOwnerNodeId(args: { leadId: string; leadName: string; ownerNodeIds: ReadonlySet; - memberNodeIdByName: ReadonlyMap; + memberNodeIdByAlias: ReadonlyMap; }): string | null { - const { taskOwner, author, leadId, leadName, ownerNodeIds, memberNodeIdByName } = args; + const { taskOwner, author, leadId, leadName, ownerNodeIds, memberNodeIdByAlias } = args; if (taskOwner) { - const ownerId = resolveParticipantId(taskOwner, leadId, leadName, memberNodeIdByName); + const ownerId = resolveParticipantId(taskOwner, leadId, leadName, memberNodeIdByAlias); if (ownerNodeIds.has(ownerId)) { return ownerId; } } - const authorId = resolveParticipantId(author, leadId, leadName, memberNodeIdByName); + const authorId = resolveParticipantId(author, leadId, leadName, memberNodeIdByAlias); if (ownerNodeIds.has(authorId)) { return authorId; } @@ -367,7 +366,7 @@ function resolveParticipantId( name: string, leadId: string, leadName: string | undefined, - memberNodeIdByName: ReadonlyMap + memberNodeIdByAlias: ReadonlyMap ): string { const normalized = name.trim().toLowerCase(); if (normalized === 'user' || normalized === 'team-lead') { @@ -376,7 +375,7 @@ function resolveParticipantId( if (normalized === leadName?.trim().toLowerCase()) { return leadId; } - return memberNodeIdByName.get(name) ?? leadId; + return memberNodeIdByAlias.get(name) ?? leadId; } function buildParticipantLabel(name: string | undefined, leadName: string): string { diff --git a/src/features/agent-graph/core/domain/graphOwnerIdentity.ts b/src/features/agent-graph/core/domain/graphOwnerIdentity.ts index bb050616..02a02aa8 100644 --- a/src/features/agent-graph/core/domain/graphOwnerIdentity.ts +++ b/src/features/agent-graph/core/domain/graphOwnerIdentity.ts @@ -17,6 +17,31 @@ export function buildGraphMemberNodeIdForMember( 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)) { diff --git a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts index 9586f4d0..86b53c9f 100644 --- a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts +++ b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts @@ -30,6 +30,7 @@ import { } from '../../core/domain/buildInlineActivityEntries'; import { collapseOverflowStacksWithMeta } from '../../core/domain/collapseOverflowStacks'; import { + buildGraphMemberNodeIdAliasMap, buildGraphMemberNodeIdForMember, getGraphStableOwnerId, GRAPH_STABLE_SLOT_LAYOUT_VERSION, @@ -134,7 +135,7 @@ export class TeamGraphAdapter { const leadId = `lead:${teamName}`; const leadName = TeamGraphAdapter.#getLeadMemberName(teamData, teamName); - const memberNodeIdByName = TeamGraphAdapter.#buildMemberNodeIdByName(teamData, teamName); + const memberNodeIdByAlias = TeamGraphAdapter.#buildMemberNodeIdByAlias(teamData, teamName); const provisioningPresentation = buildTeamProvisioningPresentation({ progress: provisioningProgress, members: teamData.members, @@ -164,7 +165,7 @@ export class TeamGraphAdapter { leadId, teamData, teamName, - memberNodeIdByName, + memberNodeIdByAlias, spawnStatuses, pendingApprovalAgents, activeTools, @@ -173,8 +174,8 @@ export class TeamGraphAdapter { isTeamProvisioning, isLaunchSettling ); - this.#buildTaskNodes(nodes, edges, teamData, teamName, commentReadState, memberNodeIdByName); - this.#buildProcessNodes(nodes, edges, teamData, teamName, memberNodeIdByName); + 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, @@ -184,7 +185,7 @@ export class TeamGraphAdapter { leadId, leadName, edges, - memberNodeIdByName + memberNodeIdByAlias ); this.#buildCommentParticles( particles, @@ -193,7 +194,7 @@ export class TeamGraphAdapter { leadId, leadName, edges, - memberNodeIdByName + memberNodeIdByAlias ); return { @@ -226,11 +227,10 @@ export class TeamGraphAdapter { return getGraphLeadMemberName(data, teamName); } - static #buildMemberNodeIdByName(data: TeamData, teamName: string): Map { - return new Map( - data.members - .filter((member) => !isLeadMember(member)) - .map((member) => [member.name, buildGraphMemberNodeIdForMember(teamName, member)] as const) + static #buildMemberNodeIdByAlias(data: TeamData, teamName: string): Map { + return buildGraphMemberNodeIdAliasMap( + teamName, + data.members.filter((member) => !isLeadMember(member)) ); } @@ -464,7 +464,7 @@ export class TeamGraphAdapter { leadId: string, data: TeamData, teamName: string, - memberNodeIdByName: ReadonlyMap, + memberNodeIdByAlias: ReadonlyMap, spawnStatuses?: Record, pendingApprovalAgents?: Set, activeTools?: Record>, @@ -478,7 +478,7 @@ export class TeamGraphAdapter { if (isLeadMember(member)) continue; const memberId = - memberNodeIdByName.get(member.name) ?? buildGraphMemberNodeIdForMember(teamName, member); + memberNodeIdByAlias.get(member.name) ?? buildGraphMemberNodeIdForMember(teamName, member); const spawn = spawnStatuses?.[member.name]; const activeTool = TeamGraphAdapter.#selectVisibleTool( activeTools?.[member.name], @@ -568,7 +568,7 @@ export class TeamGraphAdapter { data: TeamData, teamName: string, commentReadState?: Record, - memberNodeIdByName?: ReadonlyMap + memberNodeIdByAlias?: ReadonlyMap ): void { const taskStateById = new Map>(); const taskDisplayIds = new Map(); @@ -589,7 +589,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 ? (memberNodeIdByName?.get(task.owner) ?? null) : 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); @@ -752,11 +752,11 @@ export class TeamGraphAdapter { edges: GraphEdge[], data: TeamData, teamName: string, - memberNodeIdByName?: ReadonlyMap + memberNodeIdByAlias?: ReadonlyMap ): void { for (const { process: proc, ownerId } of TeamGraphAdapter.#selectRelevantProcesses( data.processes, - memberNodeIdByName + memberNodeIdByAlias )) { const procId = `process:${teamName}:${proc.id}`; @@ -786,13 +786,13 @@ export class TeamGraphAdapter { static #selectRelevantProcesses( processes: readonly TeamProcess[], - memberNodeIdByName?: ReadonlyMap + memberNodeIdByAlias?: ReadonlyMap ): { process: TeamProcess; ownerId: string }[] { const selectedByOwnerId = new Map(); for (const process of processes) { const ownerId = process.registeredBy - ? (memberNodeIdByName?.get(process.registeredBy) ?? null) + ? (memberNodeIdByAlias?.get(process.registeredBy) ?? null) : null; if (!ownerId) { continue; @@ -872,7 +872,7 @@ export class TeamGraphAdapter { leadId: string, leadName: string, edges: GraphEdge[], - memberNodeIdByName: ReadonlyMap + memberNodeIdByAlias: ReadonlyMap ): void { const ordered = [...messages].reverse(); @@ -960,7 +960,7 @@ export class TeamGraphAdapter { leadId, leadName, edges, - memberNodeIdByName + memberNodeIdByAlias ); if (!edgeId) continue; @@ -970,7 +970,7 @@ export class TeamGraphAdapter { msg.from ?? '', leadId, leadName, - memberNodeIdByName + memberNodeIdByAlias ); const isFromTeammate = fromId !== leadId; @@ -1011,7 +1011,7 @@ export class TeamGraphAdapter { leadId: string, leadName: string, edges: GraphEdge[], - memberNodeIdByName: ReadonlyMap + memberNodeIdByAlias: ReadonlyMap ): void { // First call: record current comment counts without creating particles. // This prevents pre-existing comments from spawning particles when the graph opens. @@ -1052,7 +1052,7 @@ export class TeamGraphAdapter { newComment.author, leadId, leadName, - memberNodeIdByName + memberNodeIdByAlias ); const taskNodeId = `task:${teamName}:${task.id}`; const authorEdge = @@ -1187,7 +1187,7 @@ export class TeamGraphAdapter { leadId: string, leadName: string, edges: GraphEdge[], - memberNodeIdByName: ReadonlyMap + memberNodeIdByAlias: ReadonlyMap ): string | null { const { from, to } = msg; @@ -1196,9 +1196,14 @@ export class TeamGraphAdapter { from, leadId, leadName, - memberNodeIdByName + memberNodeIdByAlias + ); + const toId = TeamGraphAdapter.#resolveParticipantId( + to, + leadId, + leadName, + memberNodeIdByAlias ); - const toId = TeamGraphAdapter.#resolveParticipantId(to, leadId, leadName, memberNodeIdByName); return ( edges.find((e) => e.source === fromId && e.target === toId)?.id ?? edges.find((e) => e.source === toId && e.target === fromId)?.id ?? @@ -1211,7 +1216,7 @@ export class TeamGraphAdapter { from, leadId, leadName, - memberNodeIdByName + memberNodeIdByAlias ); return ( edges.find( @@ -1229,12 +1234,12 @@ export class TeamGraphAdapter { name: string, leadId: string, leadName: string | undefined, - memberNodeIdByName: ReadonlyMap + memberNodeIdByAlias: ReadonlyMap ): string { const normalized = name.trim().toLowerCase(); if (normalized === 'user' || normalized === 'team-lead') return leadId; if (normalized === leadName?.trim().toLowerCase()) return leadId; - return memberNodeIdByName.get(name) ?? leadId; + 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/ui/GraphActivityHud.tsx b/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx index b157fb78..42bb1a73 100644 --- a/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx +++ b/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; -import { ACTIVITY_ANCHOR_LAYOUT, ACTIVITY_LANE } from '@claude-teams/agent-graph'; +import { ACTIVITY_LANE } from '@claude-teams/agent-graph'; import { ActivityItem } from '@renderer/components/team/activity/ActivityItem'; import { buildMessageContext, @@ -26,18 +26,26 @@ import type { } 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[]; - getActivityAnchorScreenPlacement: ( - ownerNodeId: string - ) => { x: number; y: number; scale: number; visible: boolean } | null; - getActivityAnchorWorldPosition?: (ownerNodeId: string) => { x: number; y: number } | null; + 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 }; - getNodeScreenPosition?: (nodeId: string) => { x: number; y: number; visible: boolean } | null; focusNodeIds: ReadonlySet | null; enabled?: boolean; onOpenTaskDetail?: (taskId: string) => void; @@ -53,13 +61,11 @@ interface GraphActivityHudProps { export const GraphActivityHud = ({ teamName, nodes, - getActivityAnchorScreenPlacement, - getActivityAnchorWorldPosition = () => null, + getActivityWorldRect = () => null, getCameraZoom = () => 1, worldToScreen, getNodeWorldPosition = () => null, getViewportSize, - getNodeScreenPosition = () => null, focusNodeIds, enabled = true, onOpenTaskDetail, @@ -125,7 +131,9 @@ export const GraphActivityHud = ({ overflowCount, }; }) - .filter((lane) => lane.entries.length > 0 || lane.overflowCount > 0); + .filter( + (lane) => lane.node.kind === 'member' || lane.entries.length > 0 || lane.overflowCount > 0 + ); }, [entryMapByOwnerNodeId, ownerNodes]); useLayoutEffect(() => { @@ -157,7 +165,7 @@ export const GraphActivityHud = ({ shell: HTMLDivElement; connector: SVGSVGElement | null; connectorPath: SVGPathElement | null; - laneTopLeft: { x: number; y: number }; + laneRect: NonNullable>; nodeWorld: { x: number; y: number }; }[] = []; @@ -169,10 +177,9 @@ export const GraphActivityHud = ({ const connector = connectorRefs.current.get(lane.node.id) ?? null; const connectorPath = connectorPathRefs.current.get(lane.node.id) ?? null; - const placement = getActivityAnchorScreenPlacement(lane.node.id); - const laneTopLeft = getActivityAnchorWorldPosition(lane.node.id); + const laneRect = getActivityWorldRect(lane.node.id); const nodeWorld = getNodeWorldPosition(lane.node.id); - if (!placement || !laneTopLeft || !nodeWorld) { + if (!laneRect || !nodeWorld || !worldToScreen) { shell.style.opacity = '0'; if (connector) { connector.style.opacity = '0'; @@ -180,19 +187,18 @@ export const GraphActivityHud = ({ continue; } - const scale = Math.max(getCameraZoom(), 0.001); - const widthScreen = Math.max(1, (shell.offsetWidth || ACTIVITY_LANE.width) * scale); - const heightScreen = Math.max(1, (shell.offsetHeight || 220) * scale); + 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 - ? placement.x + widthScreen > -80 && - placement.x < viewport.width + 80 && - placement.y + heightScreen > -80 && - placement.y < viewport.height + 80 - : placement.visible; - - const nodeScreen = getNodeScreenPosition(lane.node.id); - if (!nodeScreen?.visible || !laneVisible) { + 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'; @@ -205,31 +211,23 @@ export const GraphActivityHud = ({ shell, connector, connectorPath, - laneTopLeft, + laneRect, nodeWorld, }); } for (const entry of measurableLanes) { - const { lane, shell, connector, connectorPath, laneTopLeft, nodeWorld } = entry; + const { lane, shell, connector, connectorPath, laneRect, nodeWorld } = entry; const baseOpacity = focusNodeIds && !focusNodeIds.has(lane.node.id) ? 0.25 : 1; - const widthWorld = shell.offsetWidth || ACTIVITY_LANE.width; - const heightWorld = shell.offsetHeight || 220; - const ownerBottomLimit = - nodeWorld.y + - (lane.node.kind === 'lead' - ? ACTIVITY_ANCHOR_LAYOUT.leadOffsetY + ACTIVITY_ANCHOR_LAYOUT.reservedHeight - : ACTIVITY_ANCHOR_LAYOUT.memberOffsetY + ACTIVITY_ANCHOR_LAYOUT.reservedHeight); - const adjustedLaneTop = Math.min(laneTopLeft.y, ownerBottomLimit - heightWorld); shell.style.opacity = String(baseOpacity); - shell.style.left = `${Math.round(laneTopLeft.x)}px`; - shell.style.top = `${Math.round(adjustedLaneTop)}px`; + shell.style.left = `${Math.round(laneRect.left)}px`; + shell.style.top = `${Math.round(laneRect.top)}px`; shell.style.transform = ''; if (connector && connectorPath) { - const endX = laneTopLeft.x + widthWorld / 2; - const endY = adjustedLaneTop + heightWorld - 6; + 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); @@ -273,11 +271,9 @@ export const GraphActivityHud = ({ }, [ enabled, focusNodeIds, - getActivityAnchorScreenPlacement, - getActivityAnchorWorldPosition, + getActivityWorldRect, getCameraZoom, getNodeWorldPosition, - getNodeScreenPosition, getViewportSize, worldToScreen, visibleLanes, @@ -341,7 +337,9 @@ export const GraphActivityHud = ({ if (!(canvas instanceof HTMLCanvasElement)) { return; } - event.preventDefault(); + if (event.cancelable) { + event.preventDefault(); + } canvas.dispatchEvent( new WheelEvent('wheel', { deltaX: event.deltaX, @@ -395,91 +393,118 @@ export const GraphActivityHud = ({ > {visibleLanes.map((lane) => (
- { - 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 opacity-0" - style={{ width: `${ACTIVITY_LANE.width}px`, maxWidth: `${ACTIVITY_LANE.width}px` }} - > -
- Activity -
-
- {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); + {(() => { + const laneRect = getActivityWorldRect(lane.node.id); + const laneWidth = laneRect?.width ?? ACTIVITY_LANE.width; + const laneHeight = laneRect?.height ?? ACTIVITY_SHELL_HEIGHT; - return ( -
handleMessageClick(timelineItem)} - onKeyDown={(event) => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - handleMessageClick(timelineItem); - } - }} - > - -
- ); - })} - - {lane.overflowCount > 0 ? ( - - ) : null} -
-
+ { + 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 opacity-0" + style={{ + width: `${laneWidth}px`, + maxWidth: `${laneWidth}px`, + height: `${laneHeight}px`, + }} + > +
+
+ 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} +
+
+
+ + ); + })()}
))}
diff --git a/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx b/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx index fbf6287e..74df726b 100644 --- a/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx +++ b/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx @@ -131,33 +131,30 @@ export const TeamGraphOverlay = ({ renderHud={(hudProps) => { const extraHudProps = hudProps as typeof hudProps & { getViewportSize?: () => { width: number; height: number }; - getActivityAnchorWorldPosition?: ( - ownerNodeId: string - ) => { x: number; y: number } | null; + 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 { - getLaunchAnchorScreenPlacement, - getActivityAnchorScreenPlacement, - getViewportSize, - getNodeScreenPosition, - focusNodeIds, - } = extraHudProps; + const { getLaunchAnchorScreenPlacement, getViewportSize, focusNodeIds } = extraHudProps; return ( <> { const extraHudProps = hudProps as typeof hudProps & { getViewportSize?: () => { width: number; height: number }; - getActivityAnchorWorldPosition?: ( - ownerNodeId: string - ) => { x: number; y: number } | null; + 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 { - getLaunchAnchorScreenPlacement, - getActivityAnchorScreenPlacement, - getViewportSize, - getNodeScreenPosition, - focusNodeIds, - } = extraHudProps; + const { getLaunchAnchorScreenPlacement, getViewportSize, focusNodeIds } = extraHudProps; return ( <> { React.createElement(GraphActivityHud, { teamName: 'demo-team', nodes: [node], - getActivityAnchorScreenPlacement: () => ({ x: 40, y: 80, scale: 1, visible: true }), + getActivityWorldRect: () => ({ + left: 40, + top: 80, + right: 336, + bottom: 372, + width: 296, + height: 292, + }), + getCameraZoom: () => 1, + worldToScreen: (x: number, y: number) => ({ x, y }), + getNodeWorldPosition: () => ({ x: 120, y: 40 }), + getViewportSize: () => ({ width: 1200, height: 800 }), focusNodeIds: null, onOpenMemberProfile, }) @@ -256,7 +266,7 @@ describe('GraphActivityHud', () => { }); }); - it('keeps the activity lane above the owner label area when packed anchor drifts too low', async () => { + it('pins the activity lane to the provided world rect without post-hoc repositioning', async () => { const message: InboxMessage = { from: 'team-lead', to: 'jack', @@ -307,17 +317,23 @@ describe('GraphActivityHud', () => { document.body.appendChild(host); const root = createRoot(host); const nodeWorld = { x: 320, y: 300 }; - const packedAnchor = { x: 120, y: 260 }; + const laneRect = { + left: 120, + top: 340, + right: 416, + bottom: 632, + width: 296, + height: 292, + }; await act(async () => { root.render( React.createElement(GraphActivityHud, { teamName: 'demo-team', nodes: [node], - getActivityAnchorScreenPlacement: () => ({ x: 40, y: 80, scale: 1, visible: true }), - getActivityAnchorWorldPosition: () => packedAnchor, + getActivityWorldRect: () => laneRect, + getCameraZoom: () => 1, getNodeWorldPosition: () => nodeWorld, - getNodeScreenPosition: () => ({ x: 400, y: 300, visible: true }), getViewportSize: () => ({ width: 1200, height: 800 }), worldToScreen: (x: number, y: number) => ({ x, y }), focusNodeIds: null, @@ -328,12 +344,8 @@ describe('GraphActivityHud', () => { const shell = host.querySelector('.z-10'); expect(shell).not.toBeNull(); - const expectedTop = - nodeWorld.y + - ACTIVITY_ANCHOR_LAYOUT.memberOffsetY + - ACTIVITY_ANCHOR_LAYOUT.reservedHeight - - 220; - expect((shell as HTMLDivElement).style.top).toBe(`${Math.round(expectedTop)}px`); + expect((shell as HTMLDivElement).style.left).toBe(`${laneRect.left}px`); + expect((shell as HTMLDivElement).style.top).toBe(`${laneRect.top}px`); await act(async () => { root.unmount(); diff --git a/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts index 4c2ba66f..af760759 100644 --- a/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts +++ b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts @@ -736,6 +736,80 @@ describe('TeamGraphAdapter particles', () => { ]); }); + it('resolves task and process owners by stable owner id aliases, not only member names', () => { + const adapter = TeamGraphAdapter.create(); + + const graph = adapter.adapt( + createBaseTeamData({ + config: { + name: 'My Team', + members: [ + { name: 'team-lead', agentId: 'lead-agent' }, + { name: 'alice', agentId: 'agent-alice' }, + ], + projectPath: '/repo', + }, + members: [ + { + name: 'team-lead', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + agentType: 'team-lead', + agentId: 'lead-agent', + }, + { + name: 'alice', + status: 'active', + currentTaskId: null, + taskCount: 1, + lastActiveAt: null, + messageCount: 0, + agentId: 'agent-alice', + }, + ], + tasks: [ + { + id: 'task-owned-by-stable-id', + displayId: '#42', + subject: 'Stable owner task', + owner: 'agent-alice', + status: 'completed', + comments: [], + reviewState: 'none', + } as TeamTaskWithKanban, + ], + processes: [ + { + id: 'proc-owned-by-stable-id', + label: 'Stable owner process', + pid: 4242, + registeredBy: 'agent-alice', + registeredAt: '2026-03-28T19:00:02.000Z', + }, + ], + }), + 'my-team' + ); + + expect(findNode(graph, 'task:my-team:task-owned-by-stable-id')).toMatchObject({ + ownerId: 'member:my-team:agent-alice', + taskStatus: 'completed', + }); + expect(findNode(graph, 'process:my-team:proc-owned-by-stable-id')).toMatchObject({ + ownerId: 'member:my-team:agent-alice', + }); + expect( + graph.edges.some( + (edge) => + edge.id === + 'edge:own:member:my-team:agent-alice:task:my-team:task-owned-by-stable-id' + ) + ).toBe(true); + }); + it('skips noisy idle inbox rows in the activity feed while keeping cross-team traffic on the lead lane', () => { const adapter = TeamGraphAdapter.create(); diff --git a/test/renderer/features/agent-graph/buildInlineActivityEntries.test.ts b/test/renderer/features/agent-graph/buildInlineActivityEntries.test.ts index 6c97a6ff..2d556459 100644 --- a/test/renderer/features/agent-graph/buildInlineActivityEntries.test.ts +++ b/test/renderer/features/agent-graph/buildInlineActivityEntries.test.ts @@ -199,4 +199,72 @@ describe('buildInlineActivityEntries', () => { taskRefs: [{ taskId: 'task-1', displayId: '#8fdd6803', teamName: 'my-team' }], }); }); + + it('routes comment activity to a member lane when task.owner is stored as stable owner id', () => { + const data = createBaseTeamData({ + config: { + name: 'My Team', + members: [{ name: 'team-lead', agentId: 'lead-agent' }, { name: 'jack', agentId: 'agent-jack' }], + projectPath: '/repo', + }, + tasks: [ + { + id: 'task-stable-owner', + displayId: '#91', + subject: 'Stable owner routing', + owner: 'agent-jack', + status: 'in_progress', + comments: [ + { + id: 'comment-stable-owner', + author: 'team-lead', + text: 'Проверь финальную сводку перед merge', + createdAt: '2026-03-28T19:00:03.000Z', + type: 'regular', + }, + ], + reviewState: 'none', + } as unknown as TeamTaskWithKanban, + ], + members: [ + { + name: 'team-lead', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + agentType: 'team-lead', + agentId: 'lead-agent', + }, + { + name: 'jack', + status: 'active', + currentTaskId: null, + taskCount: 1, + lastActiveAt: null, + messageCount: 0, + agentId: 'agent-jack', + }, + ], + }); + + const entries = buildInlineActivityEntries({ + data, + teamName: 'my-team', + leadId: 'lead:my-team', + leadName: getGraphLeadMemberName(data, 'my-team'), + ownerNodeIds: new Set(['lead:my-team', 'member:my-team:agent-jack']), + }); + + expect(entries.get('member:my-team:agent-jack')).toEqual([ + expect.objectContaining({ + graphItem: expect.objectContaining({ + id: 'activity:comment:my-team:task-stable-owner:comment-stable-owner', + title: '#91 Stable owner routing', + taskId: 'task-stable-owner', + }), + }), + ]); + }); }); diff --git a/test/renderer/features/agent-graph/useGraphSimulation.test.ts b/test/renderer/features/agent-graph/useGraphSimulation.test.ts index 7abed6f4..96e084ed 100644 --- a/test/renderer/features/agent-graph/useGraphSimulation.test.ts +++ b/test/renderer/features/agent-graph/useGraphSimulation.test.ts @@ -3,12 +3,15 @@ import { describe, expect, it, vi } from 'vitest'; import { buildStableSlotLayoutSnapshot, computeOwnerFootprints, + computeProcessBandWidth, resolveNearestSlotAssignment, snapshotToWorldBounds, validateStableSlotLayout, } from '../../../../packages/agent-graph/src/layout/stableSlots'; +import { KanbanLayoutEngine } from '../../../../packages/agent-graph/src/layout/kanbanLayout'; +import { TASK_PILL } from '../../../../packages/agent-graph/src/constants/canvas-constants'; +import { ACTIVITY_LANE } from '../../../../packages/agent-graph/src/layout/activityLane'; import { STABLE_SLOT_GEOMETRY } from '../../../../packages/agent-graph/src/layout/stableSlotGeometry'; -import { ACTIVITY_ANCHOR_LAYOUT } from '../../../../packages/agent-graph/src/layout/activityLane'; import type { GraphLayoutPort, GraphNode } from '@claude-teams/agent-graph'; @@ -51,6 +54,17 @@ function createTask( }; } +function createProcess(teamName: string, processId: string, ownerId: string): GraphNode { + return { + id: `process:${teamName}:${processId}`, + kind: 'process', + label: processId, + state: 'active', + ownerId, + domainRef: { kind: 'process', teamName, processId }, + }; +} + describe('stable slot layout planner', () => { it('does not build a stable slot snapshot when the lead is missing', () => { const snapshot = buildStableSlotLayoutSnapshot({ @@ -96,7 +110,7 @@ describe('stable slot layout planner', () => { expect(validateStableSlotLayout(snapshot!)).toEqual({ valid: true }); }); - it('keeps a fixed process rail width centered inside the owner slot', () => { + it('builds a board band that contains both the activity column and kanban band', () => { const teamName = 'team-process-width'; const lead = createLead(teamName); const alice = createMember(teamName, 'agent-alice', 'alice'); @@ -116,11 +130,61 @@ describe('stable slot layout planner', () => { const frame = snapshot?.memberSlotFrames[0]; expect(frame).toBeDefined(); - expect(frame?.processBandRect.width).toBe(STABLE_SLOT_GEOMETRY.processRailWidth); - expect(frame?.processBandRect.left).toBeCloseTo( - (frame?.bounds.left ?? 0) + ((frame?.bounds.width ?? 0) - STABLE_SLOT_GEOMETRY.processRailWidth) / 2, - 6 + expect(frame?.boardBandRect.top).toBe(frame?.activityColumnRect.top); + expect(frame?.boardBandRect.top).toBe(frame?.kanbanBandRect.top); + expect(frame?.activityColumnRect.left).toBe(frame?.boardBandRect.left); + expect(frame?.kanbanBandRect.left).toBeGreaterThan(frame?.activityColumnRect.right ?? 0); + expect(frame?.processBandRect.width).toBe(computeProcessBandWidth(0)); + }); + + it('reserves a full empty activity column and minimum kanban width for idle members', () => { + const teamName = 'team-empty-slot'; + const lead = createLead(teamName); + const alice = createMember(teamName, 'agent-alice', 'alice'); + const layout: GraphLayoutPort = { + version: 'stable-slots-v1', + ownerOrder: [alice.id], + slotAssignments: { + [alice.id]: { ringIndex: 0, sectorIndex: 1 }, + }, + }; + + const [footprint] = computeOwnerFootprints([lead, alice], layout); + + expect(footprint).toBeDefined(); + expect(footprint?.activityColumnWidth).toBe(ACTIVITY_LANE.width); + expect(footprint?.activityColumnHeight).toBe( + ACTIVITY_LANE.headerHeight + + ACTIVITY_LANE.maxVisibleItems * ACTIVITY_LANE.rowHeight + + ACTIVITY_LANE.overflowHeight ); + expect(footprint?.kanbanBandWidth).toBe(TASK_PILL.width); + expect(footprint?.boardBandHeight).toBe( + Math.max(footprint?.activityColumnHeight ?? 0, footprint?.kanbanBandHeight ?? 0) + ); + }); + + it('grows process band width when an owner has multiple visible process nodes', () => { + const teamName = 'team-process-growth'; + const lead = createLead(teamName); + const alice = createMember(teamName, 'agent-alice', 'alice'); + const processes = Array.from({ length: 7 }, (_, index) => + createProcess(teamName, `proc-${index + 1}`, alice.id) + ); + const layout: GraphLayoutPort = { + version: 'stable-slots-v1', + ownerOrder: [alice.id], + slotAssignments: { + [alice.id]: { ringIndex: 0, sectorIndex: 1 }, + }, + }; + + const [footprint] = computeOwnerFootprints([lead, alice, ...processes], layout); + + expect(footprint).toBeDefined(); + expect(footprint?.processCount).toBe(7); + expect(footprint?.processBandWidth).toBe(computeProcessBandWidth(7)); + expect((footprint?.processBandWidth ?? 0) > STABLE_SLOT_GEOMETRY.processRailWidth).toBe(true); }); it('includes full topology bounds for fit, not only activity overlays', () => { @@ -252,40 +316,34 @@ describe('stable slot layout planner', () => { it('computes the next ring radius from previous ring depth, not member count', () => { const teamName = 'team-ring-depth'; const lead = createLead(teamName); - const members = Array.from({ length: 7 }, (_, index) => - createMember(teamName, `agent-${index + 1}`, `member-${index + 1}`) - ); + const first = createMember(teamName, 'agent-first', 'member-1'); + const second = createMember(teamName, 'agent-second', 'member-2'); const layout: GraphLayoutPort = { version: 'stable-slots-v1', - ownerOrder: members.map((member) => member.id), - slotAssignments: Object.fromEntries( - members.map((member, index) => [ - member.id, - { - ringIndex: index < 6 ? 0 : 1, - sectorIndex: index % 6, - }, - ]) - ), + ownerOrder: [first.id, second.id], + slotAssignments: { + [first.id]: { ringIndex: 0, sectorIndex: 1 }, + [second.id]: { ringIndex: 1, sectorIndex: 1 }, + }, }; const snapshot = buildStableSlotLayoutSnapshot({ teamName, - nodes: [lead, ...members], + nodes: [lead, first, second], layout, }); - const footprints = computeOwnerFootprints([lead, ...members], layout); + const footprints = computeOwnerFootprints([lead, first, second], layout); const firstRingFrame = snapshot?.memberSlotFrames.find( - (frame) => frame.ringIndex === 0 && frame.sectorIndex === 0 + (frame) => frame.ownerId === first.id ); const secondRingFrame = snapshot?.memberSlotFrames.find( - (frame) => frame.ringIndex === 1 && frame.sectorIndex === 0 + (frame) => frame.ownerId === second.id ); expect(snapshot).not.toBeNull(); expect(firstRingFrame).toBeDefined(); expect(secondRingFrame).toBeDefined(); - const firstFootprint = footprints[0]; + const firstFootprint = footprints.find((footprint) => footprint.ownerId === first.id); expect(firstFootprint).toBeDefined(); if (!firstFootprint) { throw new Error('expected first footprint for ring-depth test'); @@ -293,17 +351,82 @@ describe('stable slot layout planner', () => { const ringDelta = Math.hypot(secondRingFrame!.ownerX, secondRingFrame!.ownerY) - Math.hypot(firstRingFrame!.ownerX, firstRingFrame!.ownerY); - const ownerAnchorOffsetY = - STABLE_SLOT_GEOMETRY.memberSlotInnerPadding + - ACTIVITY_ANCHOR_LAYOUT.reservedHeight + - STABLE_SLOT_GEOMETRY.slotVerticalGap + - STABLE_SLOT_GEOMETRY.ownerBandHeight / 2; - const expectedRingDelta = - ownerAnchorOffsetY + - (firstFootprint.slotHeight - ownerAnchorOffsetY) + - STABLE_SLOT_GEOMETRY.ringGap; + const sectorVector = { x: 0.82, y: -0.57 }; + const ownerLocalY = + STABLE_SLOT_GEOMETRY.memberSlotInnerPadding + STABLE_SLOT_GEOMETRY.ownerBandHeight / 2; + const topOffset = -ownerLocalY; + const bottomOffset = firstFootprint.slotHeight - ownerLocalY; + const halfWidth = firstFootprint.slotWidth / 2; + const vectorLength = Math.hypot(sectorVector.x, sectorVector.y) || 1; + const unitX = sectorVector.x / vectorLength; + const unitY = sectorVector.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); + const outwardDepth = Math.max(...cornerProjections); + const inwardDepth = Math.max(...cornerProjections.map((projection) => -projection)); + const expectedRingDelta = outwardDepth + inwardDepth + STABLE_SLOT_GEOMETRY.ringGap; - expect(ringDelta).toBeCloseTo(expectedRingDelta, 6); + expect(Math.abs(ringDelta - expectedRingDelta)).toBeLessThan(2); + }); + + it('keeps owned tasks out of unassigned topology when default sector candidates near the lead are invalid', () => { + const teamName = 'team-owned-tasks'; + const lead = createLead(teamName); + const members = [ + createMember(teamName, 'agent-alice', 'alice'), + createMember(teamName, 'agent-bob', 'bob'), + createMember(teamName, 'agent-tom', 'tom'), + createMember(teamName, 'agent-jack', 'jack'), + ]; + const tasks = [ + createTask(teamName, 'task-a', members[0].id, { taskStatus: 'completed' }), + createTask(teamName, 'task-b', members[1].id, { taskStatus: 'completed' }), + createTask(teamName, 'task-c', members[2].id, { taskStatus: 'completed' }), + createTask(teamName, 'task-d', members[3].id, { taskStatus: 'completed' }), + ]; + const layout: GraphLayoutPort = { + version: 'stable-slots-v1', + ownerOrder: members.map((member) => member.id), + slotAssignments: {}, + }; + + const nodes = [lead, ...members, ...tasks]; + const snapshot = buildStableSlotLayoutSnapshot({ + teamName, + nodes, + layout, + }); + + expect(snapshot).not.toBeNull(); + expect(validateStableSlotLayout(snapshot!)).toEqual({ valid: true }); + expect(snapshot?.unassignedTaskRect).toBeNull(); + + const memberSlotFrames = snapshot!.memberSlotFrames; + for (const frame of memberSlotFrames) { + const ownerNode = nodes.find((node) => node.id === frame.ownerId); + if (!ownerNode) { + continue; + } + ownerNode.x = frame.ownerX; + ownerNode.y = frame.ownerY; + } + KanbanLayoutEngine.layout(nodes, { + memberSlotFrames, + unassignedTaskRect: snapshot!.unassignedTaskRect, + }); + + for (const task of tasks) { + const ownerFrame = memberSlotFrames.find((frame) => frame.ownerId === task.ownerId); + expect(ownerFrame).toBeDefined(); + expect(task.x).toBeGreaterThanOrEqual(ownerFrame!.kanbanBandRect.left); + expect(task.x).toBeLessThanOrEqual(ownerFrame!.kanbanBandRect.right); + expect(task.y).toBeGreaterThanOrEqual(ownerFrame!.kanbanBandRect.top); + expect(task.y).toBeLessThanOrEqual(ownerFrame!.kanbanBandRect.bottom); + } }); it('keeps the same sector and spills to the next outer ring when the saved slot is already occupied', () => { From d81a45f15b15fc16e4b728ec3c3bfab01e33db99 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 10:53:52 +0300 Subject: [PATCH 042/121] fix(agent-graph): use directional central exclusion --- .../agent-graph/src/layout/stableSlots.ts | 245 ++++++++++++++---- .../agent-graph/useGraphSimulation.test.ts | 202 ++++++++++++++- 2 files changed, 395 insertions(+), 52 deletions(-) diff --git a/packages/agent-graph/src/layout/stableSlots.ts b/packages/agent-graph/src/layout/stableSlots.ts index 448095e2..e606c3cd 100644 --- a/packages/agent-graph/src/layout/stableSlots.ts +++ b/packages/agent-graph/src/layout/stableSlots.ts @@ -60,6 +60,7 @@ export interface StableSlotLayoutSnapshot { launchAnchor: { x: number; y: number } | null; leadCentralReservedBlock: StableRect; runtimeCentralExclusion: StableRect; + centralCollisionRects: StableRect[]; memberSlotFrames: SlotFrame[]; memberSlotFrameByOwnerId: Map; unassignedTaskRect: StableRect | null; @@ -113,6 +114,7 @@ const SLOT_GEOMETRY = { const PROCESS_RAIL_NODE_GAP = 42; const PROCESS_RAIL_NODE_FOOTPRINT = 28; +const GEOMETRY_EPSILON = 0.001; const SECTOR_VECTORS = STABLE_SLOT_SECTOR_VECTORS; @@ -143,27 +145,30 @@ export function buildStableSlotLayoutSnapshot({ const ownerFootprints = computeOwnerFootprints(nodes, layout); const unassignedTaskRect = buildUnassignedTaskRect(nodes, leadCentralReservedBlock); + const centralCollisionRects = buildCentralCollisionRects({ + leadCoreRect, + leadActivityRect, + launchHudRect, + unassignedTaskRect, + }); const runtimeCentralExclusion = padRect( - unionRects( - unassignedTaskRect - ? [leadCentralReservedBlock, unassignedTaskRect] - : [leadCentralReservedBlock] - ), + unionRects(centralCollisionRects), SLOT_GEOMETRY.centralPadding ); - const memberSlotFrames = planOwnerSlots(ownerFootprints, runtimeCentralExclusion, layout); + const memberSlotFrames = planOwnerSlots( + ownerFootprints, + centralCollisionRects, + runtimeCentralExclusion, + layout + ); const memberSlotFrameByOwnerId = new Map( memberSlotFrames.map((frame) => [frame.ownerId, frame] as const) ); const fitBounds = unionRects( [ - leadCentralReservedBlock, - leadActivityRect, - launchHudRect, runtimeCentralExclusion, ...memberSlotFrames.map((frame) => frame.bounds), - ...(unassignedTaskRect ? [unassignedTaskRect] : []), ].filter(Boolean) ); @@ -180,6 +185,7 @@ export function buildStableSlotLayoutSnapshot({ }, leadCentralReservedBlock, runtimeCentralExclusion, + centralCollisionRects, memberSlotFrames, memberSlotFrameByOwnerId, unassignedTaskRect, @@ -187,6 +193,33 @@ export function buildStableSlotLayoutSnapshot({ }; } +function buildCentralCollisionRects(args: { + leadCoreRect: StableRect; + leadActivityRect: StableRect; + launchHudRect: StableRect; + unassignedTaskRect: StableRect | null; +}): StableRect[] { + const rects = [args.leadCoreRect, args.leadActivityRect, args.launchHudRect]; + 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 @@ -347,6 +380,7 @@ export function resolveNearestSlotAssignment(args: { footprintByOwnerId, currentFrame, existingFrames, + centralCollisionRects: args.snapshot.centralCollisionRects, runtimeCentralExclusion: args.snapshot.runtimeCentralExclusion, ringStates, pointerX: args.ownerX, @@ -418,6 +452,9 @@ function validateStaticSnapshotRects( ['leadCentralReservedBlock', snapshot.leadCentralReservedBlock], ['runtimeCentralExclusion', snapshot.runtimeCentralExclusion], ['fitBounds', snapshot.fitBounds], + ...snapshot.centralCollisionRects.map( + (rect, index) => [`centralCollisionRects[${index}]`, rect] as [string, StableRect] + ), ]; if (snapshot.unassignedTaskRect) { @@ -452,11 +489,19 @@ function validateLeadSnapshotRects( if (!rectContainsRect(snapshot.runtimeCentralExclusion, snapshot.leadCentralReservedBlock)) { return { valid: false, reason: 'runtimeCentralExclusion must contain leadCentralReservedBlock' }; } + const paddedCentralCollisionRects = padCentralCollisionRects( + snapshot.centralCollisionRects, + SLOT_GEOMETRY.centralPadding + ); if ( - snapshot.unassignedTaskRect && - !rectContainsRect(snapshot.runtimeCentralExclusion, snapshot.unassignedTaskRect) + paddedCentralCollisionRects.some( + (rect) => !rectContainsRect(snapshot.runtimeCentralExclusion, rect) + ) ) { - return { valid: false, reason: 'runtimeCentralExclusion must contain unassignedTaskRect' }; + return { + valid: false, + reason: 'runtimeCentralExclusion must contain all centralCollisionRects', + }; } return null; @@ -485,8 +530,11 @@ function validateMemberSlotFrame( } seenAssignments.add(assignmentKey); - if (rectsOverlap(frame.bounds, snapshot.runtimeCentralExclusion)) { - return { valid: false, reason: `slot frame for ${frame.ownerId} overlaps runtimeCentralExclusion` }; + if (rectOverlapsAnyCentralRect(frame.bounds, snapshot.centralCollisionRects)) { + return { + valid: false, + reason: `slot frame for ${frame.ownerId} overlaps centralCollisionRects`, + }; } if (!rectContainsRect(frame.bounds, frame.boardBandRect)) { return { valid: false, reason: `boardBandRect escapes slot bounds for ${frame.ownerId}` }; @@ -614,7 +662,8 @@ function buildUnassignedTaskRect( function planOwnerSlots( ownerFootprints: OwnerFootprint[], - centralExclusion: StableRect, + centralCollisionRects: readonly StableRect[], + runtimeCentralExclusion: StableRect, layout?: GraphLayoutPort ): SlotFrame[] { const placedFrames: SlotFrame[] = []; @@ -626,7 +675,8 @@ function planOwnerSlots( for (const footprint of ownerFootprints) { const resolvedFrame = resolveOwnerSlotFrame({ footprint, - centralExclusion, + centralCollisionRects, + runtimeCentralExclusion, ringStates, preferredAssignment: preferredAssignments.get(footprint.ownerId), usedSlotKeys, @@ -667,7 +717,8 @@ function buildPreferredAssignmentsMap( function resolveOwnerSlotFrame(args: { footprint: OwnerFootprint; - centralExclusion: StableRect; + centralCollisionRects: readonly StableRect[]; + runtimeCentralExclusion: StableRect; ringStates: RingLayoutStateMap; preferredAssignment?: GraphOwnerSlotAssignment; usedSlotKeys: Set; @@ -676,7 +727,8 @@ function resolveOwnerSlotFrame(args: { }): SlotFrame { const { footprint, - centralExclusion, + centralCollisionRects, + runtimeCentralExclusion, ringStates, preferredAssignment, usedSlotKeys, @@ -690,7 +742,8 @@ function resolveOwnerSlotFrame(args: { const directMatch = findFirstValidSlotFrame({ candidateAssignments: candidates, footprint, - centralExclusion, + centralCollisionRects, + runtimeCentralExclusion, ringStates, usedSlotKeys, placedFrames, @@ -706,7 +759,8 @@ function resolveOwnerSlotFrame(args: { const spilloverMatch = findFirstValidSlotFrame({ candidateAssignments: spilloverCandidates, footprint, - centralExclusion, + centralCollisionRects, + runtimeCentralExclusion, ringStates, usedSlotKeys, placedFrames, @@ -717,7 +771,8 @@ function resolveOwnerSlotFrame(args: { return buildEmergencyFallbackSlotFrame({ footprint, - centralExclusion, + centralCollisionRects, + runtimeCentralExclusion, ringStates, usedSlotKeys, placedOwnerCount: placedFrames.length, @@ -728,19 +783,29 @@ function resolveOwnerSlotFrame(args: { function buildSlotFrame( footprint: OwnerFootprint, assignment: GraphOwnerSlotAssignment, - centralExclusion: StableRect, + centralCollisionRects: readonly StableRect[], + runtimeCentralExclusion: StableRect, options: { ringStates: RingLayoutStateMap } ): SlotFrame | null { - const vector = SECTOR_VECTORS[assignment.sectorIndex % SECTOR_VECTORS.length] ?? SECTOR_VECTORS[0]; const radius = resolveRingRadiusForAssignment({ assignment, footprint, - centralExclusion, + 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]; const ownerX = vector.x * radius; const ownerY = vector.y * radius; const slotTop = @@ -844,7 +909,8 @@ function ownerFootprintsSpillBudget(placedOwnerCount: number): number { function buildEmergencyFallbackSlotFrame(args: { footprint: OwnerFootprint; - centralExclusion: StableRect; + centralCollisionRects: readonly StableRect[]; + runtimeCentralExclusion: StableRect; ringStates: RingLayoutStateMap; usedSlotKeys: Set; placedOwnerCount: number; @@ -855,9 +921,15 @@ function buildEmergencyFallbackSlotFrame(args: { sectorIndex: 0, }; args.usedSlotKeys.add(buildAssignmentKey(assignment)); - const frame = buildSlotFrame(args.footprint, assignment, args.centralExclusion, { - ringStates: args.ringStates, - }); + 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}`); } @@ -871,6 +943,7 @@ function rankNearestSlotAssignmentResult(args: { footprintByOwnerId: ReadonlyMap; currentFrame: SlotFrame; existingFrames: readonly SlotFrame[]; + centralCollisionRects: readonly StableRect[]; runtimeCentralExclusion: StableRect; ringStates: RingLayoutStateMap; pointerX: number; @@ -883,14 +956,21 @@ function rankNearestSlotAssignmentResult(args: { footprintByOwnerId, currentFrame, existingFrames, + centralCollisionRects, runtimeCentralExclusion, ringStates, pointerX, pointerY, } = args; - const frame = buildSlotFrame(footprint, assignment, runtimeCentralExclusion, { - ringStates, - }); + const frame = buildSlotFrame( + footprint, + assignment, + centralCollisionRects, + runtimeCentralExclusion, + { + ringStates, + } + ); if (!frame) { return null; } @@ -900,6 +980,7 @@ function rankNearestSlotAssignmentResult(args: { occupiedFrame, footprintByOwnerId, currentFrame, + centralCollisionRects, runtimeCentralExclusion, ringStates, }); @@ -908,8 +989,8 @@ function rankNearestSlotAssignmentResult(args: { } const otherFrames = existingFrames.filter((existing) => existing.ownerId !== occupiedFrame.ownerId); if ( - !isSlotFramePlacementValid(frame, otherFrames, runtimeCentralExclusion) || - !isSlotFramePlacementValid(displacedFrame, otherFrames, runtimeCentralExclusion) || + !isSlotFramePlacementValid(frame, otherFrames, centralCollisionRects) || + !isSlotFramePlacementValid(displacedFrame, otherFrames, centralCollisionRects) || rectsOverlapWithGap(frame.bounds, displacedFrame.bounds, SLOT_GEOMETRY.ringPadding) ) { return null; @@ -927,7 +1008,7 @@ function rankNearestSlotAssignmentResult(args: { }); } - if (!isSlotFramePlacementValid(frame, existingFrames, runtimeCentralExclusion)) { + if (!isSlotFramePlacementValid(frame, existingFrames, centralCollisionRects)) { return null; } @@ -943,6 +1024,7 @@ function buildDisplacedFrameForNearestAssignment(args: { occupiedFrame: SlotFrame; footprintByOwnerId: ReadonlyMap; currentFrame: SlotFrame; + centralCollisionRects: readonly StableRect[]; runtimeCentralExclusion: StableRect; ringStates: RingLayoutStateMap; }): SlotFrame | null { @@ -956,6 +1038,7 @@ function buildDisplacedFrameForNearestAssignment(args: { ringIndex: args.currentFrame.ringIndex, sectorIndex: args.currentFrame.sectorIndex, }, + args.centralCollisionRects, args.runtimeCentralExclusion, { ringStates: args.ringStates } ); @@ -982,7 +1065,8 @@ function buildRankedNearestSlotAssignmentResult(args: { function findFirstValidSlotFrame(args: { candidateAssignments: readonly GraphOwnerSlotAssignment[]; footprint: OwnerFootprint; - centralExclusion: StableRect; + centralCollisionRects: readonly StableRect[]; + runtimeCentralExclusion: StableRect; ringStates: RingLayoutStateMap; usedSlotKeys: Set; placedFrames: readonly SlotFrame[]; @@ -1000,7 +1084,8 @@ function findFirstValidSlotFrame(args: { function tryBuildValidSlotFrame( args: { footprint: OwnerFootprint; - centralExclusion: StableRect; + centralCollisionRects: readonly StableRect[]; + runtimeCentralExclusion: StableRect; ringStates: RingLayoutStateMap; usedSlotKeys: Set; placedFrames: readonly SlotFrame[]; @@ -1012,13 +1097,19 @@ function tryBuildValidSlotFrame( if (args.usedSlotKeys.has(slotKey) && !isSameAssignment(args.preferredAssignment, assignment)) { return null; } - const frame = buildSlotFrame(args.footprint, assignment, args.centralExclusion, { - ringStates: args.ringStates, - }); + const frame = buildSlotFrame( + args.footprint, + assignment, + args.centralCollisionRects, + args.runtimeCentralExclusion, + { + ringStates: args.ringStates, + } + ); if (!frame) { return null; } - if (!isSlotFramePlacementValid(frame, args.placedFrames, args.centralExclusion)) { + if (!isSlotFramePlacementValid(frame, args.placedFrames, args.centralCollisionRects)) { return null; } args.usedSlotKeys.add(slotKey); @@ -1152,12 +1243,18 @@ function computeSlotDirectionalDepths( function resolveRingRadiusForAssignment(args: { assignment: GraphOwnerSlotAssignment; footprint: OwnerFootprint; - centralExclusion: StableRect; + centralCollisionRects: readonly StableRect[]; + runtimeCentralExclusion: StableRect; ringStates: RingLayoutStateMap; }): number | null { const vector = SECTOR_VECTORS[args.assignment.sectorIndex % SECTOR_VECTORS.length] ?? SECTOR_VECTORS[0]; - const minRadius = computeMinimumRingRadius(vector, args.footprint, args.centralExclusion); + 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, @@ -1211,7 +1308,48 @@ function buildSectorRingStateKey(sectorIndex: number, ringIndex: number): string return `${sectorIndex}:${ringIndex}`; } -function computeMinimumRingRadius( +function resolveMinimumDirectionalRadius(args: { + assignment: GraphOwnerSlotAssignment; + footprint: OwnerFootprint; + centralCollisionRects: readonly StableRect[]; + runtimeCentralExclusion: StableRect; +}): number { + const legacyRadiusHint = computeLegacyMinimumRingRadius( + SECTOR_VECTORS[args.assignment.sectorIndex % SECTOR_VECTORS.length] ?? SECTOR_VECTORS[0], + args.footprint, + args.runtimeCentralExclusion + ); + const overlapsCentralCollision = (radius: number): boolean => { + const frame = buildSlotFrameAtRadius(args.footprint, args.assignment, radius); + 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 @@ -1253,15 +1391,20 @@ function rectsOverlap(a: StableRect, b: StableRect): boolean { function rectContainsRect(outer: StableRect, inner: StableRect): boolean { return ( - inner.left >= outer.left && - inner.right <= outer.right && - inner.top >= outer.top && - inner.bottom <= outer.bottom + 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 && x <= rect.right && y >= rect.top && y <= rect.bottom; + 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 { @@ -1278,12 +1421,12 @@ function isFiniteRect(rect: StableRect): boolean { function isSlotFramePlacementValid( frame: SlotFrame, existingFrames: readonly SlotFrame[], - runtimeCentralExclusion: StableRect + centralCollisionRects: readonly StableRect[] ): boolean { if (!isFiniteRect(frame.bounds)) { return false; } - if (rectsOverlap(frame.bounds, runtimeCentralExclusion)) { + if (rectOverlapsAnyCentralRect(frame.bounds, centralCollisionRects)) { return false; } return !existingFrames.some((existing) => diff --git a/test/renderer/features/agent-graph/useGraphSimulation.test.ts b/test/renderer/features/agent-graph/useGraphSimulation.test.ts index 96e084ed..7622f3a6 100644 --- a/test/renderer/features/agent-graph/useGraphSimulation.test.ts +++ b/test/renderer/features/agent-graph/useGraphSimulation.test.ts @@ -6,12 +6,16 @@ import { computeProcessBandWidth, resolveNearestSlotAssignment, snapshotToWorldBounds, + translateSlotFrame, validateStableSlotLayout, } from '../../../../packages/agent-graph/src/layout/stableSlots'; import { KanbanLayoutEngine } from '../../../../packages/agent-graph/src/layout/kanbanLayout'; import { TASK_PILL } from '../../../../packages/agent-graph/src/constants/canvas-constants'; import { ACTIVITY_LANE } from '../../../../packages/agent-graph/src/layout/activityLane'; -import { STABLE_SLOT_GEOMETRY } from '../../../../packages/agent-graph/src/layout/stableSlotGeometry'; +import { + STABLE_SLOT_GEOMETRY, + STABLE_SLOT_SECTOR_VECTORS, +} from '../../../../packages/agent-graph/src/layout/stableSlotGeometry'; import type { GraphLayoutPort, GraphNode } from '@claude-teams/agent-graph'; @@ -65,6 +69,18 @@ function createProcess(teamName: string, processId: string, ownerId: string): Gr }; } +function rectsOverlap( + left: { left: number; right: number; top: number; bottom: number }, + right: { left: number; right: number; top: number; bottom: number } +): boolean { + return ( + left.left < right.right && + left.right > right.left && + left.top < right.bottom && + left.bottom > right.top + ); +} + describe('stable slot layout planner', () => { it('does not build a stable slot snapshot when the lead is missing', () => { const snapshot = buildStableSlotLayoutSnapshot({ @@ -164,6 +180,48 @@ describe('stable slot layout planner', () => { ); }); + it('keeps diagonal ring-zero sectors closer than the legacy coarse central box radius', () => { + const teamName = 'team-directional-radius'; + const lead = createLead(teamName); + const alice = createMember(teamName, 'agent-alice', 'alice'); + const layout: GraphLayoutPort = { + version: 'stable-slots-v1', + ownerOrder: [alice.id], + slotAssignments: { + [alice.id]: { ringIndex: 0, sectorIndex: 1 }, + }, + }; + + const snapshot = buildStableSlotLayoutSnapshot({ + teamName, + nodes: [lead, alice], + layout, + }); + const [footprint] = computeOwnerFootprints([lead, alice], layout); + const frame = snapshot?.memberSlotFrames[0]; + const sectorVector = STABLE_SLOT_SECTOR_VECTORS[1]; + + expect(snapshot).not.toBeNull(); + expect(frame).toBeDefined(); + expect(footprint).toBeDefined(); + + const legacyHorizontalExtent = snapshot!.runtimeCentralExclusion.right; + const legacyVerticalExtent = Math.abs(snapshot!.runtimeCentralExclusion.top); + const legacyRequiredX = + (legacyHorizontalExtent + footprint!.slotWidth / 2 + STABLE_SLOT_GEOMETRY.ringPadding) / + Math.abs(sectorVector.x); + const legacyRequiredY = + (legacyVerticalExtent + footprint!.slotHeight / 2 + STABLE_SLOT_GEOMETRY.ringPadding) / + Math.abs(sectorVector.y); + const legacyMinRadius = Math.max(legacyRequiredX, legacyRequiredY, 0); + const actualRadius = Math.abs(frame!.ownerX / sectorVector.x); + + expect(actualRadius).toBeLessThan(legacyMinRadius); + expect( + snapshot!.centralCollisionRects.some((rect) => rectsOverlap(frame!.bounds, rect)) + ).toBe(false); + }); + it('grows process band width when an owner has multiple visible process nodes', () => { const teamName = 'team-process-growth'; const lead = createLead(teamName); @@ -251,6 +309,51 @@ describe('stable slot layout planner', () => { expect(validateStableSlotLayout(invalid).valid).toBe(false); }); + it('rejects member frames that overlap lead activity and launch central collision rects', () => { + const teamName = 'team-central-rects'; + const lead = createLead(teamName); + const alice = createMember(teamName, 'agent-alice', 'alice'); + const layout: GraphLayoutPort = { + version: 'stable-slots-v1', + ownerOrder: [alice.id], + slotAssignments: { + [alice.id]: { ringIndex: 0, sectorIndex: 1 }, + }, + }; + + const snapshot = buildStableSlotLayoutSnapshot({ + teamName, + nodes: [lead, alice], + layout, + }); + + expect(snapshot).not.toBeNull(); + const [frame] = snapshot!.memberSlotFrames; + const overlappingLeadActivity = translateSlotFrame( + frame, + snapshot!.leadActivityRect.left - frame.bounds.left + 1, + snapshot!.leadActivityRect.top - frame.bounds.top + 1 + ); + const overlappingLaunchHud = translateSlotFrame( + frame, + snapshot!.launchHudRect.left - frame.bounds.left + 1, + snapshot!.launchHudRect.top - frame.bounds.top + 1 + ); + + expect( + validateStableSlotLayout({ + ...snapshot!, + memberSlotFrames: [overlappingLeadActivity], + }).valid + ).toBe(false); + expect( + validateStableSlotLayout({ + ...snapshot!, + memberSlotFrames: [overlappingLaunchHud], + }).valid + ).toBe(false); + }); + it('prefers the occupied target slot when dragging near another owner anchor', () => { const teamName = 'team-b'; const lead = createLead(teamName); @@ -290,6 +393,64 @@ describe('stable slot layout planner', () => { expect(nearest?.displacedAssignment).toEqual({ ringIndex: 0, sectorIndex: 1 }); }); + it('keeps nearest-slot drag resolution on the same central collision model as the planner', () => { + const teamName = 'team-drag-central-collision'; + const lead = createLead(teamName); + const alice = createMember(teamName, 'agent-alice', 'alice'); + const bob = createMember(teamName, 'agent-bob', 'bob'); + const layout: GraphLayoutPort = { + version: 'stable-slots-v1', + ownerOrder: [alice.id, bob.id], + slotAssignments: { + [alice.id]: { ringIndex: 0, sectorIndex: 1 }, + [bob.id]: { ringIndex: 0, sectorIndex: 2 }, + }, + }; + + const snapshot = buildStableSlotLayoutSnapshot({ + teamName, + nodes: [lead, alice, bob], + layout, + }); + + expect(snapshot).not.toBeNull(); + const nearest = resolveNearestSlotAssignment({ + ownerId: alice.id, + ownerX: snapshot!.leadActivityRect.left + snapshot!.leadActivityRect.width / 2, + ownerY: snapshot!.leadActivityRect.top + snapshot!.leadActivityRect.height / 2, + nodes: [lead, alice, bob], + snapshot: snapshot!, + layout, + }); + + expect(nearest).not.toBeNull(); + const replannedSnapshot = buildStableSlotLayoutSnapshot({ + teamName, + nodes: [lead, alice, bob], + layout: { + ...layout, + slotAssignments: { + ...layout.slotAssignments, + [alice.id]: nearest!.assignment, + ...(nearest?.displacedOwnerId && nearest.displacedAssignment + ? { [nearest.displacedOwnerId]: nearest.displacedAssignment } + : {}), + }, + }, + }); + const replannedFrame = replannedSnapshot?.memberSlotFrames.find( + (frame) => frame.ownerId === alice.id + ); + + expect(replannedSnapshot).not.toBeNull(); + expect(replannedFrame).toBeDefined(); + expect( + replannedSnapshot!.centralCollisionRects.some((rect) => + rectsOverlap(replannedFrame!.bounds, rect) + ) + ).toBe(false); + }); + it('treats tasks with missing owner nodes as unassigned topology actors', () => { const teamName = 'team-orphan-task'; const lead = createLead(teamName); @@ -313,6 +474,45 @@ describe('stable slot layout planner', () => { expect(snapshot?.unassignedTaskRect).not.toBeNull(); }); + it('rejects member frames that overlap the unassigned central collision rect', () => { + const teamName = 'team-unassigned-central-rect'; + const lead = createLead(teamName); + const alice = createMember(teamName, 'agent-alice', 'alice'); + const orphanTask = createTask( + teamName, + 'task-orphan', + 'member:team-unassigned-central-rect:ghost' + ); + const layout: GraphLayoutPort = { + version: 'stable-slots-v1', + ownerOrder: [alice.id], + slotAssignments: { + [alice.id]: { ringIndex: 0, sectorIndex: 1 }, + }, + }; + + const snapshot = buildStableSlotLayoutSnapshot({ + teamName, + nodes: [lead, alice, orphanTask], + layout, + }); + + expect(snapshot?.unassignedTaskRect).not.toBeNull(); + const [frame] = snapshot!.memberSlotFrames; + const overlappingUnassigned = translateSlotFrame( + frame, + snapshot!.unassignedTaskRect!.left - frame.bounds.left + 1, + snapshot!.unassignedTaskRect!.top - frame.bounds.top + 1 + ); + + expect( + validateStableSlotLayout({ + ...snapshot!, + memberSlotFrames: [overlappingUnassigned], + }).valid + ).toBe(false); + }); + it('computes the next ring radius from previous ring depth, not member count', () => { const teamName = 'team-ring-depth'; const lead = createLead(teamName); From c303a236a5107c010e80afb285762aa039142c4e Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 11:26:30 +0300 Subject: [PATCH 043/121] feat(agent-graph): unify lead slot layout defaults --- .../src/hooks/useGraphSimulation.ts | 21 +- .../agent-graph/src/layout/kanbanLayout.ts | 27 +- .../agent-graph/src/layout/stableSlots.ts | 256 ++++++++++------ packages/agent-graph/src/ui/GraphControls.tsx | 280 +++++++++--------- packages/agent-graph/src/ui/GraphView.tsx | 3 + .../renderer/ui/GraphProvisioningHud.tsx | 126 ++------ .../renderer/ui/TeamGraphOverlay.tsx | 12 +- .../agent-graph/renderer/ui/TeamGraphTab.tsx | 15 +- src/renderer/store/slices/teamSlice.ts | 60 +++- .../agent-graph/GraphProvisioningHud.test.ts | 10 +- .../agent-graph/useGraphSimulation.test.ts | 75 +++-- test/renderer/store/teamSlice.test.ts | 48 ++- 12 files changed, 523 insertions(+), 410 deletions(-) diff --git a/packages/agent-graph/src/hooks/useGraphSimulation.ts b/packages/agent-graph/src/hooks/useGraphSimulation.ts index e1eed487..5da1294c 100644 --- a/packages/agent-graph/src/hooks/useGraphSimulation.ts +++ b/packages/agent-graph/src/hooks/useGraphSimulation.ts @@ -251,14 +251,15 @@ function applySnapshotToNodes( 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 = 0; - node.y = 0; - node.fx = 0; - node.fy = 0; + node.x = leadFrame.ownerX; + node.y = leadFrame.ownerY; + node.fx = leadFrame.ownerX; + node.fy = leadFrame.ownerY; node.vx = 0; node.vy = 0; continue; @@ -278,9 +279,10 @@ function applySnapshotToNodes( } } - positionProcessNodes(nodes, translatedFrames); + positionProcessNodes(nodes, [snapshot.leadSlotFrame, ...translatedFrames]); KanbanLayoutEngine.layout(nodes, { memberSlotFrames: translatedFrames, + leadSlotFrame: snapshot.leadSlotFrame, unassignedTaskRect: snapshot.unassignedTaskRect, }); positionCrossTeamNodes(nodes, snapshot.fitBounds); @@ -322,16 +324,15 @@ function commitSnapshotGeometry(args: { activityRectByNodeIdRef.current.clear(); extraWorldBoundsRef.current = snapshotToWorldBounds(snapshot); - if (snapshot.leadNodeId && snapshot.launchAnchor) { - launchAnchorPositionsRef.current.set(snapshot.leadNodeId, snapshot.launchAnchor); - } - for (const frame of getTranslatedMemberFrames(snapshot, dragOwnerPositionsRef.current)) { activityRectByNodeIdRef.current.set(frame.ownerId, frame.activityColumnRect); } if (snapshot.leadNodeId) { - activityRectByNodeIdRef.current.set(snapshot.leadNodeId, snapshot.leadActivityRect); + activityRectByNodeIdRef.current.set( + snapshot.leadNodeId, + snapshot.leadSlotFrame.activityColumnRect + ); } } diff --git a/packages/agent-graph/src/layout/kanbanLayout.ts b/packages/agent-graph/src/layout/kanbanLayout.ts index ce0fb704..4c1475ad 100644 --- a/packages/agent-graph/src/layout/kanbanLayout.ts +++ b/packages/agent-graph/src/layout/kanbanLayout.ts @@ -10,7 +10,6 @@ import type { GraphNode } from '../ports/types'; 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 */ @@ -49,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; } @@ -58,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 { @@ -89,6 +78,7 @@ export class KanbanLayoutEngine { nodes: GraphNode[], options?: { memberSlotFrames?: readonly SlotFrame[]; + leadSlotFrame?: SlotFrame | null; unassignedTaskRect?: StableRect | null; } ): void { @@ -96,9 +86,12 @@ export class KanbanLayoutEngine { nodeMap.clear(); for (const n of nodes) nodeMap.set(n.id, n); const leadX = nodes.find((node) => node.kind === 'lead')?.x ?? null; - const memberSlotFrameByOwnerId = new Map( + 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(); @@ -110,10 +103,10 @@ export class KanbanLayoutEngine { return false; } if (owner.kind === 'lead') { - return true; + return ownerSlotFrameByOwnerId.has(ownerId); } if (owner.kind === 'member') { - return memberSlotFrameByOwnerId.has(ownerId); + return ownerSlotFrameByOwnerId.has(ownerId); } return false; }; @@ -143,7 +136,7 @@ export class KanbanLayoutEngine { owner, ownerId, leadX, - memberSlotFrameByOwnerId.get(ownerId) ?? null + ownerSlotFrameByOwnerId.get(ownerId) ?? null ); if (zoneInfo) this.zones.push(zoneInfo); } diff --git a/packages/agent-graph/src/layout/stableSlots.ts b/packages/agent-graph/src/layout/stableSlots.ts index e606c3cd..76b9f110 100644 --- a/packages/agent-graph/src/layout/stableSlots.ts +++ b/packages/agent-graph/src/layout/stableSlots.ts @@ -1,7 +1,7 @@ import { KANBAN_ZONE, TASK_PILL } from '../constants/canvas-constants'; import type { GraphLayoutPort, GraphNode, GraphOwnerSlotAssignment } from '../ports/types'; import { ACTIVITY_LANE } from './activityLane'; -import { LAUNCH_ANCHOR_LAYOUT, type WorldBounds } from './launchAnchor'; +import type { WorldBounds } from './launchAnchor'; import { STABLE_SLOT_GEOMETRY, STABLE_SLOT_SECTOR_VECTORS, @@ -55,6 +55,7 @@ export interface StableSlotLayoutSnapshot { teamName: string; leadNodeId: string | null; leadCoreRect: StableRect; + leadSlotFrame: SlotFrame; leadActivityRect: StableRect; launchHudRect: StableRect; launchAnchor: { x: number; y: number } | null; @@ -128,27 +129,21 @@ export function buildStableSlotLayoutSnapshot({ return null; } - const leadCoreRect = createCenteredRect(0, 0, 200, 168); - const leadActivityRect = createRect( - leadCoreRect.left - SLOT_GEOMETRY.centralBlockGap - ACTIVITY_LANE.width, - -SLOT_GEOMETRY.activityColumnHeight / 2, - ACTIVITY_LANE.width, - SLOT_GEOMETRY.activityColumnHeight + const leadCoreRect = createCenteredRect(0, 0, 200, 96); + const leadFootprint = computeOwnerFootprintForOwnerId(nodes, leadNode.id); + const leadSlotFrame = buildSlotFrameAtRadius( + leadFootprint, + { ringIndex: 0, sectorIndex: 0 }, + 0 ); - const launchHudRect = createRect( - leadCoreRect.right + SLOT_GEOMETRY.centralBlockGap, - -LAUNCH_ANCHOR_LAYOUT.compactHeight / 2, - LAUNCH_ANCHOR_LAYOUT.compactWidth, - LAUNCH_ANCHOR_LAYOUT.compactHeight - ); - const leadCentralReservedBlock = unionRects([leadCoreRect, leadActivityRect, launchHudRect]); + 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({ - leadCoreRect, - leadActivityRect, - launchHudRect, + leadCentralReservedBlock, unassignedTaskRect, }); const runtimeCentralExclusion = padRect( @@ -177,12 +172,10 @@ export function buildStableSlotLayoutSnapshot({ teamName, leadNodeId: leadNode.id, leadCoreRect, + leadSlotFrame, leadActivityRect, launchHudRect, - launchAnchor: { - x: launchHudRect.left + launchHudRect.width / 2, - y: launchHudRect.top + launchHudRect.height / 2, - }, + launchAnchor: null, leadCentralReservedBlock, runtimeCentralExclusion, centralCollisionRects, @@ -194,12 +187,10 @@ export function buildStableSlotLayoutSnapshot({ } function buildCentralCollisionRects(args: { - leadCoreRect: StableRect; - leadActivityRect: StableRect; - launchHudRect: StableRect; + leadCentralReservedBlock: StableRect; unassignedTaskRect: StableRect | null; }): StableRect[] { - const rects = [args.leadCoreRect, args.leadActivityRect, args.launchHudRect]; + const rects = [args.leadCentralReservedBlock]; if (args.unassignedTaskRect) { rects.push(args.unassignedTaskRect); } @@ -253,64 +244,96 @@ export function computeOwnerFootprints( return []; } - const taskColumnCount = taskColumnsByOwnerId.get(ownerId)?.size ?? 0; - const kanbanBandWidth = - taskColumnCount <= 1 - ? TASK_PILL.width - : TASK_PILL.width + (taskColumnCount - 1) * KANBAN_ZONE.columnWidth; - const processCount = processCountByOwnerId.get(ownerId) ?? 0; - const processBandWidth = computeProcessBandWidth(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 + + 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; - 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 - ); + boardBandHeight + ); - return [ - { - ownerId, - slotWidth, - slotHeight, - widthBucket: classifyWidthBucket(slotWidth), - radialDepth, - activityColumnWidth: SLOT_GEOMETRY.activityColumnWidth, - activityColumnHeight: SLOT_GEOMETRY.activityColumnHeight, - processBandWidth, - kanbanBandWidth, - kanbanBandHeight: SLOT_GEOMETRY.kanbanBandHeight, - boardBandWidth, - boardBandHeight, - taskColumnCount, - processCount, - } satisfies OwnerFootprint, - ]; - }); + 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 { @@ -447,6 +470,11 @@ function validateStaticSnapshotRects( ): 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], @@ -477,14 +505,34 @@ function validateStaticSnapshotRects( 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 (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.launchHudRect)) { - return { valid: false, reason: 'launchHudRect 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' }; @@ -513,11 +561,13 @@ function validateMemberSlotFrame( seenOwnerIds: Set, seenAssignments: Set ): StableSlotLayoutValidationResult | null { - if (!isFiniteRect(frame.bounds)) { - return { valid: false, reason: `slot frame for ${frame.ownerId} contains non-finite bounds` }; - } - if (!Number.isFinite(frame.ownerX) || !Number.isFinite(frame.ownerY)) { - return { valid: false, reason: `slot frame for ${frame.ownerId} contains non-finite anchor` }; + 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}` }; @@ -536,41 +586,55 @@ function validateMemberSlotFrame( 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 slot bounds for ${frame.ownerId}` }; + return { valid: false, reason: `boardBandRect escapes ${label}` }; } if (!rectContainsRect(frame.bounds, frame.activityColumnRect)) { - return { valid: false, reason: `activityColumnRect escapes slot bounds for ${frame.ownerId}` }; + return { valid: false, reason: `activityColumnRect escapes ${label}` }; } if (!rectContainsRect(frame.bounds, frame.processBandRect)) { - return { valid: false, reason: `processBandRect escapes slot bounds for ${frame.ownerId}` }; + return { valid: false, reason: `processBandRect escapes ${label}` }; } if (!rectContainsRect(frame.bounds, frame.kanbanBandRect)) { - return { valid: false, reason: `kanbanBandRect escapes slot bounds for ${frame.ownerId}` }; + return { valid: false, reason: `kanbanBandRect escapes ${label}` }; } if (!rectContainsRect(frame.boardBandRect, frame.activityColumnRect)) { return { valid: false, - reason: `activityColumnRect escapes boardBandRect for ${frame.ownerId}`, + reason: `activityColumnRect escapes boardBandRect in ${label}`, }; } if (!rectContainsRect(frame.boardBandRect, frame.kanbanBandRect)) { return { valid: false, - reason: `kanbanBandRect escapes boardBandRect for ${frame.ownerId}`, + reason: `kanbanBandRect escapes boardBandRect in ${label}`, }; } if (rectsOverlap(frame.activityColumnRect, frame.kanbanBandRect)) { return { valid: false, - reason: `activityColumnRect overlaps kanbanBandRect for ${frame.ownerId}`, + reason: `activityColumnRect overlaps kanbanBandRect in ${label}`, }; } if (!pointInRect(frame.ownerX, frame.ownerY, frame.bounds)) { - return { valid: false, reason: `owner anchor escapes slot bounds for ${frame.ownerId}` }; + return { valid: false, reason: `owner anchor escapes ${label}` }; } - if (!rectContainsRect(snapshot.fitBounds, frame.bounds)) { - return { valid: false, reason: `slot frame for ${frame.ownerId} escapes fitBounds` }; + if (!rectContainsRect(fitBounds, frame.bounds)) { + return { valid: false, reason: `${label} escapes fitBounds` }; } return null; diff --git a/packages/agent-graph/src/ui/GraphControls.tsx b/packages/agent-graph/src/ui/GraphControls.tsx index 4d0318f4..e933c9c1 100644 --- a/packages/agent-graph/src/ui/GraphControls.tsx +++ b/packages/agent-graph/src/ui/GraphControls.tsx @@ -48,6 +48,7 @@ export interface GraphControlsProps { teamName: string; teamColor?: string; isAlive?: boolean; + topToolbarContent?: React.ReactNode; } const TOPBAR_BUTTON_SIZE = 25; @@ -67,6 +68,7 @@ export function GraphControls({ onToggleSidebar, isSidebarVisible = true, teamColor, + topToolbarContent, }: GraphControlsProps): React.JSX.Element { const [isSettingsOpen, setIsSettingsOpen] = useState(false); const settingsRef = useRef(null); @@ -105,160 +107,170 @@ export function GraphControls({ return ( <> -
- {onToggleSidebar ? ( -
- - ) : ( - - ) - } - toolbar - title={isSidebarVisible ? 'Hide sidebar' : 'Show sidebar'} - /> -
- ) : null} - {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 052c1465..7daf3e57 100644 --- a/packages/agent-graph/src/ui/GraphView.tsx +++ b/packages/agent-graph/src/ui/GraphView.tsx @@ -47,6 +47,7 @@ export interface GraphViewProps { onCreateTask?: () => void; onToggleSidebar?: () => void; isSidebarVisible?: boolean; + renderTopToolbarContent?: () => React.ReactNode; onOwnerSlotDrop?: (payload: { nodeId: string; assignment: GraphOwnerSlotAssignment; @@ -92,6 +93,7 @@ export function GraphView({ onCreateTask, onToggleSidebar, isSidebarVisible = true, + renderTopToolbarContent, onOwnerSlotDrop, renderOverlay, renderEdgeOverlay, @@ -748,6 +750,7 @@ export function GraphView({ teamName={data.teamName} teamColor={data.teamColor} isAlive={data.isAlive} + topToolbarContent={renderTopToolbarContent?.()} /> {renderHud ? ( diff --git a/src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx b/src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx index 4f2a90c7..26f6f3ff 100644 --- a/src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx +++ b/src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx @@ -1,4 +1,4 @@ -import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { DISPLAY_STEPS } from '@renderer/components/team/provisioningSteps'; import { StepProgressBar } from '@renderer/components/team/StepProgressBar'; @@ -13,7 +13,7 @@ import { DialogTitle, } from '@renderer/components/ui/dialog'; import { cn } from '@renderer/lib/utils'; -import { AlertTriangle, CheckCircle2, ExternalLink, Loader2, X } from 'lucide-react'; +import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react'; import type { TeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation'; import type { CSSProperties } from 'react'; @@ -51,28 +51,28 @@ function getToneClasses(tone: TeamProvisioningPresentation['compactTone']): { return { border: 'border-red-400/35 bg-[rgba(26,10,16,0.92)]', badge: 'border-red-500/30 text-red-300', - icon: , + icon: , iconClassName: 'text-red-400', }; case 'warning': return { border: 'border-amber-400/35 bg-[rgba(31,18,8,0.92)]', badge: 'border-amber-500/30 text-amber-200', - icon: , + icon: , iconClassName: 'text-amber-400', }; case 'success': return { border: 'border-emerald-400/35 bg-[rgba(8,24,18,0.92)]', badge: 'border-emerald-500/30 text-emerald-200', - icon: , + icon: , iconClassName: 'text-emerald-400', }; default: return { border: 'border-cyan-400/25 bg-[rgba(8,14,26,0.92)]', badge: 'border-cyan-500/20 text-cyan-200', - icon: , + icon: , iconClassName: 'text-cyan-300', }; } @@ -80,26 +80,17 @@ function getToneClasses(tone: TeamProvisioningPresentation['compactTone']): { export interface GraphProvisioningHudProps { teamName: string; - leadNodeId: string | null; - getLaunchAnchorScreenPlacement: ( - leadNodeId: string - ) => { x: number; y: number; scale: number; visible: boolean } | null; enabled?: boolean; } export const GraphProvisioningHud = ({ teamName, - leadNodeId, - getLaunchAnchorScreenPlacement, enabled = true, }: GraphProvisioningHudProps): React.JSX.Element | null => { const { presentation, runInstanceKey } = useTeamProvisioningPresentation(teamName); - const shellRef = useRef(null); const lastActiveStepRef = useRef(-1); const [detailsOpen, setDetailsOpen] = useState(false); - const [dismissed, setDismissed] = useState(false); - const shouldRender = - enabled && shouldRenderLaunchHud(presentation) && !dismissed && Boolean(leadNodeId); + const shouldRender = enabled && shouldRenderLaunchHud(presentation); const tone = presentation ? getToneClasses(presentation.compactTone) : null; const errorStepIndex = presentation?.isFailed ? lastActiveStepRef.current >= 0 @@ -109,63 +100,21 @@ export const GraphProvisioningHud = ({ useEffect(() => { setDetailsOpen(false); - setDismissed(false); lastActiveStepRef.current = -1; }, [runInstanceKey, teamName]); - useEffect(() => { - if (!shouldRender || !leadNodeId) { - setDetailsOpen(false); - } - }, [leadNodeId, shouldRender]); - useEffect(() => { if (presentation && !presentation.isFailed && presentation.currentStepIndex >= 0) { lastActiveStepRef.current = presentation.currentStepIndex; } }, [presentation]); - useLayoutEffect(() => { - if (!shouldRender || !leadNodeId) { - return; - } - let frameId = 0; - const updatePosition = (): void => { - const shell = shellRef.current; - if (!shell) { - frameId = window.requestAnimationFrame(updatePosition); - return; - } - const placement = getLaunchAnchorScreenPlacement(leadNodeId); - if (!placement) { - shell.style.opacity = '0'; - frameId = window.requestAnimationFrame(updatePosition); - return; - } - - if (!placement.visible) { - shell.style.opacity = '0'; - frameId = window.requestAnimationFrame(updatePosition); - return; - } - - shell.style.opacity = '1'; - shell.style.transform = `translate(${Math.round(placement.x)}px, ${Math.round(placement.y)}px) scale(${placement.scale.toFixed(3)})`; - frameId = window.requestAnimationFrame(updatePosition); - }; - - updatePosition(); - return () => { - window.cancelAnimationFrame(frameId); - }; - }, [getLaunchAnchorScreenPlacement, leadNodeId, shouldRender]); - const compactLabel = useMemo(() => { if (!presentation?.compactDetail) { return null; } - return presentation.compactDetail.length > 88 - ? `${presentation.compactDetail.slice(0, 88)}...` + return presentation.compactDetail.length > 54 + ? `${presentation.compactDetail.slice(0, 54)}...` : presentation.compactDetail; }, [presentation?.compactDetail]); @@ -174,21 +123,21 @@ export const GraphProvisioningHud = ({ } return ( -
-
+ - -
- -
+
+ @@ -254,6 +184,6 @@ export const GraphProvisioningHud = ({
-
+ ); }; diff --git a/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx b/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx index 74df726b..e5c7c6da 100644 --- a/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx +++ b/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx @@ -58,10 +58,6 @@ export const TeamGraphOverlay = ({ const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName); const effectiveSidebarVisible = sidebarVisible ?? persistedSidebarVisible; const handleToggleSidebar = onToggleSidebar ?? toggleSidebarVisible; - const leadNodeId = useMemo( - () => graphData.nodes.find((node) => node.kind === 'lead')?.id ?? null, - [graphData.nodes] - ); // Task action dispatchers (same pattern as TeamGraphTab) const dispatchTaskAction = useCallback( @@ -126,6 +122,7 @@ export const TeamGraphOverlay = ({ onCreateTask={openCreateTask} onToggleSidebar={handleToggleSidebar} isSidebarVisible={effectiveSidebarVisible} + renderTopToolbarContent={() => } onOwnerSlotDrop={commitOwnerSlotDrop} className="team-graph-view min-w-0 flex-1" renderHud={(hudProps) => { @@ -143,7 +140,7 @@ export const TeamGraphOverlay = ({ worldToScreen?: (x: number, y: number) => { x: number; y: number }; getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null; }; - const { getLaunchAnchorScreenPlacement, getViewportSize, focusNodeIds } = extraHudProps; + const { getViewportSize, focusNodeIds } = extraHudProps; return ( <> @@ -159,11 +156,6 @@ export const TeamGraphOverlay = ({ onOpenTaskDetail={onOpenTaskDetail} onOpenMemberProfile={onOpenMemberProfile} /> - ); }} diff --git a/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx b/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx index 31976bf6..65f6bda0 100644 --- a/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx +++ b/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx @@ -46,10 +46,6 @@ export const TeamGraphTab = ({ }: TeamGraphTabProps): React.JSX.Element => { const graphData = useTeamGraphAdapter(teamName); const { openTeamPage, commitOwnerSlotDrop } = useTeamGraphSurfaceActions(teamName); - const leadNodeId = useMemo( - () => graphData.nodes.find((node) => node.kind === 'lead')?.id ?? null, - [graphData.nodes] - ); const [fullscreen, setFullscreen] = useState(false); const { sidebarVisible, toggleSidebarVisible } = useGraphSidebarVisibility(); const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName); @@ -149,6 +145,9 @@ export const TeamGraphTab = ({ onCreateTask={openCreateTask} onToggleSidebar={toggleSidebarVisible} isSidebarVisible={sidebarVisible} + renderTopToolbarContent={() => ( + + )} onOwnerSlotDrop={commitOwnerSlotDrop} renderHud={(hudProps) => { const extraHudProps = hudProps as typeof hudProps & { @@ -165,7 +164,7 @@ export const TeamGraphTab = ({ worldToScreen?: (x: number, y: number) => { x: number; y: number }; getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null; }; - const { getLaunchAnchorScreenPlacement, getViewportSize, focusNodeIds } = extraHudProps; + const { getViewportSize, focusNodeIds } = extraHudProps; return ( <> @@ -182,12 +181,6 @@ export const TeamGraphTab = ({ onOpenTaskDetail={dispatchOpenTask} onOpenMemberProfile={dispatchOpenProfile} /> - ); }} diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index e1d10700..8b9ca8e5 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -86,6 +86,27 @@ interface RefreshTeamDataOptions { } type TeamGraphSlotAssignments = Record; +type TeamGraphMemberSeedInput = Pick; + +const SMALL_TEAM_CARDINAL_SLOT_PRESETS: ReadonlyArray> = [ + [], + [{ ringIndex: 0, sectorIndex: 0 }], + [ + { ringIndex: 0, sectorIndex: 5 }, + { ringIndex: 0, sectorIndex: 1 }, + ], + [ + { ringIndex: 0, sectorIndex: 5 }, + { ringIndex: 0, sectorIndex: 1 }, + { ringIndex: 0, sectorIndex: 3 }, + ], + [ + { ringIndex: 0, sectorIndex: 5 }, + { ringIndex: 0, sectorIndex: 1 }, + { ringIndex: 0, sectorIndex: 4 }, + { ringIndex: 0, sectorIndex: 2 }, + ], +]; export function isTeamDataRefreshPending(teamName: string): boolean { return ( @@ -943,7 +964,7 @@ export function selectTeamDataForName( function migrateStableSlotAssignmentsForMembers( assignments: TeamGraphSlotAssignments | undefined, - members: readonly Pick[] + members: readonly TeamGraphMemberSeedInput[] ): { assignments: TeamGraphSlotAssignments; changed: boolean } { const nextAssignments: TeamGraphSlotAssignments = { ...(assignments ?? {}) }; let changed = false; @@ -970,6 +991,36 @@ function migrateStableSlotAssignmentsForMembers( return { assignments: nextAssignments, changed }; } +function seedStableSlotAssignmentsForMembers( + assignments: TeamGraphSlotAssignments, + members: readonly TeamGraphMemberSeedInput[] +): { assignments: TeamGraphSlotAssignments; changed: boolean } { + const visibleMembers = members.filter((member) => !member.removedAt); + if (visibleMembers.length === 0 || visibleMembers.length > 4) { + return { assignments, changed: false }; + } + + const visibleStableOwnerIds = visibleMembers.map((member) => getStableTeamOwnerId(member)); + const hasAnyVisibleAssignments = visibleStableOwnerIds.some( + (stableOwnerId) => assignments[stableOwnerId] != null + ); + if (hasAnyVisibleAssignments) { + return { assignments, changed: false }; + } + + const preset = SMALL_TEAM_CARDINAL_SLOT_PRESETS[visibleMembers.length]; + if (!preset || preset.length !== visibleMembers.length) { + return { assignments, changed: false }; + } + + const nextAssignments: TeamGraphSlotAssignments = { ...assignments }; + visibleMembers.forEach((member, index) => { + nextAssignments[getStableTeamOwnerId(member)] = preset[index]!; + }); + + return { assignments: nextAssignments, changed: true }; +} + function isVisibleInActiveTeamSurface( state: Pick, teamName: string | null | undefined @@ -1077,7 +1128,7 @@ export interface TeamSlice { clearKanbanFilter: () => void; ensureTeamGraphSlotAssignments: ( teamName: string, - members: readonly Pick[] + members: readonly TeamGraphMemberSeedInput[] ) => void; setTeamGraphOwnerSlotAssignment: ( teamName: string, @@ -1783,10 +1834,11 @@ export const createTeamSlice: StateCreator = (set, const currentAssignments = nextSlotAssignmentsByTeam[teamName]; const migrated = migrateStableSlotAssignmentsForMembers(currentAssignments, members); - if (migrated.changed) { + const seeded = seedStableSlotAssignmentsForMembers(migrated.assignments, members); + if (migrated.changed || seeded.changed) { nextSlotAssignmentsByTeam = { ...nextSlotAssignmentsByTeam, - [teamName]: migrated.assignments, + [teamName]: seeded.assignments, }; changed = true; } diff --git a/test/renderer/features/agent-graph/GraphProvisioningHud.test.ts b/test/renderer/features/agent-graph/GraphProvisioningHud.test.ts index 49a29299..383f7951 100644 --- a/test/renderer/features/agent-graph/GraphProvisioningHud.test.ts +++ b/test/renderer/features/agent-graph/GraphProvisioningHud.test.ts @@ -46,8 +46,6 @@ vi.mock('@renderer/components/team/TeamProvisioningPanel', () => ({ ), })); -const placement = { x: 120, y: 80, scale: 1, visible: true }; - describe('GraphProvisioningHud', () => { afterEach(() => { document.body.innerHTML = ''; @@ -77,8 +75,6 @@ describe('GraphProvisioningHud', () => { root.render( React.createElement(GraphProvisioningHud, { teamName: 'northstar-core', - leadNodeId: 'lead:northstar-core', - getLaunchAnchorScreenPlacement: () => placement, }) ); await Promise.resolve(); @@ -117,14 +113,12 @@ describe('GraphProvisioningHud', () => { root.render( React.createElement(GraphProvisioningHud, { teamName: 'northstar-core', - leadNodeId: 'lead:northstar-core', - getLaunchAnchorScreenPlacement: () => placement, }) ); await Promise.resolve(); }); - const openButton = host.querySelector('button[aria-label="Open full launch details"]'); + const openButton = host.querySelector('button[aria-label="Open launch details"]'); expect(openButton).not.toBeNull(); await act(async () => { @@ -165,8 +159,6 @@ describe('GraphProvisioningHud', () => { root.render( React.createElement(GraphProvisioningHud, { teamName: 'northstar-core', - leadNodeId: 'lead:northstar-core', - getLaunchAnchorScreenPlacement: () => placement, enabled: false, }) ); diff --git a/test/renderer/features/agent-graph/useGraphSimulation.test.ts b/test/renderer/features/agent-graph/useGraphSimulation.test.ts index 7622f3a6..2db1509f 100644 --- a/test/renderer/features/agent-graph/useGraphSimulation.test.ts +++ b/test/renderer/features/agent-graph/useGraphSimulation.test.ts @@ -98,7 +98,7 @@ describe('stable slot layout planner', () => { expect(snapshot).toBeNull(); }); - it('builds launch and activity geometry around the central lead block', () => { + it('builds lead activity inside the same central owner slot topology', () => { const teamName = 'team-a'; const lead = createLead(teamName); const alice = createMember(teamName, 'agent-alice', 'alice'); @@ -118,11 +118,13 @@ describe('stable slot layout planner', () => { expect(snapshot).not.toBeNull(); expect(snapshot?.leadNodeId).toBe(lead.id); - expect(snapshot?.launchAnchor).not.toBeNull(); + expect(snapshot?.launchAnchor).toBeNull(); + expect(snapshot?.leadSlotFrame.ownerId).toBe(lead.id); expect(snapshot?.memberSlotFrames).toHaveLength(1); expect(snapshot?.memberSlotFrames[0]?.ownerId).toBe(alice.id); - expect(snapshot?.leadActivityRect.left).toBeLessThan(snapshot?.leadCoreRect.left ?? 0); - expect(snapshot?.fitBounds.right).toBeGreaterThan(snapshot?.leadCoreRect.right ?? 0); + expect(snapshot?.leadActivityRect.top).toBeGreaterThan(snapshot?.leadCoreRect.bottom ?? 0); + expect(snapshot?.leadSlotFrame.activityColumnRect.left).toBe(snapshot?.leadActivityRect.left); + expect(snapshot?.leadSlotFrame.activityColumnRect.top).toBe(snapshot?.leadActivityRect.top); expect(validateStableSlotLayout(snapshot!)).toEqual({ valid: true }); }); @@ -309,7 +311,7 @@ describe('stable slot layout planner', () => { expect(validateStableSlotLayout(invalid).valid).toBe(false); }); - it('rejects member frames that overlap lead activity and launch central collision rects', () => { + it('rejects member frames that overlap the lead central reserved block', () => { const teamName = 'team-central-rects'; const lead = createLead(teamName); const alice = createMember(teamName, 'agent-alice', 'alice'); @@ -329,27 +331,16 @@ describe('stable slot layout planner', () => { expect(snapshot).not.toBeNull(); const [frame] = snapshot!.memberSlotFrames; - const overlappingLeadActivity = translateSlotFrame( + const overlappingLeadBlock = translateSlotFrame( frame, - snapshot!.leadActivityRect.left - frame.bounds.left + 1, - snapshot!.leadActivityRect.top - frame.bounds.top + 1 - ); - const overlappingLaunchHud = translateSlotFrame( - frame, - snapshot!.launchHudRect.left - frame.bounds.left + 1, - snapshot!.launchHudRect.top - frame.bounds.top + 1 + snapshot!.leadCentralReservedBlock.left - frame.bounds.left + 1, + snapshot!.leadCentralReservedBlock.top - frame.bounds.top + 1 ); expect( validateStableSlotLayout({ ...snapshot!, - memberSlotFrames: [overlappingLeadActivity], - }).valid - ).toBe(false); - expect( - validateStableSlotLayout({ - ...snapshot!, - memberSlotFrames: [overlappingLaunchHud], + memberSlotFrames: [overlappingLeadBlock], }).valid ).toBe(false); }); @@ -629,6 +620,50 @@ describe('stable slot layout planner', () => { } }); + it('positions lead-owned tasks inside the lead kanban band instead of unassigned', () => { + const teamName = 'team-lead-owned-tasks'; + const lead = createLead(teamName); + const alice = createMember(teamName, 'agent-alice', 'alice'); + const leadTasks = [ + createTask(teamName, 'lead-a', lead.id, { taskStatus: 'completed' }), + createTask(teamName, 'lead-b', lead.id, { taskStatus: 'in_progress' }), + ]; + const layout: GraphLayoutPort = { + version: 'stable-slots-v1', + ownerOrder: [alice.id], + slotAssignments: { + [alice.id]: { ringIndex: 0, sectorIndex: 1 }, + }, + }; + + const nodes = [lead, alice, ...leadTasks]; + const snapshot = buildStableSlotLayoutSnapshot({ + teamName, + nodes, + layout, + }); + + expect(snapshot).not.toBeNull(); + expect(snapshot?.unassignedTaskRect).toBeNull(); + lead.x = snapshot!.leadSlotFrame.ownerX; + lead.y = snapshot!.leadSlotFrame.ownerY; + alice.x = snapshot!.memberSlotFrames[0]?.ownerX; + alice.y = snapshot!.memberSlotFrames[0]?.ownerY; + + KanbanLayoutEngine.layout(nodes, { + leadSlotFrame: snapshot!.leadSlotFrame, + memberSlotFrames: snapshot!.memberSlotFrames, + unassignedTaskRect: snapshot!.unassignedTaskRect, + }); + + for (const task of leadTasks) { + expect(task.x).toBeGreaterThanOrEqual(snapshot!.leadSlotFrame.kanbanBandRect.left); + expect(task.x).toBeLessThanOrEqual(snapshot!.leadSlotFrame.kanbanBandRect.right); + expect(task.y).toBeGreaterThanOrEqual(snapshot!.leadSlotFrame.kanbanBandRect.top); + expect(task.y).toBeLessThanOrEqual(snapshot!.leadSlotFrame.kanbanBandRect.bottom); + } + }); + it('keeps the same sector and spills to the next outer ring when the saved slot is already occupied', () => { const teamName = 'team-wide-spill'; const lead = createLead(teamName); diff --git a/test/renderer/store/teamSlice.test.ts b/test/renderer/store/teamSlice.test.ts index d91c919f..a7e9e286 100644 --- a/test/renderer/store/teamSlice.test.ts +++ b/test/renderer/store/teamSlice.test.ts @@ -259,6 +259,48 @@ describe('teamSlice actions', () => { }); }); + it('seeds first-open cardinal slot defaults for small visible teams with no saved placements', () => { + const store = createSliceStore(); + + store.getState().ensureTeamGraphSlotAssignments('my-team', [ + { name: 'alice', agentId: 'agent-alice' }, + { name: 'bob', agentId: 'agent-bob' }, + { name: 'tom', agentId: 'agent-tom' }, + { name: 'jack', agentId: 'agent-jack' }, + ]); + + expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({ + 'agent-alice': { ringIndex: 0, sectorIndex: 5 }, + 'agent-bob': { ringIndex: 0, sectorIndex: 1 }, + 'agent-tom': { ringIndex: 0, sectorIndex: 4 }, + 'agent-jack': { ringIndex: 0, sectorIndex: 2 }, + }); + }); + + it('seeds visible members even when only hidden owners have saved placements', () => { + const store = createSliceStore(); + store.setState({ + slotLayoutVersion: 'stable-slots-v1', + slotAssignmentsByTeam: { + 'my-team': { + 'agent-hidden': { ringIndex: 2, sectorIndex: 4 }, + }, + }, + }); + + store.getState().ensureTeamGraphSlotAssignments('my-team', [ + { name: 'hidden', agentId: 'agent-hidden', removedAt: '2026-04-16T08:00:00.000Z' }, + { name: 'alice', agentId: 'agent-alice' }, + { name: 'bob', agentId: 'agent-bob' }, + ]); + + expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({ + 'agent-hidden': { ringIndex: 2, sectorIndex: 4 }, + 'agent-alice': { ringIndex: 0, sectorIndex: 5 }, + 'agent-bob': { ringIndex: 0, sectorIndex: 1 }, + }); + }); + it('resets stale slot assignments when slot layout version mismatches', () => { const store = createSliceStore(); store.setState({ @@ -278,7 +320,11 @@ describe('teamSlice actions', () => { ]); expect(store.getState().slotLayoutVersion).toBe('stable-slots-v1'); - expect(store.getState().slotAssignmentsByTeam).toEqual({}); + expect(store.getState().slotAssignmentsByTeam).toEqual({ + 'my-team': { + 'agent-alice': { ringIndex: 0, sectorIndex: 0 }, + }, + }); }); it('keeps hidden-member slot assignments so the same stable owner can reuse them later', () => { From 46304421498a4e296d9cd7ce8e409cb93f610790 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 13:05:16 +0300 Subject: [PATCH 044/121] fix(agent-graph): stabilize slot layout interactions --- .../src/hooks/useGraphSimulation.ts | 12 + .../agent-graph/src/layout/stableSlots.ts | 274 +++++++++++++++++- packages/agent-graph/src/ui/GraphCanvas.tsx | 57 ++++ packages/agent-graph/src/ui/GraphView.tsx | 194 ++++++++++--- .../renderer/hooks/useTeamGraphAdapter.ts | 21 +- .../renderer/ui/TeamGraphOverlay.tsx | 15 +- .../agent-graph/renderer/ui/TeamGraphTab.tsx | 15 +- src/renderer/store/slices/teamSlice.ts | 126 +++++++- .../agent-graph/useGraphSimulation.test.ts | 133 +++++++++ test/renderer/store/teamSlice.test.ts | 112 ++++++- 10 files changed, 890 insertions(+), 69 deletions(-) diff --git a/packages/agent-graph/src/hooks/useGraphSimulation.ts b/packages/agent-graph/src/hooks/useGraphSimulation.ts index 5da1294c..cd4d62ad 100644 --- a/packages/agent-graph/src/hooks/useGraphSimulation.ts +++ b/packages/agent-graph/src/hooks/useGraphSimulation.ts @@ -38,6 +38,7 @@ export interface UseGraphSimulationResult { tick: (dt: number) => void; setNodePosition: (nodeId: string, x: number, y: number) => void; clearNodePosition: (nodeId: string) => void; + clearTransientOwnerPositions: () => void; resolveNearestOwnerSlot: ( nodeId: string, x: number, @@ -46,6 +47,8 @@ export interface UseGraphSimulationResult { assignment: GraphOwnerSlotAssignment; displacedOwnerId?: string; displacedAssignment?: GraphOwnerSlotAssignment; + previewOwnerX: number; + previewOwnerY: number; } | null; getLaunchAnchorWorldPosition: (leadNodeId: string) => { x: number; y: number } | null; getActivityWorldRect: (nodeId: string) => StableRect | null; @@ -199,6 +202,14 @@ export function useGraphSimulation(): UseGraphSimulationResult { [applyCurrentLayout] ); + const clearTransientOwnerPositions = useCallback(() => { + if (dragOwnerPositionsRef.current.size === 0) { + return; + } + dragOwnerPositionsRef.current.clear(); + applyCurrentLayout(); + }, [applyCurrentLayout]); + const resolveNearestOwnerSlot = useCallback( (nodeId: string, x: number, y: number) => { const snapshot = layoutSnapshotRef.current; @@ -234,6 +245,7 @@ export function useGraphSimulation(): UseGraphSimulationResult { tick, setNodePosition, clearNodePosition, + clearTransientOwnerPositions, resolveNearestOwnerSlot, getLaunchAnchorWorldPosition: (leadNodeId: string) => launchAnchorPositionsRef.current.get(leadNodeId) ?? null, diff --git a/packages/agent-graph/src/layout/stableSlots.ts b/packages/agent-graph/src/layout/stableSlots.ts index 76b9f110..070323ea 100644 --- a/packages/agent-graph/src/layout/stableSlots.ts +++ b/packages/agent-graph/src/layout/stableSlots.ts @@ -77,6 +77,8 @@ interface NearestSlotAssignmentResult { assignment: GraphOwnerSlotAssignment; displacedOwnerId?: string; displacedAssignment?: GraphOwnerSlotAssignment; + previewOwnerX: number; + previewOwnerY: number; } interface RankedNearestSlotAssignmentResult extends NearestSlotAssignmentResult { @@ -116,8 +118,41 @@ const SLOT_GEOMETRY = { 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, @@ -378,6 +413,17 @@ export function resolveNearestSlotAssignment(args: { 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( @@ -423,10 +469,93 @@ export function resolveNearestSlotAssignment(args: { 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 { @@ -730,6 +859,18 @@ function planOwnerSlots( 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(); @@ -754,6 +895,105 @@ function planOwnerSlots( 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 { @@ -870,6 +1110,15 @@ function buildSlotFrameAtRadius( 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 = @@ -1122,6 +1371,8 @@ function buildRankedNearestSlotAssignmentResult(args: { assignment: args.assignment, displacedOwnerId: args.displacedOwnerId, displacedAssignment: args.displacedAssignment, + previewOwnerX: args.frame.ownerX, + previewOwnerY: args.frame.ownerY, distanceSquared: dx * dx + dy * dy, }; } @@ -1377,14 +1628,33 @@ function resolveMinimumDirectionalRadius(args: { 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( - SECTOR_VECTORS[args.assignment.sectorIndex % SECTOR_VECTORS.length] ?? SECTOR_VECTORS[0], + args.vector, args.footprint, args.runtimeCentralExclusion ); const overlapsCentralCollision = (radius: number): boolean => { - const frame = buildSlotFrameAtRadius(args.footprint, args.assignment, radius); + const frame = buildSlotFrameAtRadiusWithVector( + args.footprint, + { ringIndex: 0, sectorIndex: 0 }, + radius, + args.vector + ); return rectOverlapsAnyCentralRect(frame.bounds, args.centralCollisionRects); }; 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/GraphView.tsx b/packages/agent-graph/src/ui/GraphView.tsx index 7daf3e57..a842bbf9 100644 --- a/packages/agent-graph/src/ui/GraphView.tsx +++ b/packages/agent-graph/src/ui/GraphView.tsx @@ -43,6 +43,7 @@ export interface GraphViewProps { onRequestClose?: () => void; onRequestPinAsTab?: () => void; onRequestFullscreen?: () => void; + isSurfaceActive?: boolean; onOpenTeamPage?: () => void; onCreateTask?: () => void; onToggleSidebar?: () => void; @@ -89,6 +90,7 @@ export function GraphView({ onRequestClose, onRequestPinAsTab, onRequestFullscreen, + isSurfaceActive = true, onOpenTeamPage, onCreateTask, onToggleSidebar, @@ -127,6 +129,12 @@ 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); // ─── Hooks ────────────────────────────────────────────────────────────── const simulation = useGraphSimulation(); @@ -284,6 +292,7 @@ export function GraphView({ hoveredEdgeId: hoveredEdgeIdRef.current, focusNodeIds: focusState.focusNodeIds, focusEdgeIds: focusState.focusEdgeIds, + dragPreview: dragPreviewRef.current, }); rafRef.current = requestAnimationFrame(animate); @@ -370,6 +379,17 @@ export function GraphView({ allowAutoFitRef.current = false; }, []); + useLayoutEffect(() => { + if (!isSurfaceActive) { + return; + } + interaction.handleMouseUp(); + simulation.clearTransientOwnerPositions(); + dragPreviewRef.current = null; + isPanningRef.current = false; + edgeMouseDownRef.current = null; + }, [interaction, isSurfaceActive, simulation]); + const handleWheel = useCallback( (e: WheelEvent) => { markUserInteracted(); @@ -385,6 +405,7 @@ export function GraphView({ const handleMouseDown = useCallback( (e: React.MouseEvent) => { if (e.button !== 0) return; // only left click + dragPreviewRef.current = null; const canvas = canvasHandle.current?.getCanvas(); if (!canvas) return; @@ -435,59 +456,64 @@ export function GraphView({ ] ); - const handleMouseMove = useCallback( - (e: React.MouseEvent) => { - // Dragging with left button held - if (e.buttons & 1) { - if (isPanningRef.current) { - camera.handlePanMove(e.clientX, e.clientY); - return; - } - const canvas = canvasHandle.current?.getCanvas(); - if (!canvas) return; - const rect = canvas.getBoundingClientRect(); - const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top); - interaction.handleMouseMove(world.x, world.y, getVisibleNodes(simulation.stateRef.current.nodes)); - return; + const processActivePointerMove = useCallback( + (clientX: number, clientY: number, buttons: number) => { + if ((buttons & 1) === 0) { + dragPreviewRef.current = null; + return false; + } + + if (isPanningRef.current) { + 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 = 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; + 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) { + 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, getVisibleEdges, getVisibleNodes, 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 + dragPreviewRef.current = null; + setSelectedNodeId(null); setSelectedEdgeId(null); edgeMouseDownRef.current = null; + interaction.handleMouseUp(); return; } @@ -510,11 +536,13 @@ export function GraphView({ requestAnimationFrame(() => { simulation.clearNodePosition(draggedNodeId); }); + dragPreviewRef.current = null; edgeMouseDownRef.current = null; return; } } simulation.clearNodePosition(draggedNodeId); + dragPreviewRef.current = null; edgeMouseDownRef.current = null; return; } @@ -529,7 +557,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) { @@ -548,17 +576,103 @@ 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; }, - [camera, data.teamName, events, interaction, onOwnerSlotDrop, simulation] + [camera, events, interaction, onOwnerSlotDrop, simulation] ); + const handleMouseMove = useCallback( + (e: React.MouseEvent) => { + if (processActivePointerMove(e.clientX, e.clientY, e.buttons)) { + 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 ((event.buttons & 1) === 0) { + return; + } + if ( + !isPanningRef.current && + !interaction.dragNodeId.current && + !interaction.isDragging.current && + !edgeMouseDownRef.current + ) { + return; + } + processActivePointerMove(event.clientX, event.clientY, event.buttons); + }; + + const handleWindowMouseUp = (event: MouseEvent): void => { + if ( + !isPanningRef.current && + !interaction.dragNodeId.current && + !interaction.isDragging.current && + !edgeMouseDownRef.current + ) { + return; + } + completePointerInteraction(event.clientX, event.clientY); + }; + + window.addEventListener('mousemove', handleWindowMouseMove); + window.addEventListener('mouseup', handleWindowMouseUp); + return () => { + window.removeEventListener('mousemove', handleWindowMouseMove); + window.removeEventListener('mouseup', handleWindowMouseUp); + }; + }, [completePointerInteraction, interaction, processActivePointerMove]); + const handleDoubleClick = useCallback( (e: React.MouseEvent) => { const canvas = canvasHandle.current?.getCanvas(); diff --git a/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts b/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts index 88e2127e..7f33931e 100644 --- a/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts +++ b/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts @@ -8,7 +8,10 @@ import { useEffect, useMemo, useRef, useSyncExternalStore } from 'react'; import { getSnapshot, subscribe } from '@renderer/services/commentReadStorage'; import { useStore } from '@renderer/store'; import { + getDefaultTeamGraphSlotAssignmentsForMembers, getCurrentProvisioningProgressForTeam, + hasAppliedDefaultTeamGraphSlotAssignments, + isTeamGraphSlotPersistenceDisabled, selectTeamDataForName, } from '@renderer/store/slices/teamSlice'; import { useShallow } from 'zustand/react/shallow'; @@ -62,6 +65,20 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { const commentReadState = useSyncExternalStore(subscribe, getSnapshot); + const effectiveSlotAssignments = useMemo(() => { + if (!teamData) { + return slotAssignments; + } + if (!isTeamGraphSlotPersistenceDisabled()) { + return slotAssignments; + } + if (hasAppliedDefaultTeamGraphSlotAssignments(teamName)) { + return slotAssignments; + } + const defaults = getDefaultTeamGraphSlotAssignmentsForMembers(teamData.members); + return Object.keys(defaults).length === 0 ? undefined : defaults; + }, [slotAssignments, teamData, teamName]); + useEffect(() => { if (!teamName || !teamData) { return; @@ -84,7 +101,7 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { commentReadState, provisioningProgress, memberSpawnSnapshot, - slotAssignments + effectiveSlotAssignments ), [ teamData, @@ -99,7 +116,7 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { commentReadState, provisioningProgress, memberSpawnSnapshot, - slotAssignments, + effectiveSlotAssignments, ] ); } diff --git a/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx b/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx index e5c7c6da..9780bef1 100644 --- a/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx +++ b/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx @@ -3,10 +3,12 @@ * Follows the exact ProjectEditorOverlay pattern (lazy-loaded, fixed z-50). */ -import { useCallback, useMemo } from 'react'; +import { useCallback, useLayoutEffect, useMemo } from 'react'; import { GraphView } from '@claude-teams/agent-graph'; import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost'; +import { useStore } from '@renderer/store'; +import { isTeamGraphSlotPersistenceDisabled } from '@renderer/store/slices/teamSlice'; import { useGraphCreateTaskDialog } from '../hooks/useGraphCreateTaskDialog'; import { useGraphSidebarVisibility } from '../hooks/useGraphSidebarVisibility'; @@ -53,6 +55,9 @@ export const TeamGraphOverlay = ({ }: TeamGraphOverlayProps): React.JSX.Element => { const graphData = useTeamGraphAdapter(teamName); const { openTeamPage: openTeamTab, commitOwnerSlotDrop } = useTeamGraphSurfaceActions(teamName); + const resetTeamGraphSlotAssignmentsToDefaults = useStore( + (s) => s.resetTeamGraphSlotAssignmentsToDefaults + ); const { sidebarVisible: persistedSidebarVisible, toggleSidebarVisible } = useGraphSidebarVisibility(); const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName); @@ -86,6 +91,13 @@ export const TeamGraphOverlay = ({ openCreateTaskDialog(''); }, [openCreateTaskDialog]); + useLayoutEffect(() => { + if (!isTeamGraphSlotPersistenceDisabled()) { + return; + } + resetTeamGraphSlotAssignmentsToDefaults(teamName); + }, [resetTeamGraphSlotAssignmentsToDefaults, teamName]); + const events: GraphEventPort = { onNodeDoubleClick: useCallback( (ref: GraphDomainRef) => { @@ -116,6 +128,7 @@ export const TeamGraphOverlay = ({ { const graphData = useTeamGraphAdapter(teamName); const { openTeamPage, commitOwnerSlotDrop } = useTeamGraphSurfaceActions(teamName); + const resetTeamGraphSlotAssignmentsToDefaults = useStore( + (s) => s.resetTeamGraphSlotAssignmentsToDefaults + ); const [fullscreen, setFullscreen] = useState(false); const { sidebarVisible, toggleSidebarVisible } = useGraphSidebarVisibility(); const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName); @@ -76,6 +81,13 @@ export const TeamGraphTab = ({ openCreateTaskDialog(''); }, [openCreateTaskDialog]); + useLayoutEffect(() => { + if (!isTeamGraphSlotPersistenceDisabled() || !isActive) { + return; + } + resetTeamGraphSlotAssignmentsToDefaults(teamName); + }, [isActive, resetTeamGraphSlotAssignmentsToDefaults, teamName]); + // Task action dispatchers const dispatchTaskAction = useCallback( (action: string) => (taskId: string) => @@ -140,6 +152,7 @@ export const TeamGraphTab = ({ events={events} className="team-graph-view size-full" suspendAnimation={!isActive} + isSurfaceActive={isActive} onRequestFullscreen={() => setFullscreen(true)} onOpenTeamPage={openTeamPage} onCreateTask={openCreateTask} diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 8b9ca8e5..9434292c 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -10,6 +10,7 @@ import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team'; +import { isLeadMember } from '@shared/utils/leadDetection'; import { createLogger } from '@shared/utils/logger'; import { getTaskKanbanColumn } from '@shared/utils/reviewState'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; @@ -55,6 +56,7 @@ import type { import type { StateCreator } from 'zustand'; const GRAPH_STABLE_SLOT_LAYOUT_VERSION = 'stable-slots-v1' as const; +const DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS = true; const logger = createLogger('teamSlice'); const TEAM_GET_DATA_TIMEOUT_MS = 30_000; @@ -81,6 +83,7 @@ const teamRefreshBurstDiagnostics = new Map< { windowStartedAt: number; count: number; lastWarnAt: number } >(); const memberSpawnUiEqualLastWarnAtByTeam = new Map(); +const sessionDefaultGraphSlotAssignmentsAppliedByTeam = new Set(); interface RefreshTeamDataOptions { withDedup?: boolean; } @@ -92,20 +95,20 @@ const SMALL_TEAM_CARDINAL_SLOT_PRESETS: ReadonlyArray !member.removedAt); + const visibleMembers = members.filter((member) => !member.removedAt && !isLeadMember(member)); if (visibleMembers.length === 0 || visibleMembers.length > 4) { return { assignments, changed: false }; } @@ -1021,6 +1025,44 @@ function seedStableSlotAssignmentsForMembers( return { assignments: nextAssignments, changed: true }; } +function areTeamGraphSlotAssignmentsEqual( + left: TeamGraphSlotAssignments | undefined, + right: TeamGraphSlotAssignments | undefined +): boolean { + const leftEntries = Object.entries(left ?? {}); + const rightEntries = Object.entries(right ?? {}); + if (leftEntries.length !== rightEntries.length) { + return false; + } + + for (const [stableOwnerId, leftAssignment] of leftEntries) { + const rightAssignment = right?.[stableOwnerId]; + if ( + !rightAssignment || + rightAssignment.ringIndex !== leftAssignment.ringIndex || + rightAssignment.sectorIndex !== leftAssignment.sectorIndex + ) { + return false; + } + } + + return true; +} + +export function getDefaultTeamGraphSlotAssignmentsForMembers( + members: readonly TeamGraphMemberSeedInput[] +): TeamGraphSlotAssignments { + return seedStableSlotAssignmentsForMembers({}, members).assignments; +} + +export function isTeamGraphSlotPersistenceDisabled(): boolean { + return DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS; +} + +export function hasAppliedDefaultTeamGraphSlotAssignments(teamName: string): boolean { + return sessionDefaultGraphSlotAssignmentsAppliedByTeam.has(teamName); +} + function isVisibleInActiveTeamSurface( state: Pick, teamName: string | null | undefined @@ -1829,9 +1871,34 @@ export const createTeamSlice: StateCreator = (set, if (state.slotLayoutVersion !== GRAPH_STABLE_SLOT_LAYOUT_VERSION) { nextState.slotLayoutVersion = GRAPH_STABLE_SLOT_LAYOUT_VERSION; nextSlotAssignmentsByTeam = {}; + sessionDefaultGraphSlotAssignmentsAppliedByTeam.clear(); changed = true; } + if (DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS) { + if (!sessionDefaultGraphSlotAssignmentsAppliedByTeam.has(teamName)) { + const currentAssignments = nextSlotAssignmentsByTeam[teamName]; + const defaultAssignments = getDefaultTeamGraphSlotAssignmentsForMembers(members); + if (!areTeamGraphSlotAssignmentsEqual(currentAssignments, defaultAssignments)) { + nextSlotAssignmentsByTeam = { ...nextSlotAssignmentsByTeam }; + if (Object.keys(defaultAssignments).length === 0) { + delete nextSlotAssignmentsByTeam[teamName]; + } else { + nextSlotAssignmentsByTeam[teamName] = defaultAssignments; + } + changed = true; + } + sessionDefaultGraphSlotAssignmentsAppliedByTeam.add(teamName); + } + + if (!changed) { + return {}; + } + + nextState.slotAssignmentsByTeam = nextSlotAssignmentsByTeam; + return nextState; + } + const currentAssignments = nextSlotAssignmentsByTeam[teamName]; const migrated = migrateStableSlotAssignmentsForMembers(currentAssignments, members); const seeded = seedStableSlotAssignmentsForMembers(migrated.assignments, members); @@ -1979,6 +2046,7 @@ export const createTeamSlice: StateCreator = (set, clearTeamGraphSlotAssignments: (teamName) => { set((state) => { if (!teamName) { + sessionDefaultGraphSlotAssignmentsAppliedByTeam.clear(); if ( Object.keys(state.slotAssignmentsByTeam).length === 0 && state.slotLayoutVersion === GRAPH_STABLE_SLOT_LAYOUT_VERSION @@ -1997,6 +2065,7 @@ export const createTeamSlice: StateCreator = (set, const nextAssignmentsByTeam = { ...state.slotAssignmentsByTeam }; delete nextAssignmentsByTeam[teamName]; + sessionDefaultGraphSlotAssignmentsAppliedByTeam.delete(teamName); return { slotLayoutVersion: GRAPH_STABLE_SLOT_LAYOUT_VERSION, slotAssignmentsByTeam: nextAssignmentsByTeam, @@ -2006,13 +2075,48 @@ export const createTeamSlice: StateCreator = (set, resetTeamGraphSlotAssignmentsToDefaults: (teamName) => { set((state) => { + if (!DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS) { + const currentAssignments = state.slotAssignmentsByTeam[teamName]; + if (!currentAssignments || Object.keys(currentAssignments).length === 0) { + return {}; + } + + const nextAssignmentsByTeam = { ...state.slotAssignmentsByTeam }; + delete nextAssignmentsByTeam[teamName]; + return { + slotLayoutVersion: GRAPH_STABLE_SLOT_LAYOUT_VERSION, + slotAssignmentsByTeam: nextAssignmentsByTeam, + }; + } + + const teamData = selectTeamDataForName(state, teamName); + const defaultAssignments = teamData + ? getDefaultTeamGraphSlotAssignmentsForMembers(teamData.members) + : {}; const currentAssignments = state.slotAssignmentsByTeam[teamName]; - if (!currentAssignments || Object.keys(currentAssignments).length === 0) { + const hasCurrentAssignments = + currentAssignments && Object.keys(currentAssignments).length > 0; + + if ( + areTeamGraphSlotAssignmentsEqual(currentAssignments, defaultAssignments) && + sessionDefaultGraphSlotAssignmentsAppliedByTeam.has(teamName) + ) { return {}; } const nextAssignmentsByTeam = { ...state.slotAssignmentsByTeam }; - delete nextAssignmentsByTeam[teamName]; + if (Object.keys(defaultAssignments).length === 0) { + delete nextAssignmentsByTeam[teamName]; + sessionDefaultGraphSlotAssignmentsAppliedByTeam.delete(teamName); + } else { + nextAssignmentsByTeam[teamName] = defaultAssignments; + sessionDefaultGraphSlotAssignmentsAppliedByTeam.add(teamName); + } + + if (!hasCurrentAssignments && Object.keys(defaultAssignments).length === 0) { + return {}; + } + return { slotLayoutVersion: GRAPH_STABLE_SLOT_LAYOUT_VERSION, slotAssignmentsByTeam: nextAssignmentsByTeam, diff --git a/test/renderer/features/agent-graph/useGraphSimulation.test.ts b/test/renderer/features/agent-graph/useGraphSimulation.test.ts index 2db1509f..a485d673 100644 --- a/test/renderer/features/agent-graph/useGraphSimulation.test.ts +++ b/test/renderer/features/agent-graph/useGraphSimulation.test.ts @@ -155,6 +155,97 @@ describe('stable slot layout planner', () => { expect(frame?.processBandRect.width).toBe(computeProcessBandWidth(0)); }); + it('uses strict cardinal owner slots for teams with up to four members', () => { + const teamName = 'team-cardinal-four'; + const lead = createLead(teamName); + const top = createMember(teamName, 'agent-top', 'top'); + const right = createMember(teamName, 'agent-right', 'right'); + const bottom = createMember(teamName, 'agent-bottom', 'bottom'); + const left = createMember(teamName, 'agent-left', 'left'); + const layout: GraphLayoutPort = { + version: 'stable-slots-v1', + ownerOrder: [top.id, right.id, bottom.id, left.id], + slotAssignments: { + [top.id]: { ringIndex: 0, sectorIndex: 0 }, + [right.id]: { ringIndex: 0, sectorIndex: 1 }, + [bottom.id]: { ringIndex: 0, sectorIndex: 2 }, + [left.id]: { ringIndex: 0, sectorIndex: 3 }, + }, + }; + + const snapshot = buildStableSlotLayoutSnapshot({ + teamName, + nodes: [lead, top, right, bottom, left], + layout, + }); + + expect(snapshot).not.toBeNull(); + + const topFrame = snapshot!.memberSlotFrameByOwnerId.get(top.id)!; + const rightFrame = snapshot!.memberSlotFrameByOwnerId.get(right.id)!; + const bottomFrame = snapshot!.memberSlotFrameByOwnerId.get(bottom.id)!; + const leftFrame = snapshot!.memberSlotFrameByOwnerId.get(left.id)!; + + expect(Math.abs(topFrame.ownerX)).toBeLessThan(1); + expect(topFrame.ownerY).toBeLessThan(0); + + expect(rightFrame.ownerX).toBeGreaterThan(0); + expect(Math.abs(rightFrame.ownerY)).toBeLessThan(1); + + expect(Math.abs(bottomFrame.ownerX)).toBeLessThan(1); + expect(bottomFrame.ownerY).toBeGreaterThan(0); + + expect(leftFrame.ownerX).toBeLessThan(0); + expect(Math.abs(leftFrame.ownerY)).toBeLessThan(1); + + expect(Math.abs(Math.abs(leftFrame.ownerX) - Math.abs(rightFrame.ownerX))).toBeLessThan(1); + expect(Math.abs(Math.abs(topFrame.ownerY) - Math.abs(bottomFrame.ownerY))).toBeLessThan(1); + }); + + it('uses strict cardinal owner slots even when ownerOrder differs from assignment order', () => { + const teamName = 'team-cardinal-misaligned-order'; + const lead = createLead(teamName); + const alice = createMember(teamName, 'agent-alice', 'alice'); + const bob = createMember(teamName, 'agent-bob', 'bob'); + const tom = createMember(teamName, 'agent-tom', 'tom'); + const jack = createMember(teamName, 'agent-jack', 'jack'); + const layout: GraphLayoutPort = { + version: 'stable-slots-v1', + ownerOrder: [jack.id, alice.id, tom.id, bob.id], + slotAssignments: { + [alice.id]: { ringIndex: 0, sectorIndex: 0 }, + [bob.id]: { ringIndex: 0, sectorIndex: 1 }, + [tom.id]: { ringIndex: 0, sectorIndex: 2 }, + [jack.id]: { ringIndex: 0, sectorIndex: 3 }, + }, + }; + + const snapshot = buildStableSlotLayoutSnapshot({ + teamName, + nodes: [lead, alice, bob, tom, jack], + layout, + }); + + expect(snapshot).not.toBeNull(); + + const aliceFrame = snapshot!.memberSlotFrameByOwnerId.get(alice.id)!; + const bobFrame = snapshot!.memberSlotFrameByOwnerId.get(bob.id)!; + const tomFrame = snapshot!.memberSlotFrameByOwnerId.get(tom.id)!; + const jackFrame = snapshot!.memberSlotFrameByOwnerId.get(jack.id)!; + + expect(Math.abs(aliceFrame.ownerX)).toBeLessThan(1); + expect(aliceFrame.ownerY).toBeLessThan(0); + + expect(bobFrame.ownerX).toBeGreaterThan(0); + expect(Math.abs(bobFrame.ownerY)).toBeLessThan(1); + + expect(Math.abs(tomFrame.ownerX)).toBeLessThan(1); + expect(tomFrame.ownerY).toBeGreaterThan(0); + + expect(jackFrame.ownerX).toBeLessThan(0); + expect(Math.abs(jackFrame.ownerY)).toBeLessThan(1); + }); + it('reserves a full empty activity column and minimum kanban width for idle members', () => { const teamName = 'team-empty-slot'; const lead = createLead(teamName); @@ -384,6 +475,48 @@ describe('stable slot layout planner', () => { expect(nearest?.displacedAssignment).toEqual({ ringIndex: 0, sectorIndex: 1 }); }); + it('keeps drag resolution inside strict cardinal slots for four-member teams', () => { + const teamName = 'team-cardinal-drag'; + const lead = createLead(teamName); + const top = createMember(teamName, 'agent-top', 'top'); + const right = createMember(teamName, 'agent-right', 'right'); + const bottom = createMember(teamName, 'agent-bottom', 'bottom'); + const left = createMember(teamName, 'agent-left', 'left'); + const layout: GraphLayoutPort = { + version: 'stable-slots-v1', + ownerOrder: [top.id, right.id, bottom.id, left.id], + slotAssignments: { + [top.id]: { ringIndex: 0, sectorIndex: 0 }, + [right.id]: { ringIndex: 0, sectorIndex: 1 }, + [bottom.id]: { ringIndex: 0, sectorIndex: 2 }, + [left.id]: { ringIndex: 0, sectorIndex: 3 }, + }, + }; + + const snapshot = buildStableSlotLayoutSnapshot({ + teamName, + nodes: [lead, top, right, bottom, left], + layout, + }); + + expect(snapshot).not.toBeNull(); + const rightFrame = snapshot!.memberSlotFrameByOwnerId.get(right.id)!; + + const nearest = resolveNearestSlotAssignment({ + ownerId: top.id, + ownerX: rightFrame.ownerX, + ownerY: rightFrame.ownerY, + nodes: [lead, top, right, bottom, left], + snapshot: snapshot!, + layout, + }); + + expect(nearest).not.toBeNull(); + expect(nearest?.assignment).toEqual({ ringIndex: 0, sectorIndex: 1 }); + expect(nearest?.displacedOwnerId).toBe(right.id); + expect(nearest?.displacedAssignment).toEqual({ ringIndex: 0, sectorIndex: 0 }); + }); + it('keeps nearest-slot drag resolution on the same central collision model as the planner', () => { const teamName = 'team-drag-central-collision'; const lead = createLead(teamName); diff --git a/test/renderer/store/teamSlice.test.ts b/test/renderer/store/teamSlice.test.ts index a7e9e286..787ef9af 100644 --- a/test/renderer/store/teamSlice.test.ts +++ b/test/renderer/store/teamSlice.test.ts @@ -221,7 +221,7 @@ describe('teamSlice actions', () => { expect(store.getState().warmTaskChangeSummaries).not.toHaveBeenCalled(); }); - it('commits an owner slot drop atomically even when prior assignments were sparse', () => { + it('commits owner slot drops in the current session while persistence is disabled', () => { const store = createSliceStore(); store.getState().commitTeamGraphOwnerSlotDrop( @@ -238,7 +238,7 @@ describe('teamSlice actions', () => { }); }); - it('migrates fallback name-based slot assignments to agentId-based stable owner ids', () => { + it('replaces persisted slot assignments with defaults while persistence is disabled', () => { const store = createSliceStore(); store.setState({ slotLayoutVersion: 'stable-slots-v1', @@ -255,7 +255,8 @@ describe('teamSlice actions', () => { ]); expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({ - 'agent-alice': { ringIndex: 0, sectorIndex: 3 }, + 'agent-alice': { ringIndex: 0, sectorIndex: 0 }, + 'agent-bob': { ringIndex: 0, sectorIndex: 1 }, }); }); @@ -270,14 +271,33 @@ describe('teamSlice actions', () => { ]); expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({ - 'agent-alice': { ringIndex: 0, sectorIndex: 5 }, + 'agent-alice': { ringIndex: 0, sectorIndex: 0 }, 'agent-bob': { ringIndex: 0, sectorIndex: 1 }, - 'agent-tom': { ringIndex: 0, sectorIndex: 4 }, - 'agent-jack': { ringIndex: 0, sectorIndex: 2 }, + 'agent-tom': { ringIndex: 0, sectorIndex: 2 }, + 'agent-jack': { ringIndex: 0, sectorIndex: 3 }, }); }); - it('seeds visible members even when only hidden owners have saved placements', () => { + it('ignores the lead member when deriving small-team cardinal defaults', () => { + const store = createSliceStore(); + + store.getState().ensureTeamGraphSlotAssignments('my-team', [ + { name: 'team-lead', agentId: 'lead-id' }, + { name: 'alice', agentId: 'agent-alice' }, + { name: 'bob', agentId: 'agent-bob' }, + { name: 'tom', agentId: 'agent-tom' }, + { name: 'jack', agentId: 'agent-jack' }, + ]); + + expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({ + 'agent-alice': { ringIndex: 0, sectorIndex: 0 }, + 'agent-bob': { ringIndex: 0, sectorIndex: 1 }, + 'agent-tom': { ringIndex: 0, sectorIndex: 2 }, + 'agent-jack': { ringIndex: 0, sectorIndex: 3 }, + }); + }); + + it('drops hidden persisted slot assignments and reseeds visible members while persistence is disabled', () => { const store = createSliceStore(); store.setState({ slotLayoutVersion: 'stable-slots-v1', @@ -295,8 +315,7 @@ describe('teamSlice actions', () => { ]); expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({ - 'agent-hidden': { ringIndex: 2, sectorIndex: 4 }, - 'agent-alice': { ringIndex: 0, sectorIndex: 5 }, + 'agent-alice': { ringIndex: 0, sectorIndex: 0 }, 'agent-bob': { ringIndex: 0, sectorIndex: 1 }, }); }); @@ -327,7 +346,7 @@ describe('teamSlice actions', () => { }); }); - it('keeps hidden-member slot assignments so the same stable owner can reuse them later', () => { + it('ignores hidden-member persisted slot assignments while persistence is disabled', () => { const store = createSliceStore(); store.setState({ slotLayoutVersion: 'stable-slots-v1', @@ -344,8 +363,77 @@ describe('teamSlice actions', () => { ]); expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({ - 'agent-hidden': { ringIndex: 1, sectorIndex: 5 }, - 'agent-visible': { ringIndex: 0, sectorIndex: 2 }, + 'agent-visible': { ringIndex: 0, sectorIndex: 0 }, + }); + }); + + it('does not reseed a team again after defaults were applied once in the session', () => { + const store = createSliceStore(); + + store.getState().ensureTeamGraphSlotAssignments('my-team', [ + { name: 'alice', agentId: 'agent-alice' }, + { name: 'bob', agentId: 'agent-bob' }, + ]); + + store.getState().setTeamGraphOwnerSlotAssignment('my-team', 'agent-alice', { + ringIndex: 1, + sectorIndex: 4, + }); + + store.getState().ensureTeamGraphSlotAssignments('my-team', [ + { name: 'alice', agentId: 'agent-alice' }, + { name: 'bob', agentId: 'agent-bob' }, + ]); + + expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({ + 'agent-alice': { ringIndex: 1, sectorIndex: 4 }, + 'agent-bob': { ringIndex: 0, sectorIndex: 1 }, + }); + }); + + it('resets graph slot assignments back to defaults when reopening the graph surface', () => { + const store = createSliceStore(); + store.setState({ + teamDataCacheByName: { + 'my-team': { + teamName: 'my-team', + config: { name: 'My Team' }, + tasks: [], + members: [ + { name: 'alice', agentId: 'agent-alice' }, + { name: 'bob', agentId: 'agent-bob' }, + { name: 'tom', agentId: 'agent-tom' }, + { name: 'jack', agentId: 'agent-jack' }, + ], + messages: [], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + processes: [], + }, + }, + }); + + store.getState().ensureTeamGraphSlotAssignments('my-team', [ + { name: 'alice', agentId: 'agent-alice' }, + { name: 'bob', agentId: 'agent-bob' }, + { name: 'tom', agentId: 'agent-tom' }, + { name: 'jack', agentId: 'agent-jack' }, + ]); + + store.getState().commitTeamGraphOwnerSlotDrop( + 'my-team', + 'agent-alice', + { ringIndex: 0, sectorIndex: 2 }, + 'agent-tom', + { ringIndex: 0, sectorIndex: 0 } + ); + + store.getState().resetTeamGraphSlotAssignmentsToDefaults('my-team'); + + expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({ + 'agent-alice': { ringIndex: 0, sectorIndex: 0 }, + 'agent-bob': { ringIndex: 0, sectorIndex: 1 }, + 'agent-tom': { ringIndex: 0, sectorIndex: 2 }, + 'agent-jack': { ringIndex: 0, sectorIndex: 3 }, }); }); From 345fd3e41d2e74848249ec865edf55d61ea1b451 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 13:16:28 +0300 Subject: [PATCH 045/121] fix(recent-projects): recover codex projects after degraded startup --- src/features/recent-projects/contracts/api.ts | 4 +- src/features/recent-projects/contracts/dto.ts | 5 ++ .../recent-projects/contracts/index.ts | 1 + .../recent-projects/contracts/normalize.ts | 31 +++++++ .../ListDashboardRecentProjectsResponse.ts | 1 + .../ports/RecentProjectsSourcePort.ts | 9 ++- .../ListDashboardRecentProjectsUseCase.ts | 32 ++++++-- .../input/http/registerRecentProjectsHttp.ts | 14 +++- .../input/ipc/registerRecentProjectsIpc.ts | 14 +++- .../DashboardRecentProjectsPresenter.ts | 38 +++++---- .../ClaudeRecentProjectsSourceAdapter.ts | 12 ++- .../CodexRecentProjectsSourceAdapter.ts | 28 +++++-- .../createRecentProjectsFeature.ts | 14 ++-- .../hooks/useRecentProjectsSection.ts | 40 +++++++++- .../utils/recentProjectsClientCache.ts | 43 ++++++---- src/renderer/api/httpClient.ts | 6 +- ...lizeDashboardRecentProjectsPayload.test.ts | 50 ++++++++++++ ...ListDashboardRecentProjectsUseCase.test.ts | 71 ++++++++++++++++ .../CodexRecentProjectsSourceAdapter.test.ts | 51 +++++++----- .../utils/recentProjectsClientCache.test.ts | 80 ++++++++++++++----- 20 files changed, 437 insertions(+), 107 deletions(-) create mode 100644 src/features/recent-projects/contracts/normalize.ts create mode 100644 test/features/recent-projects/contracts/normalizeDashboardRecentProjectsPayload.test.ts diff --git a/src/features/recent-projects/contracts/api.ts b/src/features/recent-projects/contracts/api.ts index 285ce11e..c1a74622 100644 --- a/src/features/recent-projects/contracts/api.ts +++ b/src/features/recent-projects/contracts/api.ts @@ -1,5 +1,5 @@ -import type { DashboardRecentProject } from './dto'; +import type { DashboardRecentProjectsPayload } from './dto'; export interface RecentProjectsElectronApi { - getDashboardRecentProjects(): Promise; + getDashboardRecentProjects(): Promise; } diff --git a/src/features/recent-projects/contracts/dto.ts b/src/features/recent-projects/contracts/dto.ts index 253ac36e..bdb4eda0 100644 --- a/src/features/recent-projects/contracts/dto.ts +++ b/src/features/recent-projects/contracts/dto.ts @@ -17,3 +17,8 @@ export interface DashboardRecentProject { 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 index 69f32f5a..41e0bc74 100644 --- a/src/features/recent-projects/contracts/index.ts +++ b/src/features/recent-projects/contracts/index.ts @@ -1,3 +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..e38ce700 --- /dev/null +++ b/src/features/recent-projects/contracts/normalize.ts @@ -0,0 +1,31 @@ +import type { DashboardRecentProject, DashboardRecentProjectsPayload } from './dto'; + +export type DashboardRecentProjectsPayloadLike = + | DashboardRecentProjectsPayload + | DashboardRecentProject[] + | 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 index 7016a81f..0800ee06 100644 --- a/src/features/recent-projects/core/application/models/ListDashboardRecentProjectsResponse.ts +++ b/src/features/recent-projects/core/application/models/ListDashboardRecentProjectsResponse.ts @@ -2,4 +2,5 @@ import type { RecentProjectAggregate } from '../../domain/models/RecentProjectAg export interface ListDashboardRecentProjectsResponse { projects: RecentProjectAggregate[]; + degraded: boolean; } diff --git a/src/features/recent-projects/core/application/ports/RecentProjectsSourcePort.ts b/src/features/recent-projects/core/application/ports/RecentProjectsSourcePort.ts index 004a8d72..cba03607 100644 --- a/src/features/recent-projects/core/application/ports/RecentProjectsSourcePort.ts +++ b/src/features/recent-projects/core/application/ports/RecentProjectsSourcePort.ts @@ -1,7 +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; + 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 index fefd56d8..348fc1b4 100644 --- a/src/features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase.ts +++ b/src/features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase.ts @@ -6,7 +6,11 @@ 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 { RecentProjectsSourcePort } from '../ports/RecentProjectsSourcePort'; +import type { + RecentProjectsSourcePayload, + RecentProjectsSourcePort, + RecentProjectsSourceResult, +} from '../ports/RecentProjectsSourcePort'; const DEFAULT_CACHE_TTL_MS = 10_000; const DEFAULT_DEGRADED_CACHE_TTL_MS = 1_500; @@ -16,6 +20,20 @@ interface SourceLoadResult { 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; @@ -66,6 +84,7 @@ export class ListDashboardRecentProjectsUseCase { const hasDegradedSources = results.some((result) => result.degraded); const response: ListDashboardRecentProjectsResponse = { projects: mergeRecentProjectCandidates(successful), + degraded: hasDegradedSources, }; if (hasDegradedSources && stale && response.projects.length === 0) { @@ -111,10 +130,10 @@ export class ListDashboardRecentProjectsUseCase { source .list() .then( - (candidates) => + (payload) => ({ kind: 'success', - candidates, + payload: normalizeSourcePayload(payload), }) as const ) .catch( @@ -130,7 +149,7 @@ export class ListDashboardRecentProjectsUseCase { ]); if (result.kind === 'success') { - return { candidates: result.candidates, degraded: false }; + return result.payload; } if (result.kind === 'timeout') { @@ -161,10 +180,7 @@ export class ListDashboardRecentProjectsUseCase { sourceIndex: number ): Promise { try { - return { - candidates: await source.list(), - degraded: false, - }; + return normalizeSourcePayload(await source.list()); } catch (error) { this.deps.logger.warn('recent-projects source failed', { sourceId, diff --git a/src/features/recent-projects/main/adapters/input/http/registerRecentProjectsHttp.ts b/src/features/recent-projects/main/adapters/input/http/registerRecentProjectsHttp.ts index 991cab02..ac3001f0 100644 --- a/src/features/recent-projects/main/adapters/input/http/registerRecentProjectsHttp.ts +++ b/src/features/recent-projects/main/adapters/input/http/registerRecentProjectsHttp.ts @@ -1,6 +1,7 @@ import { DASHBOARD_RECENT_PROJECTS_ROUTE, - type DashboardRecentProject, + normalizeDashboardRecentProjectsPayload, + type DashboardRecentProjectsPayload, } from '@features/recent-projects/contracts'; import { createLogger } from '@shared/utils/logger'; @@ -13,12 +14,17 @@ export function registerRecentProjectsHttp( app: FastifyInstance, feature: RecentProjectsFeatureFacade ): void { - app.get(DASHBOARD_RECENT_PROJECTS_ROUTE, async (): Promise => { + app.get(DASHBOARD_RECENT_PROJECTS_ROUTE, async (): Promise => { try { - return await feature.listDashboardRecentProjects(); + return ( + normalizeDashboardRecentProjectsPayload(await feature.listDashboardRecentProjects()) ?? { + projects: [], + degraded: true, + } + ); } catch (error) { logger.error('Failed to load dashboard recent projects via HTTP', error); - return []; + 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 index 906c97b5..a18ea436 100644 --- a/src/features/recent-projects/main/adapters/input/ipc/registerRecentProjectsIpc.ts +++ b/src/features/recent-projects/main/adapters/input/ipc/registerRecentProjectsIpc.ts @@ -1,4 +1,7 @@ -import { GET_DASHBOARD_RECENT_PROJECTS } from '@features/recent-projects/contracts'; +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'; @@ -12,10 +15,15 @@ export function registerRecentProjectsIpc( ): void { ipcMain.handle(GET_DASHBOARD_RECENT_PROJECTS, async () => { try { - return await feature.listDashboardRecentProjects(); + return ( + normalizeDashboardRecentProjectsPayload(await feature.listDashboardRecentProjects()) ?? { + projects: [], + degraded: true, + } + ); } catch (error) { logger.error('Failed to load dashboard recent projects via IPC', error); - return []; + return { projects: [], degraded: true }; } }); } diff --git a/src/features/recent-projects/main/adapters/output/presenters/DashboardRecentProjectsPresenter.ts b/src/features/recent-projects/main/adapters/output/presenters/DashboardRecentProjectsPresenter.ts index 638c662e..c9a3e5fb 100644 --- a/src/features/recent-projects/main/adapters/output/presenters/DashboardRecentProjectsPresenter.ts +++ b/src/features/recent-projects/main/adapters/output/presenters/DashboardRecentProjectsPresenter.ts @@ -1,21 +1,27 @@ -import type { DashboardRecentProject } from '@features/recent-projects/contracts'; +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< - DashboardRecentProject[] -> { - present(response: ListDashboardRecentProjectsResponse): DashboardRecentProject[] { - return response.projects.map((aggregate) => ({ - 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, - })); +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 index 2d5aa3c5..700b9122 100644 --- a/src/features/recent-projects/main/adapters/output/sources/ClaudeRecentProjectsSourceAdapter.ts +++ b/src/features/recent-projects/main/adapters/output/sources/ClaudeRecentProjectsSourceAdapter.ts @@ -3,7 +3,10 @@ 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 } from '@features/recent-projects/core/application/ports/RecentProjectsSourcePort'; +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'; @@ -47,7 +50,7 @@ export class ClaudeRecentProjectsSourceAdapter implements RecentProjectsSourcePo private readonly logger: LoggerPort ) {} - async list(): Promise { + async list(): Promise { const activeContext = this.getActiveContext(); const groups = activeContext.type === 'local' @@ -63,7 +66,10 @@ export class ClaudeRecentProjectsSourceAdapter implements RecentProjectsSourcePo contextId: activeContext.id, }); - return candidates; + return { + candidates, + degraded: false, + }; } async #groupLocalProjects(activeContext: ServiceContext): Promise { diff --git a/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts b/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts index fa0af4a9..f597fcfe 100644 --- a/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts +++ b/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts @@ -2,7 +2,10 @@ import { normalizeIdentityPath } from '@features/recent-projects/main/infrastruc import path from 'path'; import type { LoggerPort } from '@features/recent-projects/core/application/ports/LoggerPort'; -import type { RecentProjectsSourcePort } from '@features/recent-projects/core/application/ports/RecentProjectsSourcePort'; +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, @@ -34,6 +37,10 @@ function normalizeTimestamp(value: number | undefined): number { 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; @@ -49,21 +56,28 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor } ) {} - async list(): Promise { + async list(): Promise { const activeContext = this.deps.getActiveContext(); const localContext = this.deps.getLocalContext(); if (activeContext.type !== 'local' || activeContext.id !== localContext?.id) { - return []; + return { + candidates: [], + degraded: false, + }; } const binaryPath = await this.deps.resolveBinary(); if (!binaryPath) { this.deps.logger.info('codex recent-projects source skipped - binary unavailable'); - return []; + 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; @@ -79,9 +93,13 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor this.deps.logger.info('codex recent-projects source loaded', { count: candidates.length, + degraded, }); - return candidates; + return { + candidates, + degraded, + }; } async #listRecentThreads(binaryPath: string): Promise { diff --git a/src/features/recent-projects/main/composition/createRecentProjectsFeature.ts b/src/features/recent-projects/main/composition/createRecentProjectsFeature.ts index effb5a8c..f77986e8 100644 --- a/src/features/recent-projects/main/composition/createRecentProjectsFeature.ts +++ b/src/features/recent-projects/main/composition/createRecentProjectsFeature.ts @@ -10,11 +10,14 @@ import { RecentProjectIdentityResolver } from '../infrastructure/identity/Recent import type { ClockPort } from '../../core/application/ports/ClockPort'; import type { LoggerPort } from '../../core/application/ports/LoggerPort'; -import type { DashboardRecentProject } from '@features/recent-projects/contracts'; +import { + normalizeDashboardRecentProjectsPayload, + type DashboardRecentProjectsPayload, +} from '@features/recent-projects/contracts'; import type { ServiceContext } from '@main/services'; export interface RecentProjectsFeatureFacade { - listDashboardRecentProjects(): Promise; + listDashboardRecentProjects(): Promise; } export function createRecentProjectsFeature(deps: { @@ -22,7 +25,7 @@ export function createRecentProjectsFeature(deps: { getLocalContext: () => ServiceContext | undefined; logger: LoggerPort; }): RecentProjectsFeatureFacade { - const cache = new InMemoryRecentProjectsCache(); + const cache = new InMemoryRecentProjectsCache(); const presenter = new DashboardRecentProjectsPresenter(); const clock: ClockPort = { now: () => Date.now() }; const jsonRpcStdioClient = new JsonRpcStdioClient(deps.logger); @@ -48,9 +51,10 @@ export function createRecentProjectsFeature(deps: { }); return { - listDashboardRecentProjects: () => { + listDashboardRecentProjects: async () => { const activeContext = deps.getActiveContext(); - return useCase.execute(`dashboard-recent-projects:${activeContext.id}`); + const payload = await useCase.execute(`dashboard-recent-projects:${activeContext.id}`); + return normalizeDashboardRecentProjectsPayload(payload) ?? { projects: [], degraded: true }; }, }; } diff --git a/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts b/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts index 63d2c223..baa2f48b 100644 --- a/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts +++ b/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts @@ -23,6 +23,9 @@ 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) { @@ -72,7 +75,13 @@ export function useRecentProjectsSection( const initialSnapshot = useMemo(() => getRecentProjectsClientSnapshot(), []); const { openRecentProject, openProjectPath, selectProjectFolder } = useOpenRecentProject(); const [recentProjects, setRecentProjects] = useState( - initialSnapshot?.projects ?? [] + 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); @@ -80,7 +89,9 @@ export function useRecentProjectsSection( const [aliveTeams, setAliveTeams] = useState([]); const [openHistoryVersion, setOpenHistoryVersion] = useState(0); const hasFetchedTasksRef = useRef(globalTasksInitialized); - const recentProjectsRef = useRef(initialSnapshot?.projects ?? []); + const recentProjectsRef = useRef( + initialSnapshot?.payload.projects ?? [] + ); useEffect(() => { recentProjectsRef.current = recentProjects; @@ -95,11 +106,13 @@ export function useRecentProjectsSection( } setError(null); try { - const projects = await loadRecentProjectsWithClientCache( + const payload = await loadRecentProjectsWithClientCache( () => api.getDashboardRecentProjects(), options ); - setRecentProjects(projects); + 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 { @@ -116,6 +129,25 @@ export function useRecentProjectsSection( 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; diff --git a/src/features/recent-projects/renderer/utils/recentProjectsClientCache.ts b/src/features/recent-projects/renderer/utils/recentProjectsClientCache.ts index e28d89b2..dc804d0d 100644 --- a/src/features/recent-projects/renderer/utils/recentProjectsClientCache.ts +++ b/src/features/recent-projects/renderer/utils/recentProjectsClientCache.ts @@ -1,38 +1,52 @@ -import type { DashboardRecentProject } from '@features/recent-projects/contracts'; +import type { + DashboardRecentProjectsPayloadLike, + DashboardRecentProjectsPayload, +} from '@features/recent-projects/contracts'; +import { normalizeDashboardRecentProjectsPayload } 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 cachedProjects: DashboardRecentProject[] | null = null; +let cachedPayload: DashboardRecentProjectsPayloadLike = null; let cachedAt = 0; -let inFlightLoad: Promise | null = null; +let inFlightLoad: Promise | null = null; export interface RecentProjectsClientSnapshot { - projects: DashboardRecentProject[]; + payload: DashboardRecentProjectsPayload; fetchedAt: number; isStale: boolean; } export function getRecentProjectsClientSnapshot(): RecentProjectsClientSnapshot | null { - if (!cachedProjects) { + 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 { - projects: cachedProjects, + payload: normalizedPayload, fetchedAt: cachedAt, - isStale: Date.now() - cachedAt > RECENT_PROJECTS_CLIENT_CACHE_TTL_MS, + isStale: Date.now() - cachedAt > ttlMs, }; } export async function loadRecentProjectsWithClientCache( - loader: () => Promise, + loader: () => Promise, options?: { force?: boolean } -): Promise { +): Promise { const force = options?.force ?? false; const snapshot = getRecentProjectsClientSnapshot(); if (!force && snapshot && !snapshot.isStale) { - return snapshot.projects; + return snapshot.payload; } if (inFlightLoad) { @@ -40,10 +54,11 @@ export async function loadRecentProjectsWithClientCache( } const request = loader() - .then((projects) => { - cachedProjects = projects; + .then((payloadLike) => { + const normalizedPayload = normalizeDashboardRecentProjectsPayload(payloadLike); + cachedPayload = normalizedPayload; cachedAt = Date.now(); - return projects; + return normalizedPayload ?? { projects: [], degraded: true }; }) .finally(() => { if (inFlightLoad === request) { @@ -56,7 +71,7 @@ export async function loadRecentProjectsWithClientCache( } export function __resetRecentProjectsClientCacheForTests(): void { - cachedProjects = null; + cachedPayload = null; cachedAt = 0; inFlightLoad = null; } diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 5f2df4aa..d3983c79 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -6,7 +6,7 @@ * to run in a regular browser connected to an HTTP server. */ -import type { DashboardRecentProject } from '@features/recent-projects/contracts'; +import type { DashboardRecentProjectsPayload } from '@features/recent-projects/contracts'; import type { AppConfig, AttachmentFileData, @@ -217,8 +217,8 @@ export class HttpAPIClient implements ElectronAPI { getAppVersion = (): Promise => this.get('/api/version'); - getDashboardRecentProjects = (): Promise => - this.get('/api/dashboard/recent-projects'); + getDashboardRecentProjects = (): Promise => + this.get('/api/dashboard/recent-projects'); getProjects = (): Promise => this.get('/api/projects'); diff --git a/test/features/recent-projects/contracts/normalizeDashboardRecentProjectsPayload.test.ts b/test/features/recent-projects/contracts/normalizeDashboardRecentProjectsPayload.test.ts new file mode 100644 index 00000000..de8345a6 --- /dev/null +++ b/test/features/recent-projects/contracts/normalizeDashboardRecentProjectsPayload.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; + +import { + normalizeDashboardRecentProjectsPayload, + type DashboardRecentProject, +} from '@features/recent-projects/contracts'; + +const project = (id: string): DashboardRecentProject => ({ + id, + name: id, + primaryPath: `/tmp/${id}`, + associatedPaths: [`/tmp/${id}`], + mostRecentActivity: Date.parse('2026-04-14T12:00:00.000Z'), + providerIds: ['anthropic'], + source: 'claude', + openTarget: { + type: 'synthetic-path', + path: `/tmp/${id}`, + }, +}); + +describe('normalizeDashboardRecentProjectsPayload', () => { + it('keeps payload objects intact except for degraded normalization', () => { + expect( + normalizeDashboardRecentProjectsPayload({ + degraded: true, + projects: [project('alpha')], + }) + ).toEqual({ + degraded: true, + projects: [project('alpha')], + }); + }); + + it('normalizes legacy project arrays into healthy payloads', () => { + expect(normalizeDashboardRecentProjectsPayload([project('alpha')])).toEqual({ + degraded: false, + projects: [project('alpha')], + }); + }); + + it('returns null for malformed payloads', () => { + expect( + normalizeDashboardRecentProjectsPayload({ + degraded: false, + projects: null, + } as unknown as { degraded: boolean; projects: null }) + ).toBeNull(); + }); +}); diff --git a/test/features/recent-projects/core/application/ListDashboardRecentProjectsUseCase.test.ts b/test/features/recent-projects/core/application/ListDashboardRecentProjectsUseCase.test.ts index 14fedfc6..b68d88f3 100644 --- a/test/features/recent-projects/core/application/ListDashboardRecentProjectsUseCase.test.ts +++ b/test/features/recent-projects/core/application/ListDashboardRecentProjectsUseCase.test.ts @@ -140,6 +140,7 @@ describe('ListDashboardRecentProjectsUseCase', () => { sources: ['mixed'], }); expect(output.present).toHaveBeenCalledWith({ + degraded: true, projects: [ expect.objectContaining({ identity: 'repo:alpha', @@ -299,6 +300,7 @@ describe('ListDashboardRecentProjectsUseCase', () => { sources: ['claude'], }); expect(output.present).toHaveBeenCalledWith({ + degraded: true, projects: [ expect.objectContaining({ identity: 'repo:fresh', @@ -370,4 +372,73 @@ describe('ListDashboardRecentProjectsUseCase', () => { durationMs: 200, }); }); + + it('treats explicitly degraded source payloads as degraded even when they resolve successfully', async () => { + const cache: RecentProjectsCachePort = { + get: vi.fn().mockResolvedValue(null), + getStale: vi.fn().mockResolvedValue(null), + set: vi.fn().mockResolvedValue(undefined), + }; + const output: ListDashboardRecentProjectsOutputPort = { + present: vi.fn((response: ListDashboardRecentProjectsResponse) => ({ + ids: response.projects.map((project) => project.identity), + sources: response.projects.map((project) => project.source), + })), + }; + const sources: RecentProjectsSourcePort[] = [ + { + sourceId: 'claude', + list: vi.fn().mockResolvedValue([ + makeCandidate({ + identity: 'repo:alpha', + providerIds: ['anthropic'], + sourceKind: 'claude', + }), + ]), + }, + { + sourceId: 'codex', + list: vi.fn().mockResolvedValue({ + candidates: [], + degraded: true, + }), + }, + ]; + const logger = createLogger(); + + const useCase = new ListDashboardRecentProjectsUseCase({ + sources, + cache, + output, + clock: { now: () => 25_000 }, + logger, + }); + + await expect(useCase.execute('recent-projects:explicit-degraded')).resolves.toEqual({ + ids: ['repo:alpha'], + sources: ['claude'], + }); + + expect(output.present).toHaveBeenCalledWith({ + degraded: true, + projects: [ + expect.objectContaining({ + identity: 'repo:alpha', + source: 'claude', + }), + ], + }); + expect(cache.set).toHaveBeenCalledWith( + 'recent-projects:explicit-degraded', + { ids: ['repo:alpha'], sources: ['claude'] }, + 1_500 + ); + expect(logger.info).toHaveBeenCalledWith('recent-projects loaded', { + cacheKey: 'recent-projects:explicit-degraded', + count: 1, + degradedSources: 1, + cacheTtlMs: 1_500, + durationMs: 0, + }); + }); }); diff --git a/test/features/recent-projects/main/adapters/output/CodexRecentProjectsSourceAdapter.test.ts b/test/features/recent-projects/main/adapters/output/CodexRecentProjectsSourceAdapter.test.ts index d963ae88..40b2f9ee 100644 --- a/test/features/recent-projects/main/adapters/output/CodexRecentProjectsSourceAdapter.test.ts +++ b/test/features/recent-projects/main/adapters/output/CodexRecentProjectsSourceAdapter.test.ts @@ -57,12 +57,15 @@ describe('CodexRecentProjectsSourceAdapter', () => { logger, }); - await expect(adapter.list()).resolves.toEqual([ - expect.objectContaining({ - identity: 'repo:headless', - primaryPath: '/Users/belief/dev/projects/headless', - }), - ]); + await expect(adapter.list()).resolves.toEqual({ + candidates: [ + expect.objectContaining({ + identity: 'repo:headless', + primaryPath: '/Users/belief/dev/projects/headless', + }), + ], + degraded: true, + }); expect(logger.info).toHaveBeenCalledWith( 'codex recent-projects archived thread list degraded', @@ -110,20 +113,23 @@ describe('CodexRecentProjectsSourceAdapter', () => { logger, }); - await expect(adapter.list()).resolves.toEqual([ - expect.objectContaining({ - identity: 'repo:headless', - displayName: 'headless', - primaryPath: '/Users/belief/dev/projects/headless', - providerIds: ['codex'], - sourceKind: 'codex', - openTarget: { - type: 'synthetic-path', - path: '/Users/belief/dev/projects/headless', - }, - branchName: 'main', - }), - ]); + await expect(adapter.list()).resolves.toEqual({ + candidates: [ + expect.objectContaining({ + identity: 'repo:headless', + displayName: 'headless', + primaryPath: '/Users/belief/dev/projects/headless', + providerIds: ['codex'], + sourceKind: 'codex', + openTarget: { + type: 'synthetic-path', + path: '/Users/belief/dev/projects/headless', + }, + branchName: 'main', + }), + ], + degraded: true, + }); expect(appServerClient.listRecentThreads).toHaveBeenCalledTimes(1); expect(appServerClient.listRecentLiveThreads).toHaveBeenCalledTimes(1); @@ -153,7 +159,10 @@ describe('CodexRecentProjectsSourceAdapter', () => { logger, }); - await expect(adapter.list()).resolves.toEqual([]); + await expect(adapter.list()).resolves.toEqual({ + candidates: [], + degraded: true, + }); expect(appServerClient.listRecentLiveThreads).not.toHaveBeenCalled(); }); }); diff --git a/test/features/recent-projects/renderer/utils/recentProjectsClientCache.test.ts b/test/features/recent-projects/renderer/utils/recentProjectsClientCache.test.ts index 7f9acf74..d23e49a9 100644 --- a/test/features/recent-projects/renderer/utils/recentProjectsClientCache.test.ts +++ b/test/features/recent-projects/renderer/utils/recentProjectsClientCache.test.ts @@ -6,7 +6,10 @@ import { loadRecentProjectsWithClientCache, } from '@features/recent-projects/renderer/utils/recentProjectsClientCache'; -import type { DashboardRecentProject } from '@features/recent-projects/contracts'; +import type { + DashboardRecentProject, + DashboardRecentProjectsPayload, +} from '@features/recent-projects/contracts'; const project = (id: string): DashboardRecentProject => ({ id, @@ -22,6 +25,15 @@ const project = (id: string): DashboardRecentProject => ({ }, }); +const payload = ( + id: string, + overrides: Partial = {} +): DashboardRecentProjectsPayload => ({ + projects: [project(id)], + degraded: false, + ...overrides, +}); + describe('recentProjectsClientCache', () => { afterEach(() => { __resetRecentProjectsClientCacheForTests(); @@ -30,13 +42,13 @@ describe('recentProjectsClientCache', () => { }); it('returns cached projects while the client cache is fresh', async () => { - const loader = vi.fn().mockResolvedValue([project('alpha')]); + const loader = vi.fn().mockResolvedValue(payload('alpha')); - await expect(loadRecentProjectsWithClientCache(loader)).resolves.toEqual([project('alpha')]); - await expect(loadRecentProjectsWithClientCache(loader)).resolves.toEqual([project('alpha')]); + await expect(loadRecentProjectsWithClientCache(loader)).resolves.toEqual(payload('alpha')); + await expect(loadRecentProjectsWithClientCache(loader)).resolves.toEqual(payload('alpha')); expect(loader).toHaveBeenCalledTimes(1); - expect(getRecentProjectsClientSnapshot()?.projects).toEqual([project('alpha')]); + expect(getRecentProjectsClientSnapshot()?.payload).toEqual(payload('alpha')); }); it('revalidates stale cache without dropping the previous snapshot', async () => { @@ -44,38 +56,38 @@ describe('recentProjectsClientCache', () => { vi.setSystemTime(new Date('2026-04-14T12:00:00.000Z')); const loader = vi - .fn<() => Promise>() - .mockResolvedValueOnce([project('alpha')]) - .mockResolvedValueOnce([project('beta')]); + .fn<() => Promise>() + .mockResolvedValueOnce(payload('alpha')) + .mockResolvedValueOnce(payload('beta')); await loadRecentProjectsWithClientCache(loader); vi.setSystemTime(new Date('2026-04-14T12:00:16.000Z')); expect(getRecentProjectsClientSnapshot()).toMatchObject({ - projects: [project('alpha')], + payload: payload('alpha'), isStale: true, }); - await expect(loadRecentProjectsWithClientCache(loader, { force: true })).resolves.toEqual([ - project('beta'), - ]); + await expect(loadRecentProjectsWithClientCache(loader, { force: true })).resolves.toEqual( + payload('beta') + ); expect(loader).toHaveBeenCalledTimes(2); expect(getRecentProjectsClientSnapshot()).toMatchObject({ - projects: [project('beta')], + payload: payload('beta'), isStale: false, }); }); it('deduplicates concurrent client refreshes', async () => { const resolveLoaderRef: { - current: ((projects: DashboardRecentProject[]) => void) | null; + current: ((payload: DashboardRecentProjectsPayload) => void) | null; } = { current: null, }; const loader = vi.fn( () => - new Promise((resolve) => { + new Promise((resolve) => { resolveLoaderRef.current = resolve; }) ); @@ -85,9 +97,41 @@ describe('recentProjectsClientCache', () => { expect(loader).toHaveBeenCalledTimes(1); - resolveLoaderRef.current?.([project('alpha')]); + resolveLoaderRef.current?.(payload('alpha')); - await expect(first).resolves.toEqual([project('alpha')]); - await expect(second).resolves.toEqual([project('alpha')]); + await expect(first).resolves.toEqual(payload('alpha')); + await expect(second).resolves.toEqual(payload('alpha')); + }); + + it('marks degraded payload snapshots stale faster than healthy payloads', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-14T12:00:00.000Z')); + + const loader = vi + .fn<() => Promise>() + .mockResolvedValueOnce(payload('alpha', { degraded: true })); + + await expect(loadRecentProjectsWithClientCache(loader)).resolves.toEqual( + payload('alpha', { degraded: true }) + ); + + vi.setSystemTime(new Date('2026-04-14T12:00:01.000Z')); + expect(getRecentProjectsClientSnapshot()).toMatchObject({ + payload: payload('alpha', { degraded: true }), + isStale: false, + }); + + vi.setSystemTime(new Date('2026-04-14T12:00:02.000Z')); + expect(getRecentProjectsClientSnapshot()).toMatchObject({ + payload: payload('alpha', { degraded: true }), + isStale: true, + }); + }); + + it('normalizes legacy array responses from the loader during mixed-version dev reloads', async () => { + const loader = vi.fn<() => Promise>().mockResolvedValue([project('alpha')]); + + await expect(loadRecentProjectsWithClientCache(loader)).resolves.toEqual(payload('alpha')); + expect(getRecentProjectsClientSnapshot()?.payload).toEqual(payload('alpha')); }); }); From 19463edfc9e558c980cf5bcdc34831cc190b54f3 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 13:40:21 +0300 Subject: [PATCH 046/121] fix(agent-graph): center launch stepper hud --- packages/agent-graph/src/ui/GraphControls.tsx | 10 +-- .../renderer/ui/GraphProvisioningHud.tsx | 69 ++++--------------- .../agent-graph/GraphProvisioningHud.test.ts | 2 +- 3 files changed, 18 insertions(+), 63 deletions(-) diff --git a/packages/agent-graph/src/ui/GraphControls.tsx b/packages/agent-graph/src/ui/GraphControls.tsx index e933c9c1..a3bc581d 100644 --- a/packages/agent-graph/src/ui/GraphControls.tsx +++ b/packages/agent-graph/src/ui/GraphControls.tsx @@ -107,8 +107,8 @@ export function GraphControls({ return ( <> -
-
+
+
{onToggleSidebar ? (
-
+
{topToolbarContent ? ( -
+
{topToolbarContent}
) : null}
-
+
, - iconClassName: 'text-red-400', + border: 'border-red-400/35 bg-[rgba(26,10,16,0.9)]', }; case 'warning': return { - border: 'border-amber-400/35 bg-[rgba(31,18,8,0.92)]', - badge: 'border-amber-500/30 text-amber-200', - icon: , - iconClassName: 'text-amber-400', + border: 'border-amber-400/35 bg-[rgba(31,18,8,0.9)]', }; case 'success': return { - border: 'border-emerald-400/35 bg-[rgba(8,24,18,0.92)]', - badge: 'border-emerald-500/30 text-emerald-200', - icon: , - iconClassName: 'text-emerald-400', + border: 'border-emerald-400/35 bg-[rgba(8,24,18,0.9)]', }; default: return { - border: 'border-cyan-400/25 bg-[rgba(8,14,26,0.92)]', - badge: 'border-cyan-500/20 text-cyan-200', - icon: , - iconClassName: 'text-cyan-300', + border: 'border-cyan-400/25 bg-[rgba(8,14,26,0.9)]', }; } } @@ -109,14 +92,10 @@ export const GraphProvisioningHud = ({ } }, [presentation]); - const compactLabel = useMemo(() => { - if (!presentation?.compactDetail) { - return null; - } - return presentation.compactDetail.length > 54 - ? `${presentation.compactDetail.slice(0, 54)}...` - : presentation.compactDetail; - }, [presentation?.compactDetail]); + const ariaLabel = useMemo(() => { + const parts = [presentation?.compactTitle, presentation?.compactDetail].filter(Boolean); + return parts.join(' - ') || 'Open launch details'; + }, [presentation?.compactDetail, presentation?.compactTitle]); if (!shouldRender || !presentation || !tone) { return null; @@ -131,44 +110,20 @@ export const GraphProvisioningHud = ({ tone.border )} onClick={() => setDetailsOpen(true)} - aria-label="Open launch details" + aria-label={ariaLabel} > -
- {tone.icon} -
-
-
- {presentation.compactTitle} -
- - {presentation.isFailed - ? 'Issue' - : presentation.hasMembersStillJoining - ? 'Joining' - : presentation.isActive - ? 'Live' - : 'Ready'} - -
- {compactLabel ? ( -
- {compactLabel} -
- ) : null} -
-
-
+ {ariaLabel} diff --git a/test/renderer/features/agent-graph/GraphProvisioningHud.test.ts b/test/renderer/features/agent-graph/GraphProvisioningHud.test.ts index 383f7951..476f9f47 100644 --- a/test/renderer/features/agent-graph/GraphProvisioningHud.test.ts +++ b/test/renderer/features/agent-graph/GraphProvisioningHud.test.ts @@ -118,7 +118,7 @@ describe('GraphProvisioningHud', () => { await Promise.resolve(); }); - const openButton = host.querySelector('button[aria-label="Open launch details"]'); + const openButton = host.querySelector('button[aria-label]'); expect(openButton).not.toBeNull(); await act(async () => { From 0b97cc0794cff683c876939e9ec7089059ae2565 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 14:57:42 +0300 Subject: [PATCH 047/121] fix(agent-graph): stabilize startup slots and launch hud --- .../renderer/adapters/TeamGraphAdapter.ts | 54 ++-- .../renderer/hooks/useTeamGraphAdapter.ts | 45 +++- .../renderer/ui/GraphProvisioningHud.tsx | 39 +-- src/renderer/store/slices/teamSlice.ts | 233 ++++++++++++------ src/shared/utils/teamGraphDefaultLayout.ts | 97 ++++++++ .../agent-graph/TeamGraphAdapter.test.ts | 59 +++++ test/renderer/store/teamSlice.test.ts | 80 +++++- 7 files changed, 451 insertions(+), 156 deletions(-) create mode 100644 src/shared/utils/teamGraphDefaultLayout.ts diff --git a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts index 86b53c9f..9c9233b7 100644 --- a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts +++ b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts @@ -23,6 +23,7 @@ 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, @@ -247,10 +248,11 @@ export class TeamGraphAdapter { const visibleMemberByStableOwnerId = new Map( visibleMembers.map((member) => [getGraphStableOwnerId(member), member] as const) ); - const assignedStableOwnerIds = new Set(Object.keys(slotAssignments ?? {})); - const configStableOwnerIds = new Set( - (data.config.members ?? []).map((member) => getGraphStableOwnerId(member)) + 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) { @@ -264,44 +266,26 @@ export class TeamGraphAdapter { ownerOrder.push(nodeId); }; - const assignedVisibleMembersOutsideConfig = visibleMembers - .filter((member) => { - const stableOwnerId = getGraphStableOwnerId(member); - return ( - assignedStableOwnerIds.has(stableOwnerId) && !configStableOwnerIds.has(stableOwnerId) - ); - }) - .toSorted((left, right) => - getGraphStableOwnerId(left).localeCompare(getGraphStableOwnerId(right)) - ); - - for (const configMember of data.config.members ?? []) { - const stableOwnerId = getGraphStableOwnerId(configMember); + for (const stableOwnerId of canonicalVisibleOwnerIds) { + const visibleMember = visibleMemberByStableOwnerId.get(stableOwnerId); + if (!visibleMember) { + continue; + } if (!assignedStableOwnerIds.has(stableOwnerId)) { continue; } - pushMember(visibleMemberByStableOwnerId.get(stableOwnerId)); - } - - for (const member of assignedVisibleMembersOutsideConfig) { - pushMember(member); - } - - for (const configMember of data.config.members ?? []) { - const stableOwnerId = getGraphStableOwnerId(configMember); - if (assignedStableOwnerIds.has(stableOwnerId)) { - continue; - } - const visibleMember = visibleMemberByStableOwnerId.get(stableOwnerId); pushMember(visibleMember); } - const remainingMembers = visibleMembers.toSorted((left, right) => - getGraphStableOwnerId(left).localeCompare(getGraphStableOwnerId(right)) - ); - - for (const member of remainingMembers) { - pushMember(member); + for (const stableOwnerId of canonicalVisibleOwnerIds) { + const visibleMember = visibleMemberByStableOwnerId.get(stableOwnerId); + if (!visibleMember) { + continue; + } + if (assignedStableOwnerIds.has(stableOwnerId)) { + continue; + } + pushMember(visibleMember); } const normalizedAssignments: Record = {}; diff --git a/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts b/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts index 7f33931e..42545bb3 100644 --- a/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts +++ b/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts @@ -3,17 +3,16 @@ * Thin wrapper — instantiates the class adapter and calls adapt() with store data. */ -import { useEffect, 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 { - getDefaultTeamGraphSlotAssignmentsForMembers, getCurrentProvisioningProgressForTeam, - hasAppliedDefaultTeamGraphSlotAssignments, isTeamGraphSlotPersistenceDisabled, selectTeamDataForName, } from '@renderer/store/slices/teamSlice'; +import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout'; import { useShallow } from 'zustand/react/shallow'; import { TeamGraphAdapter } from '../adapters/TeamGraphAdapter'; @@ -35,6 +34,7 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { provisioningProgress, memberSpawnSnapshot, slotAssignments, + graphLayoutSession, ensureTeamGraphSlotAssignments, } = useStore( useShallow((s) => ({ @@ -49,6 +49,7 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { 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, })) ); @@ -72,18 +73,44 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { if (!isTeamGraphSlotPersistenceDisabled()) { return slotAssignments; } - if (hasAppliedDefaultTeamGraphSlotAssignments(teamName)) { + if (graphLayoutSession?.mode === 'manual') { return slotAssignments; } - const defaults = getDefaultTeamGraphSlotAssignmentsForMembers(teamData.members); - return Object.keys(defaults).length === 0 ? undefined : defaults; - }, [slotAssignments, teamData, teamName]); + 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]); - useEffect(() => { + useLayoutEffect(() => { if (!teamName || !teamData) { return; } - ensureTeamGraphSlotAssignments(teamName, teamData.members); + ensureTeamGraphSlotAssignments(teamName, teamData.members, teamData.config.members ?? []); }, [ensureTeamGraphSlotAssignments, teamData, teamName]); return useMemo( diff --git a/src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx b/src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx index 1fe09548..06dc290a 100644 --- a/src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx +++ b/src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx @@ -11,7 +11,6 @@ import { DialogHeader, DialogTitle, } from '@renderer/components/ui/dialog'; -import { cn } from '@renderer/lib/utils'; import type { TeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation'; import type { CSSProperties } from 'react'; @@ -38,29 +37,6 @@ function shouldRenderLaunchHud(presentation: TeamProvisioningPresentation | null return presentation != null; } -function getToneClasses(tone: TeamProvisioningPresentation['compactTone']): { - border: string; -} { - switch (tone) { - case 'error': - return { - border: 'border-red-400/35 bg-[rgba(26,10,16,0.9)]', - }; - case 'warning': - return { - border: 'border-amber-400/35 bg-[rgba(31,18,8,0.9)]', - }; - case 'success': - return { - border: 'border-emerald-400/35 bg-[rgba(8,24,18,0.9)]', - }; - default: - return { - border: 'border-cyan-400/25 bg-[rgba(8,14,26,0.9)]', - }; - } -} - export interface GraphProvisioningHudProps { teamName: string; enabled?: boolean; @@ -74,7 +50,6 @@ export const GraphProvisioningHud = ({ const lastActiveStepRef = useRef(-1); const [detailsOpen, setDetailsOpen] = useState(false); const shouldRender = enabled && shouldRenderLaunchHud(presentation); - const tone = presentation ? getToneClasses(presentation.compactTone) : null; const errorStepIndex = presentation?.isFailed ? lastActiveStepRef.current >= 0 ? lastActiveStepRef.current @@ -97,7 +72,7 @@ export const GraphProvisioningHud = ({ return parts.join(' - ') || 'Open launch details'; }, [presentation?.compactDetail, presentation?.compactTitle]); - if (!shouldRender || !presentation || !tone) { + if (!shouldRender || !presentation) { return null; } @@ -105,22 +80,16 @@ export const GraphProvisioningHud = ({ <>
) : null} - {canCreate && launchTeam && prepareState === 'failed' ? ( -
-
- -
-

- CLI environment is not available — launch is blocked -

-

- {prepareMessage ?? 'Failed to prepare environment'} -

- {!shouldHideProvisioningProviderStatusList(prepareChecks, prepareMessage) ? ( - - ) : null} - {prepareWarnings.length > 0 && prepareChecks.length === 0 ? ( -
- {prepareWarnings.map((warning) => ( -

- {warning} -

- ))} -
- ) : null} -

- {getProvisioningFailureHint(prepareMessage, prepareChecks)} -

-
-
-
- ) : null} - {!canCreate ? (

) : null} + + {canCreate && launchTeam && prepareState === 'failed' ? ( +

+
+ +
+

+ CLI environment is not available - launch is blocked +

+

+ {prepareMessage ?? 'Failed to prepare environment'} +

+

+ Pre-flight check to catch errors before launch +

+
+
+ {!shouldHideProvisioningProviderStatusList(prepareChecks, prepareMessage) ? ( + + ) : null} + {prepareWarnings.length > 0 && prepareChecks.length === 0 ? ( +
+ {prepareWarnings.map((warning) => ( +

+ {warning} +

+ ))} +
+ ) : null} +

+ {getProvisioningFailureHint(prepareMessage, prepareChecks)} +

+
+ ) : null}
diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index dcca38bb..16d8993b 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -43,8 +43,12 @@ import { } from '@renderer/utils/geminiUiFreeze'; import { normalizePath } from '@renderer/utils/pathNormalize'; import { nameColorSet } from '@renderer/utils/projectColor'; -import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability'; +import { + getTeamModelSelectionError, + normalizeTeamModelForUi, +} from '@renderer/utils/teamModelAvailability'; import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog'; +import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection'; import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import { AlertTriangle, @@ -66,15 +70,21 @@ import { resolveLaunchDialogPrefill } from './launchDialogPrefill'; import { OptionalSettingsSection } from './OptionalSettingsSection'; import { ProjectPathSelector } from './ProjectPathSelector'; import { - createInitialProviderChecks, failIncompleteProviderChecks, getProvisioningFailureHint, + getPrimaryProvisioningFailureDetail, getProvisioningProviderBackendSummary, type ProvisioningProviderCheck, ProvisioningProviderStatusList, shouldHideProvisioningProviderStatusList, updateProviderCheck, } from './ProvisioningProviderStatusList'; +import { getProvisioningModelIssue } from './provisioningModelIssues'; +import { + getProviderPrepareCachedSnapshot, + runProviderPrepareDiagnostics, + type ProviderPrepareDiagnosticsModelResult, +} from './providerPrepareDiagnostics'; import { computeEffectiveTeamModel, formatTeamModelSummary, @@ -93,10 +103,35 @@ import type { ScheduleLaunchConfig, TeamLaunchRequest, TeamProviderId, - TeamProvisioningPrepareResult, UpdateSchedulePatch, } from '@shared/types'; +function buildPrepareModelCacheKey( + cwd: string, + providerId: TeamProviderId, + backendSummary: string | null | undefined +): string { + return `${cwd}::${providerId}::${backendSummary ?? ''}`; +} + +function alignProvisioningChecks( + existingChecks: ProvisioningProviderCheck[], + providerIds: TeamProviderId[] +): ProvisioningProviderCheck[] { + const existingByProviderId = new Map( + existingChecks.map((check) => [check.providerId, check] as const) + ); + return providerIds.map( + (providerId) => + existingByProviderId.get(providerId) ?? { + providerId, + status: 'pending', + backendSummary: null, + details: [], + } + ); +} + // ============================================================================= // Props — discriminated union // ============================================================================= @@ -341,6 +376,30 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen ); return new Map(entries); }, [cliStatus?.providers]); + const runtimeBackendSummaryByProviderRef = useRef(runtimeBackendSummaryByProvider); + const prepareChecksRef = useRef([]); + const prepareModelResultsCacheRef = useRef( + new Map>() + ); + + useEffect(() => { + runtimeBackendSummaryByProviderRef.current = runtimeBackendSummaryByProvider; + }, [runtimeBackendSummaryByProvider]); + useEffect(() => { + prepareChecksRef.current = prepareChecks; + }, [prepareChecks]); + useEffect(() => { + if (!open) { + prepareModelResultsCacheRef.current.clear(); + } + }, [open]); + const runtimeProviderStatusById = useMemo( + () => + new Map( + (cliStatus?.providers ?? []).map((provider) => [provider.providerId, provider] as const) + ), + [cliStatus?.providers] + ); useEffect(() => { if (multimodelEnabled) { @@ -629,6 +688,51 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen () => computeEffectiveTeamModel(selectedModel, limitContext, selectedProviderId) ?? '', [selectedModel, limitContext, selectedProviderId] ); + const selectedModelChecksByProvider = useMemo(() => { + const modelsByProvider = new Map(); + const defaultSelectionByProvider = new Map(); + const addModel = (providerId: TeamProviderId, model: string | undefined): void => { + const trimmed = model?.trim() ?? ''; + if (!trimmed) { + return; + } + const existing = modelsByProvider.get(providerId) ?? []; + if (!existing.includes(trimmed)) { + modelsByProvider.set(providerId, [...existing, trimmed]); + } + }; + const addDefaultSelection = (providerId: TeamProviderId): void => { + if ( + providerId === 'codex' || + providerId === 'gemini' || + (providerId === 'anthropic' && selectedProviderId === 'anthropic') + ) { + defaultSelectionByProvider.set(providerId, true); + } + }; + + if (selectedModel.trim()) { + addModel(selectedProviderId, effectiveLeadRuntimeModel); + } else { + addDefaultSelection(selectedProviderId); + } + for (const member of effectiveMemberDrafts) { + if (member.removedAt) { + continue; + } + const providerId = normalizeOptionalTeamProviderId(member.providerId) ?? selectedProviderId; + if (member.model?.trim()) { + addModel(providerId, member.model); + } else { + addDefaultSelection(providerId); + } + } + for (const providerId of defaultSelectionByProvider.keys()) { + addModel(providerId, DEFAULT_PROVIDER_MODEL_SELECTION); + } + + return modelsByProvider; + }, [effectiveLeadRuntimeModel, effectiveMemberDrafts, selectedModel, selectedProviderId]); const runtimeChangeNotes = useMemo(() => { if (!isLaunch) { @@ -811,61 +915,95 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen let cancelled = false; const requestSeq = ++prepareRequestSeqRef.current; + const initialChecks = alignProvisioningChecks( + prepareChecksRef.current, + selectedMemberProviders + ); setPrepareState('loading'); setPrepareMessage('Checking selected providers...'); setPrepareWarnings([]); - setPrepareChecks(createInitialProviderChecks(selectedMemberProviders)); + setPrepareChecks(initialChecks); void (async () => { - let checks = createInitialProviderChecks(selectedMemberProviders); + let checks = initialChecks; let anyFailure = false; let anyNotes = false; const collectedWarnings: string[] = []; try { for (const providerId of selectedMemberProviders) { + const selectedModelChecks = selectedModelChecksByProvider.get(providerId) ?? []; + const backendSummary = runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null; + const cacheKey = buildPrepareModelCacheKey(effectiveCwd, providerId, backendSummary); + const cachedModelResultsById = prepareModelResultsCacheRef.current.get(cacheKey) ?? {}; + const cachedSnapshot = getProviderPrepareCachedSnapshot({ + providerId, + selectedModelIds: selectedModelChecks, + cachedModelResultsById, + }); checks = updateProviderCheck(checks, providerId, { - status: 'checking', - backendSummary: runtimeBackendSummaryByProvider.get(providerId) ?? null, - details: [], + status: selectedModelChecks.length > 0 ? cachedSnapshot.status : 'checking', + backendSummary, + details: cachedSnapshot.details, }); if (!cancelled && prepareRequestSeqRef.current === requestSeq) { setPrepareChecks(checks); - setPrepareMessage(`Checking ${getProviderLabel(providerId)} runtime...`); + setPrepareMessage( + selectedModelChecks.length > 0 + ? `Checking ${getProviderLabel(providerId)} runtime and selected model checks ${cachedSnapshot.completedCount}/${cachedSnapshot.totalCount}...` + : `Checking ${getProviderLabel(providerId)} runtime...` + ); } - const prepResult: TeamProvisioningPrepareResult = await api.teams.prepareProvisioning( - effectiveCwd, + const prepResult = await runProviderPrepareDiagnostics({ + cwd: effectiveCwd, providerId, - [providerId] - ); - const detailLines = [ - ...(prepResult.warnings ?? []).filter(Boolean), - ...(!prepResult.ready && prepResult.message ? [prepResult.message] : []), - ]; - if (prepResult.warnings?.length) { + selectedModelIds: selectedModelChecks, + prepareProvisioning: api.teams.prepareProvisioning, + limitContext, + cachedModelResultsById, + onModelProgress: ({ details, completedCount, totalCount }) => { + checks = updateProviderCheck(checks, providerId, { + status: 'checking', + backendSummary, + details, + }); + if (!cancelled && prepareRequestSeqRef.current === requestSeq) { + setPrepareChecks(checks); + setPrepareMessage( + `Checking ${getProviderLabel(providerId)} runtime and selected model checks ${completedCount}/${totalCount}...` + ); + } + }, + }); + if (prepResult.warnings.length > 0) { anyNotes = true; collectedWarnings.push( ...prepResult.warnings.map((warning) => `${getProviderLabel(providerId)}: ${warning}`) ); } - if (!prepResult.ready) { + if (prepResult.status === 'failed') { anyFailure = true; + } else if (prepResult.status === 'notes') { + anyNotes = true; } + prepareModelResultsCacheRef.current.set(cacheKey, prepResult.modelResultsById); checks = updateProviderCheck(checks, providerId, { - status: !prepResult.ready ? 'failed' : detailLines.length > 0 ? 'notes' : 'ready', - backendSummary: runtimeBackendSummaryByProvider.get(providerId) ?? null, - details: detailLines, + status: prepResult.status, + backendSummary, + details: prepResult.details, }); if (!cancelled && prepareRequestSeqRef.current === requestSeq) { setPrepareChecks(checks); } } if (cancelled || prepareRequestSeqRef.current !== requestSeq) return; + const failureMessage = + getPrimaryProvisioningFailureDetail(checks) ?? 'Some selected providers need attention.'; setPrepareState(anyFailure ? 'failed' : 'ready'); setPrepareMessage( anyFailure - ? 'Some selected providers need attention.' + ? failureMessage : anyNotes ? 'Selected providers are ready with notes.' : 'Selected providers are ready.' @@ -891,7 +1029,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen effectiveCwd, selectedProviderId, selectedMemberProviders, - runtimeBackendSummaryByProvider, + selectedModelChecksByProvider, ]); // --------------------------------------------------------------------------- @@ -1066,6 +1204,84 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen } return errors; }, [effectiveCwd, isSchedule, effectiveTeamName, promptDraft.value, cronExpression]); + const modelValidationError = useMemo(() => { + const leadError = getTeamModelSelectionError( + selectedProviderId, + selectedModel, + runtimeProviderStatusById.get(selectedProviderId) + ); + if (leadError) { + return leadError; + } + + if (!isLaunch) { + return null; + } + + for (const member of effectiveMemberDrafts) { + if (member.removedAt) { + continue; + } + + const providerId = normalizeOptionalTeamProviderId(member.providerId) ?? selectedProviderId; + const memberError = getTeamModelSelectionError( + providerId, + member.model, + runtimeProviderStatusById.get(providerId) + ); + if (!memberError) { + continue; + } + + const memberName = member.name.trim(); + return memberName ? `${memberName}: ${memberError}` : memberError; + } + + return null; + }, [ + effectiveMemberDrafts, + isLaunch, + runtimeProviderStatusById, + selectedModel, + selectedProviderId, + ]); + const leadModelIssueText = useMemo(() => { + const issue = getProvisioningModelIssue( + prepareChecks, + selectedProviderId, + effectiveLeadRuntimeModel || selectedModel + ); + return issue?.reason ?? issue?.detail ?? null; + }, [effectiveLeadRuntimeModel, prepareChecks, selectedModel, selectedProviderId]); + const memberModelIssueById = useMemo(() => { + const next: Record = {}; + if (!isLaunch) { + return next; + } + for (const member of effectiveMemberDrafts) { + if (member.removedAt) { + continue; + } + if (syncModelsWithLead && leadModelIssueText) { + next[member.id] = leadModelIssueText; + continue; + } + const providerId = normalizeOptionalTeamProviderId(member.providerId) ?? selectedProviderId; + const issue = getProvisioningModelIssue(prepareChecks, providerId, member.model); + const issueText = issue?.reason ?? issue?.detail ?? null; + if (issueText) { + next[member.id] = issueText; + } + } + return next; + }, [ + effectiveMemberDrafts, + isLaunch, + leadModelIssueText, + prepareChecks, + selectedProviderId, + syncModelsWithLead, + ]); const hasInvalidLaunchMemberNames = useMemo( () => isLaunch && @@ -1087,7 +1303,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen // --------------------------------------------------------------------------- const provisioningError = isLaunch ? props.provisioningError : null; - const activeError = localError ?? provisioningError; + const activeError = localError ?? modelValidationError ?? provisioningError; const launchInFlight = useStore((s) => isLaunch && effectiveTeamName ? isTeamProvisioningActive(s, effectiveTeamName) : false ); @@ -1119,6 +1335,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen setLocalError(validationErrors[0]); return; } + if (modelValidationError) { + setLocalError(modelValidationError); + return; + } if (isLaunch && !effectiveCwd) { setLocalError('Select working directory (cwd)'); return; @@ -1228,9 +1448,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen ? isSubmitting || launchInFlight || validationErrors.length > 0 || + !!modelValidationError || hasInvalidLaunchMemberNames || hasDuplicateLaunchMemberNames - : isSubmitting || validationErrors.length > 0; + : isSubmitting || validationErrors.length > 0 || !!modelValidationError; // --------------------------------------------------------------------------- // Dynamic labels @@ -1318,63 +1539,6 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
) : null} - {/* Launch-only: CLI env failed */} - {isLaunch && prepareState === 'failed' ? ( -
-
- -
-

- CLI environment is not available — launch is blocked -

-

- {prepareMessage ?? 'Failed to prepare environment'} -

- {!shouldHideProvisioningProviderStatusList(prepareChecks, prepareMessage) ? ( - - ) : null} - {prepareWarnings.length > 0 && prepareChecks.length === 0 ? ( -
- {prepareWarnings.map((warning) => ( -

- {warning} -

- ))} -
- ) : null} -
-

- {getProvisioningFailureHint(prepareMessage, prepareChecks)} -

- {(prepareMessage ?? '').toLowerCase().includes('spawn ') || - prepareChecks.some((check) => - check.details.some((detail) => detail.toLowerCase().includes('spawn ')) - ) ? ( - - ) : null} -
-
-
-
- ) : null} -
{/* ═══════════════════════════════════════════════════════════════════ Schedule-only: Team selector (standalone mode) @@ -1553,6 +1717,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen onSyncModelsWithTeammatesChange={setSyncModelsWithLead} leadWarningText={leadRuntimeWarningText} memberWarningById={memberRuntimeWarningById} + leadModelIssueText={leadModelIssueText} + memberModelIssueById={memberModelIssueById} softDeleteMembers disableGeminiOption={isGeminiUiFrozen()} /> @@ -1816,7 +1982,64 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
) : null} - {prepareState === 'failed' ?
: null} + {prepareState === 'failed' ? ( +
+
+ +
+

+ CLI environment is not available - launch is blocked +

+

+ {prepareMessage ?? 'Failed to prepare environment'} +

+

+ Pre-flight check to catch errors before launch +

+
+
+ {!shouldHideProvisioningProviderStatusList(prepareChecks, prepareMessage) ? ( + + ) : null} + {prepareWarnings.length > 0 && prepareChecks.length === 0 ? ( +
+ {prepareWarnings.map((warning) => ( +

+ {warning} +

+ ))} +
+ ) : null} +
+

+ {getProvisioningFailureHint(prepareMessage, prepareChecks)} +

+ {(prepareMessage ?? '').toLowerCase().includes('spawn ') || + prepareChecks.some((check) => + check.details.some((detail) => detail.toLowerCase().includes('spawn ')) + ) ? ( + + ) : null} +
+
+ ) : null}
) : null} diff --git a/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx index dcf79f8d..27bb8dfe 100644 --- a/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx +++ b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx @@ -84,6 +84,21 @@ export function failIncompleteProviderChecks( ); } +type ProvisioningDetailSummary = + | 'CLI binary missing' + | 'Working directory missing' + | 'CLI binary could not be started' + | 'CLI preflight did not complete' + | 'Authentication required' + | 'Runtime provider is not configured' + | 'CLI preflight failed' + | 'Selected model verified' + | 'Selected model unavailable' + | 'Selected model verification timed out' + | 'Selected model check failed' + | 'Ready with notes' + | 'Needs attention'; + function getStatusLabel(status: ProvisioningProviderCheckStatus): string { switch (status) { case 'checking': @@ -100,7 +115,10 @@ function getStatusLabel(status: ProvisioningProviderCheckStatus): string { } } -function summarizeDetail(detail: string, status: ProvisioningProviderCheckStatus): string | null { +function summarizeDetail( + detail: string, + status: ProvisioningProviderCheckStatus +): ProvisioningDetailSummary | null { const lower = detail.toLowerCase(); if (lower.includes('spawn ') && lower.includes(' enoent')) { @@ -132,6 +150,34 @@ function summarizeDetail(detail: string, status: ProvisioningProviderCheckStatus if (lower.includes('claude cli preflight check failed')) { return 'CLI preflight failed'; } + if (lower.includes('selected model') && lower.includes('verified for launch')) { + return 'Selected model verified'; + } + if (lower.includes('selected model') && lower.includes('is unavailable')) { + return 'Selected model unavailable'; + } + if ( + lower.includes('selected model') && + lower.includes('could not be verified') && + lower.includes('timed out') + ) { + return 'Selected model verification timed out'; + } + if (lower.includes('selected model') && lower.includes('could not be verified')) { + return 'Selected model check failed'; + } + if (lower.includes(' - verified')) { + return 'Selected model verified'; + } + if (lower.includes(' - unavailable -')) { + return 'Selected model unavailable'; + } + if (lower.includes('timed out')) { + return 'Selected model verification timed out'; + } + if (lower.includes(' - check failed -')) { + return 'Selected model check failed'; + } if (status === 'notes') { return 'Ready with notes'; @@ -142,13 +188,173 @@ function summarizeDetail(detail: string, status: ProvisioningProviderCheckStatus return null; } +function getModelDetailSummary(details: string[]): string | null { + let verifiedCount = 0; + let unavailableCount = 0; + let timedOutCount = 0; + let checkFailedCount = 0; + let checkingCount = 0; + + for (const detail of details) { + const lower = detail.toLowerCase(); + if (lower.includes(' - verified')) { + verifiedCount += 1; + continue; + } + if (lower.includes(' - unavailable -')) { + unavailableCount += 1; + continue; + } + if (lower.includes('timed out')) { + timedOutCount += 1; + continue; + } + if (lower.includes(' - check failed -')) { + checkFailedCount += 1; + continue; + } + if (lower.includes(' - checking...')) { + checkingCount += 1; + } + } + + const parts: string[] = []; + if (unavailableCount > 0) { + parts.push(`${unavailableCount} model${unavailableCount === 1 ? '' : 's'} unavailable`); + } + if (checkFailedCount > 0) { + parts.push(`${checkFailedCount} model${checkFailedCount === 1 ? '' : 's'} check failed`); + } + if (timedOutCount > 0) { + parts.push(`${timedOutCount} model${timedOutCount === 1 ? '' : 's'} timed out`); + } + if (checkingCount > 0) { + parts.push(`${checkingCount} checking`); + } + if (verifiedCount > 0) { + parts.push(`${verifiedCount} verified`); + } + + return parts.length > 0 ? `Selected model checks - ${parts.join(', ')}` : null; +} + function getDisplayStatusText(check: ProvisioningProviderCheck): string { - const summary = check.details.find(Boolean) - ? summarizeDetail(check.details[0], check.status) - : null; + const modelSummary = getModelDetailSummary(check.details); + if (modelSummary) { + return modelSummary; + } + + const summarizedDetails = check.details + .map((detail) => summarizeDetail(detail, check.status)) + .filter((detail): detail is ProvisioningDetailSummary => Boolean(detail)); + + const summary = + check.status === 'failed' + ? (summarizedDetails.find( + (detail) => + detail === 'Selected model unavailable' || + detail === 'Selected model check failed' || + detail === 'Authentication required' || + detail === 'CLI preflight failed' || + detail === 'CLI binary could not be started' + ) ?? + summarizedDetails[0] ?? + null) + : (summarizedDetails[0] ?? null); return summary ?? getStatusLabel(check.status); } +function getDetailTone( + detail: string, + status: ProvisioningProviderCheckStatus +): 'success' | 'failure' | 'checking' | 'neutral' { + const summary = summarizeDetail(detail, status); + if (summary === 'Selected model verified') { + return 'success'; + } + if (summary === 'Selected model verification timed out') { + return 'neutral'; + } + if ( + summary === 'Selected model unavailable' || + summary === 'Selected model check failed' || + summary === 'CLI binary missing' || + summary === 'Working directory missing' || + summary === 'CLI binary could not be started' || + summary === 'CLI preflight did not complete' || + summary === 'Authentication required' || + summary === 'Runtime provider is not configured' || + summary === 'CLI preflight failed' || + summary === 'Needs attention' + ) { + return 'failure'; + } + if (detail.toLowerCase().includes(' - checking...')) { + return 'checking'; + } + return 'neutral'; +} + +function getDetailColorClass(detail: string, status: ProvisioningProviderCheckStatus): string { + switch (getDetailTone(detail, status)) { + case 'success': + return 'text-emerald-400'; + case 'failure': + return 'text-red-300'; + case 'checking': + return 'text-[var(--color-text-secondary)]'; + case 'neutral': + default: + return 'text-[var(--color-text-muted)]'; + } +} + +export function getPrimaryProvisioningFailureDetail( + checks: ProvisioningProviderCheck[] +): string | null { + for (const check of checks) { + if (check.status !== 'failed') { + continue; + } + + const unavailableDetail = check.details.find((detail) => + detail.toLowerCase().includes('selected model') && + detail.toLowerCase().includes('is unavailable') + ? true + : detail.toLowerCase().includes(' - unavailable -') + ); + if (unavailableDetail) { + return unavailableDetail; + } + } + + for (const check of checks) { + if (check.status !== 'failed') { + continue; + } + + const preferredFailure = check.details.find( + (detail) => getDetailTone(detail, check.status) === 'failure' + ); + if (preferredFailure) { + return preferredFailure; + } + + const nonSuccessDetail = check.details.find( + (detail) => getDetailTone(detail, check.status) !== 'success' + ); + if (nonSuccessDetail) { + return nonSuccessDetail; + } + + if (check.details.length > 0) { + return check.details[0]; + } + } + + return null; +} + export function shouldHideProvisioningProviderStatusList( checks: ProvisioningProviderCheck[], message: string | null | undefined @@ -236,7 +442,10 @@ export const ProvisioningProviderStatusList = ({ {visibleDetails.length > 0 ? (
{visibleDetails.map((detail) => ( -

+

{detail}

))} diff --git a/src/renderer/components/team/dialogs/TeamModelSelector.tsx b/src/renderer/components/team/dialogs/TeamModelSelector.tsx index c0175dbf..0e593d34 100644 --- a/src/renderer/components/team/dialogs/TeamModelSelector.tsx +++ b/src/renderer/components/team/dialogs/TeamModelSelector.tsx @@ -11,23 +11,26 @@ import { } from '@renderer/components/ui/tooltip'; import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; +import { getAnthropicDefaultTeamModel } from '@shared/utils/anthropicModelDefaults'; import { GEMINI_UI_DISABLED_BADGE_LABEL, GEMINI_UI_DISABLED_REASON, isGeminiUiFrozen, } from '@renderer/utils/geminiUiFreeze'; +import { + getAvailableTeamProviderModelOptions, + getTeamModelUiDisabledReason, + normalizeTeamModelForUi, + TEAM_MODEL_UI_DISABLED_BADGE_LABEL, +} from '@renderer/utils/teamModelAvailability'; import { doesTeamModelCarryProviderBrand, getProviderScopedTeamModelLabel, getTeamModelLabel as getCatalogTeamModelLabel, - getTeamModelUiDisabledReason, getTeamProviderLabel as getCatalogTeamProviderLabel, - getTeamProviderModelOptions, - normalizeTeamModelForUi, - TEAM_MODEL_UI_DISABLED_BADGE_LABEL, } from '@renderer/utils/teamModelCatalog'; import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext'; -import { Info } from 'lucide-react'; +import { AlertTriangle, Info } from 'lucide-react'; export { getProviderScopedTeamModelLabel } from '@renderer/utils/teamModelCatalog'; @@ -105,9 +108,9 @@ export function computeEffectiveTeamModel( } const base = extractProviderScopedBaseModel(selectedModel, providerId); - if (limitContext) return base; + if (limitContext) return base || getAnthropicDefaultTeamModel(true); if (base === 'haiku') return base; - return base ? `${base}[1m]` : 'opus[1m]'; + return base ? `${base}[1m]` : getAnthropicDefaultTeamModel(limitContext); } export interface TeamModelSelectorProps { @@ -117,6 +120,7 @@ export interface TeamModelSelectorProps { onValueChange: (value: string) => void; id?: string; disableGeminiOption?: boolean; + modelIssueReasonByValue?: Partial>; } export const TeamModelSelector: React.FC = ({ @@ -126,8 +130,10 @@ export const TeamModelSelector: React.FC = ({ onValueChange, id, disableGeminiOption = false, + modelIssueReasonByValue, }) => { const cliStatus = useStore((s) => s.cliStatus); + const cliStatusLoading = useStore((s) => s.cliStatusLoading); const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true); const multimodelAvailable = multimodelEnabled || cliStatus?.flavor === 'agent_teams_orchestrator'; @@ -135,7 +141,7 @@ export const TeamModelSelector: React.FC = ({ disableGeminiOption && isGeminiUiFrozen() && providerId === 'gemini' ? 'anthropic' : providerId; const defaultModelTooltip = useMemo(() => { if (effectiveProviderId === 'anthropic') { - return 'Default model from Claude CLI (/model).\nUses the runtime default for the selected provider.'; + return 'Uses the Claude team default model.\nResolves to Opus 1M, or Opus 200K when Limit context is enabled.'; } return 'Uses the runtime default for the selected provider.'; }, [effectiveProviderId]); @@ -181,13 +187,20 @@ export const TeamModelSelector: React.FC = ({ return statusBadge; }; - const runtimeModels = useMemo( + const runtimeProviderStatus = useMemo( () => - cliStatus?.providers.find((provider) => provider.providerId === effectiveProviderId) - ?.models ?? [], + cliStatus?.providers.find((provider) => provider.providerId === effectiveProviderId) ?? null, [cliStatus?.providers, effectiveProviderId] ); - const normalizedValue = normalizeTeamModelForUi(effectiveProviderId, value); + const shouldAwaitRuntimeModelList = + effectiveProviderId !== 'anthropic' && + (cliStatus == null || cliStatusLoading) && + runtimeProviderStatus == null; + const normalizedValue = normalizeTeamModelForUi( + effectiveProviderId, + value, + runtimeProviderStatus + ); useEffect(() => { if (normalizedValue !== value) { @@ -196,22 +209,11 @@ export const TeamModelSelector: React.FC = ({ }, [normalizedValue, onValueChange, value]); const modelOptions = useMemo(() => { - const fallback = getTeamProviderModelOptions(effectiveProviderId); - if (effectiveProviderId === 'anthropic' || runtimeModels.length === 0) { - return fallback.map((option) => ({ - ...option, - label: - option.value === '' - ? option.label - : getProviderScopedTeamModelLabel(effectiveProviderId, option.value), - })); + if (shouldAwaitRuntimeModelList) { + return [{ value: '', label: 'Default', badgeLabel: 'Default' }]; } - const dynamicOptions = runtimeModels.map((model) => ({ - value: model, - label: getProviderScopedTeamModelLabel(effectiveProviderId, model), - })); - return [{ value: '', label: 'Default' }, ...dynamicOptions]; - }, [effectiveProviderId, runtimeModels]); + return getAvailableTeamProviderModelOptions(effectiveProviderId, runtimeProviderStatus); + }, [effectiveProviderId, runtimeProviderStatus, shouldAwaitRuntimeModelList]); return (
@@ -292,6 +294,12 @@ export const TeamModelSelector: React.FC = ({ ) : null}
+ {shouldAwaitRuntimeModelList ? ( +

+ Explicit models load from the current runtime. Default remains available while the + list is syncing. +

+ ) : null}
= ({ (() => { const modelDisabledReason = getTeamModelUiDisabledReason( effectiveProviderId, - opt.value + opt.value, + runtimeProviderStatus ); - const modelSelectable = activeProviderSelectable && !modelDisabledReason; + const availabilityStatus = + opt.value === '' ? 'available' : (opt.availabilityStatus ?? 'available'); + const availabilityReason = + opt.value === '' ? null : (opt.availabilityReason ?? null); + const modelIssueReason = + opt.value === '' ? null : (modelIssueReasonByValue?.[opt.value] ?? null); + const hasModelIssue = Boolean(modelIssueReason); + const modelSelectable = + activeProviderSelectable && + !modelDisabledReason && + (opt.value === '' || + availabilityStatus == null || + availabilityStatus === 'available'); + const modelStatusMessage = + modelIssueReason ?? modelDisabledReason ?? availabilityReason ?? null; return (
@@ -130,6 +139,7 @@ export const LeadModelRow = ({ onValueChange={onModelChange} id="lead-model" disableGeminiOption={disableGeminiOption} + modelIssueReasonByValue={model.trim() ? { [model.trim()]: modelIssueText } : undefined} /> void; warningText?: string | null; disableGeminiOption?: boolean; + modelIssueText?: string | null; } export const MemberDraftRow = ({ @@ -87,6 +89,7 @@ export const MemberDraftRow = ({ onRestore, warningText, disableGeminiOption = false, + modelIssueText, }: MemberDraftRowProps): React.JSX.Element => { const { isLight } = useTheme(); const memberColorSet = getTeamColorSet( @@ -175,6 +178,7 @@ export const MemberDraftRow = ({ const modelTooltipText = forceInheritedModelSettings ? 'Provider, model, and effort are inherited from the lead while sync is enabled.' : modelLockReason; + const hasModelIssue = Boolean(modelIssueText); return (
setModelExpanded((prev) => !prev)} @@ -262,13 +270,21 @@ export const MemberDraftRow = ({ providerId={effectiveProviderId} className="size-3.5 shrink-0" /> - {modelButtonLabel} + {modelButtonLabel} + {hasModelIssue ? ( + + ) : null} - {modelTooltipText ? ( + {modelTooltipText || modelIssueText ? ( - {modelTooltipText} + {modelIssueText ?

{modelIssueText}

: null} + {modelTooltipText ? ( +

+ {modelTooltipText} +

+ ) : null}
) : null} @@ -355,6 +371,9 @@ export const MemberDraftRow = ({ }} id={`member-${member.id}-model`} disableGeminiOption={disableGeminiOption} + modelIssueReasonByValue={ + effectiveModel?.trim() ? { [effectiveModel.trim()]: modelIssueText } : undefined + } /> )} -
- -

- If this teammate uses a different provider than the lead, they will be started in a - separate process automatically. -

-
)}
diff --git a/src/renderer/components/team/members/MembersEditorSection.tsx b/src/renderer/components/team/members/MembersEditorSection.tsx index 70beadaa..93b95459 100644 --- a/src/renderer/components/team/members/MembersEditorSection.tsx +++ b/src/renderer/components/team/members/MembersEditorSection.tsx @@ -101,6 +101,7 @@ export interface MembersEditorSectionProps { softDeleteMembers?: boolean; memberWarningById?: Record; disableGeminiOption?: boolean; + memberModelIssueById?: Record; } export const MembersEditorSection = ({ @@ -128,6 +129,7 @@ export const MembersEditorSection = ({ softDeleteMembers = false, memberWarningById, disableGeminiOption = false, + memberModelIssueById, }: MembersEditorSectionProps): React.JSX.Element => { const [jsonEditorOpen, setJsonEditorOpen] = useState(false); const [jsonText, setJsonText] = useState(''); @@ -316,6 +318,7 @@ export const MembersEditorSection = ({ modelLockReason={modelLockReason} warningText={memberWarningById?.[member.id] ?? null} disableGeminiOption={disableGeminiOption} + modelIssueText={memberModelIssueById?.[member.id] ?? null} /> ))} {softDeleteMembers && removedMembers.length > 0 ? ( @@ -356,6 +359,7 @@ export const MembersEditorSection = ({ isRemoved warningText={null} disableGeminiOption={disableGeminiOption} + modelIssueText={null} /> ))}
diff --git a/src/renderer/components/team/members/TeamRosterEditorSection.tsx b/src/renderer/components/team/members/TeamRosterEditorSection.tsx index e02a2262..5b6480ba 100644 --- a/src/renderer/components/team/members/TeamRosterEditorSection.tsx +++ b/src/renderer/components/team/members/TeamRosterEditorSection.tsx @@ -44,6 +44,8 @@ interface TeamRosterEditorSectionProps { leadWarningText?: string | null; memberWarningById?: Record; disableGeminiOption?: boolean; + leadModelIssueText?: string | null; + memberModelIssueById?: Record; } export const TeamRosterEditorSection = ({ @@ -83,6 +85,8 @@ export const TeamRosterEditorSection = ({ leadWarningText, memberWarningById, disableGeminiOption = false, + leadModelIssueText, + memberModelIssueById, }: TeamRosterEditorSectionProps): React.JSX.Element => { return ( {headerTop} @@ -124,6 +129,7 @@ export const TeamRosterEditorSection = ({ onSyncModelsWithTeammatesChange={onSyncModelsWithTeammatesChange} warningText={leadWarningText} disableGeminiOption={disableGeminiOption} + modelIssueText={leadModelIssueText} /> {headerBottom}
diff --git a/src/renderer/hooks/useCliInstaller.ts b/src/renderer/hooks/useCliInstaller.ts index 3f601835..c62bf068 100644 --- a/src/renderer/hooks/useCliInstaller.ts +++ b/src/renderer/hooks/useCliInstaller.ts @@ -34,7 +34,7 @@ export function useCliInstaller(): { fetchCliStatus: () => Promise; fetchCliProviderStatus: ( providerId: CliProviderId, - options?: { silent?: boolean; epoch?: number } + options?: { silent?: boolean; epoch?: number; verifyModels?: boolean } ) => Promise; invalidateCliStatus: () => Promise; installCli: () => void; diff --git a/src/renderer/store/slices/cliInstallerSlice.ts b/src/renderer/store/slices/cliInstallerSlice.ts index 89d2a403..b1a56b2e 100644 --- a/src/renderer/store/slices/cliInstallerSlice.ts +++ b/src/renderer/store/slices/cliInstallerSlice.ts @@ -28,8 +28,10 @@ export function createLoadingMultimodelCliStatus(): CliInstallationStatus { authenticated: false, authMethod: null, verificationState: 'unknown' as const, + modelVerificationState: 'idle' as const, statusMessage: 'Checking...', models: [], + modelAvailability: [], canLoginFromUi: true, capabilities: { teamLaunch: false, @@ -89,14 +91,14 @@ export interface CliInstallerSlice { fetchCliStatus: () => Promise; fetchCliProviderStatus: ( providerId: CliProviderId, - options?: { silent?: boolean; epoch?: number } + options?: { silent?: boolean; epoch?: number; verifyModels?: boolean } ) => Promise; invalidateCliStatus: () => Promise; installCli: () => void; } let cliStatusInFlight: Promise | null = null; -const cliProviderStatusInFlight = new Map>(); +const cliProviderStatusInFlight = new Map>(); let cliStatusEpoch = 0; const cliProviderStatusSeq = new Map(); @@ -257,7 +259,9 @@ export const createCliInstallerSlice: StateCreator { const nextLoading = silent ? state.cliProviderStatusLoading @@ -343,11 +349,11 @@ export const createCliInstallerSlice: StateCreator; + +export type TeamRuntimeModelOption = TeamProviderModelOption & { + availabilityStatus?: CliProviderModelAvailabilityStatus | null; + availabilityReason?: string | null; +}; + +export interface TeamProviderModelVerificationCounts { + checkedCount: number; + totalCount: number; + verifying: boolean; +} + +export function getTeamModelUiDisabledReason( + providerId: SupportedProviderId | undefined, + model: string | undefined, + providerStatus?: TeamModelRuntimeProviderStatus | null +): string | null { + return getRuntimeAwareTeamModelUiDisabledReason(providerId, model, providerStatus); +} + +export function isTeamModelUiDisabled( + providerId: SupportedProviderId | undefined, + model: string | undefined, + providerStatus?: TeamModelRuntimeProviderStatus | null +): boolean { + return getTeamModelUiDisabledReason(providerId, model, providerStatus) !== null; +} + +function getFallbackTeamProviderModels(providerId: SupportedProviderId): string[] { + return getVisibleTeamProviderModels( + providerId, + getTeamProviderModelOptions(providerId) + .map((option) => option.value) + .filter((value) => value.trim().length > 0) + ); +} + +function getFallbackTeamProviderModelOptions( + providerId: SupportedProviderId +): TeamRuntimeModelOption[] { + return getTeamProviderModelOptions(providerId).map((option) => ({ + ...option, + label: + option.value === '' + ? option.label + : (getProviderScopedTeamModelLabel(providerId, option.value) ?? option.value), + })); +} + +function getRuntimeSelectorModels( + providerId: SupportedProviderId, + providerStatus?: TeamModelRuntimeProviderStatus | null +): string[] { + if (!providerStatus) { + return []; + } + + return sortTeamProviderModels(providerId, providerStatus.models); +} + +function getVisibleRuntimeModels( + providerId: SupportedProviderId, + providerStatus?: TeamModelRuntimeProviderStatus | null +): string[] { + return getRuntimeSelectorModels(providerId, providerStatus).filter( + (model) => getTeamModelUiDisabledReason(providerId, model, providerStatus) == null + ); +} + +function getModelAvailabilityMap( + providerStatus?: TeamModelRuntimeProviderStatus | null +): Map { + return new Map( + (providerStatus?.modelAvailability ?? []).map((item) => [item.modelId.trim(), item]) + ); +} + +function getRuntimeModelAvailability( + providerId: SupportedProviderId, + model: string, + providerStatus?: TeamModelRuntimeProviderStatus | null +): CliProviderModelAvailabilityStatus | null { + if (providerId === 'anthropic') { + return 'available'; + } + + if (!providerStatus) { + return null; + } + + const visibleModels = getVisibleRuntimeModels(providerId, providerStatus); + if (!visibleModels.includes(model)) { + return null; + } + return 'available'; +} + +function getRuntimeModelAvailabilityReason( + model: string, + providerStatus?: TeamModelRuntimeProviderStatus | null +): string | null { + return getModelAvailabilityMap(providerStatus).get(model)?.reason ?? null; +} + +export function getTeamProviderModelVerificationCounts( + providerId: SupportedProviderId, + providerStatus?: TeamModelRuntimeProviderStatus | null +): TeamProviderModelVerificationCounts { + if (providerId === 'anthropic') { + return { + checkedCount: getFallbackTeamProviderModels(providerId).length, + totalCount: getFallbackTeamProviderModels(providerId).length, + verifying: false, + }; + } + + const totalCount = getRuntimeSelectorModels(providerId, providerStatus).length; + + return { + checkedCount: totalCount, + totalCount, + verifying: false, + }; +} + +export function getAvailableTeamProviderModels( + providerId: SupportedProviderId, + providerStatus?: TeamModelRuntimeProviderStatus | null +): string[] { + if (providerId === 'anthropic') { + return getFallbackTeamProviderModels(providerId); + } + + if (!providerStatus) { + return []; + } + + return getVisibleRuntimeModels(providerId, providerStatus).filter( + (model) => getRuntimeModelAvailability(providerId, model, providerStatus) === 'available' + ); +} + +export function getAvailableTeamProviderModelOptions( + providerId: SupportedProviderId, + providerStatus?: TeamModelRuntimeProviderStatus | null +): TeamRuntimeModelOption[] { + if (providerId === 'anthropic') { + return getFallbackTeamProviderModelOptions(providerId); + } + + if (!providerStatus) { + return [{ value: '', label: 'Default', badgeLabel: 'Default' }]; + } + + const visibleModels = getRuntimeSelectorModels(providerId, providerStatus); + return [ + { value: '', label: 'Default', badgeLabel: 'Default' }, + ...visibleModels.map((model) => ({ + value: model, + label: getProviderScopedTeamModelLabel(providerId, model) ?? model, + availabilityStatus: getRuntimeModelAvailability(providerId, model, providerStatus), + availabilityReason: getRuntimeModelAvailabilityReason(model, providerStatus), + })), + ]; +} + +export function isTeamModelAvailableForUi( + providerId: SupportedProviderId | undefined, + model: string | undefined, + providerStatus?: TeamModelRuntimeProviderStatus | null +): boolean { + const trimmed = model?.trim(); + if (!providerId || !trimmed) { + return true; + } + + if (getTeamModelUiDisabledReason(providerId, trimmed, providerStatus)) { + return false; + } + + if (providerId === 'anthropic') { + return getFallbackTeamProviderModels(providerId).includes(trimmed); + } + + return getRuntimeModelAvailability(providerId, trimmed, providerStatus) === 'available'; +} + +export function normalizeTeamModelForUi( + providerId: SupportedProviderId | undefined, + model: string | undefined, + providerStatus?: TeamModelRuntimeProviderStatus | null +): string { + const normalized = normalizeCatalogTeamModelForUi(providerId, model); + const trimmed = normalized.trim(); + if (!providerId || !trimmed) { + return normalized; + } + + if (getTeamModelUiDisabledReason(providerId, trimmed, providerStatus)) { + return ''; + } + + if (providerId === 'anthropic') { + return isTeamModelAvailableForUi(providerId, trimmed, providerStatus) ? normalized : ''; + } + + if (!providerStatus) { + return ''; + } + + const visibleModels = getVisibleRuntimeModels(providerId, providerStatus); + if (!visibleModels.includes(trimmed)) { + return ''; + } + + const availability = getRuntimeModelAvailability(providerId, trimmed, providerStatus); + return availability === 'available' ? normalized : ''; +} + +export function getTeamModelSelectionError( + providerId: SupportedProviderId | undefined, + model: string | undefined, + providerStatus?: TeamModelRuntimeProviderStatus | null +): string | null { + const trimmed = model?.trim(); + if (!providerId || !trimmed) { + return null; + } + + const disabledReason = getTeamModelUiDisabledReason(providerId, trimmed, providerStatus); + if (disabledReason) { + return `Model "${trimmed}" is disabled. ${disabledReason}`; + } + + if (providerId === 'anthropic') { + return isTeamModelAvailableForUi(providerId, trimmed, providerStatus) + ? null + : `Model "${trimmed}" is not available for the current ${getTeamProviderLabel(providerId) ?? providerId} runtime. Pick one of the listed models or use Default.`; + } + + if (!providerStatus) { + return `Model "${trimmed}" is waiting for ${getTeamProviderLabel(providerId) ?? providerId} runtime verification. Wait for the model list to load or use Default.`; + } + + const visibleModels = getVisibleRuntimeModels(providerId, providerStatus); + if (!visibleModels.includes(trimmed)) { + return `Model "${trimmed}" is not available for the current ${getTeamProviderLabel(providerId) ?? providerId} runtime. Pick one of the listed models or use Default.`; + } + + return null; +} diff --git a/src/renderer/utils/teamModelCatalog.ts b/src/renderer/utils/teamModelCatalog.ts index ee7c0614..47248f19 100644 --- a/src/renderer/utils/teamModelCatalog.ts +++ b/src/renderer/utils/teamModelCatalog.ts @@ -1,6 +1,19 @@ -import type { CliProviderId, TeamProviderId } from '@shared/types'; +import type { CliProviderId, CliProviderStatus, TeamProviderId } from '@shared/types'; +import { + filterVisibleProviderRuntimeModels, + GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL, + GPT_5_2_CODEX_UI_DISABLED_MODEL, + GPT_5_3_CODEX_SPARK_UI_DISABLED_MODEL, +} from '@shared/utils/providerModelVisibility'; + +export { + GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL, + GPT_5_2_CODEX_UI_DISABLED_MODEL, + GPT_5_3_CODEX_SPARK_UI_DISABLED_MODEL, +} from '@shared/utils/providerModelVisibility'; type SupportedProviderId = CliProviderId | TeamProviderId; +type RuntimeAwareProviderStatus = Pick; export interface TeamProviderModelOption { value: string; @@ -10,10 +23,12 @@ export interface TeamProviderModelOption { } export const TEAM_MODEL_UI_DISABLED_BADGE_LABEL = 'Disabled'; -export const GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL = 'gpt-5.1-codex-mini'; -export const GPT_5_3_CODEX_SPARK_UI_DISABLED_MODEL = 'gpt-5.3-codex-spark'; export const GPT_5_1_CODEX_MINI_UI_DISABLED_REASON = 'Temporarily disabled for team agents - this model has been less reliable with task and reply tool contracts.'; +export const GPT_5_1_CODEX_MAX_CHATGPT_UI_DISABLED_REASON = + 'Temporarily disabled for team agents when using Codex ChatGPT subscription - this model has been observed returning "Not available with Codex ChatGPT subscription".'; +export const GPT_5_2_CODEX_UI_DISABLED_REASON = + 'Temporarily disabled for team agents - this model has been observed returning "Not available with Codex ChatGPT subscription".'; export const GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON = 'Temporarily disabled for team agents - this model has been less reliable with bootstrap, task, and reply tool contracts.'; @@ -66,7 +81,12 @@ const TEAM_PROVIDER_MODEL_OPTIONS: Record !isTeamModelUiDisabled(providerId, model) - ); + return sortTeamProviderModels( + providerId, + filterVisibleProviderRuntimeModels(providerId, models) + ).filter((model) => !isRuntimeHiddenTeamModel(providerId, model, providerStatus)); } export function getTeamModelUiDisabledReason( @@ -213,6 +262,26 @@ export function getTeamModelUiDisabledReason( return getKnownTeamProviderModelOption(providerId, model)?.uiDisabledReason ?? null; } +export function getRuntimeAwareTeamModelUiDisabledReason( + providerId: SupportedProviderId | undefined, + model: string | undefined, + providerStatus?: RuntimeAwareProviderStatus | null +): string | null { + const staticReason = getTeamModelUiDisabledReason(providerId, model); + if (staticReason) { + return staticReason; + } + + const trimmed = model?.trim(); + if (!providerId || !trimmed) { + return null; + } + + return isRuntimeHiddenTeamModel(providerId, trimmed, providerStatus) + ? GPT_5_1_CODEX_MAX_CHATGPT_UI_DISABLED_REASON + : null; +} + export function isTeamModelUiDisabled( providerId: SupportedProviderId | undefined, model: string | undefined diff --git a/src/renderer/utils/teamProvisioningPresentation.ts b/src/renderer/utils/teamProvisioningPresentation.ts index 2253c177..c2a1adf2 100644 --- a/src/renderer/utils/teamProvisioningPresentation.ts +++ b/src/renderer/utils/teamProvisioningPresentation.ts @@ -17,6 +17,11 @@ type MemberSpawnStatusCollection = | Map | undefined; +interface FailedSpawnDetail { + name: string; + reason: string | null; +} + const ACTIVE_PROVISIONING_STATES = new Set([ 'validating', 'spawning', @@ -26,6 +31,73 @@ const ACTIVE_PROVISIONING_STATES = new Set([ 'verifying', ]); +function getFailedSpawnDetails( + memberSpawnStatuses: MemberSpawnStatusCollection +): FailedSpawnDetail[] { + if (!memberSpawnStatuses) { + return []; + } + const entries = + memberSpawnStatuses instanceof Map + ? [...memberSpawnStatuses.entries()] + : Object.entries(memberSpawnStatuses); + + return entries + .filter(([, entry]) => entry.launchState === 'failed_to_start' || entry.status === 'error') + .map(([name, entry]) => ({ + name, + reason: + typeof entry.hardFailureReason === 'string' && entry.hardFailureReason.trim().length > 0 + ? entry.hardFailureReason.trim() + : typeof entry.error === 'string' && entry.error.trim().length > 0 + ? entry.error.trim() + : null, + })) + .sort((left, right) => left.name.localeCompare(right.name)); +} + +function truncateFailureReason(reason: string, maxLength = 160): string { + const normalized = reason.replace(/\s+/g, ' ').trim(); + if (normalized.length <= maxLength) { + return normalized; + } + return `${normalized.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`; +} + +function buildFailedSpawnPanelMessage( + failedSpawnDetails: readonly FailedSpawnDetail[] +): string | null { + if (failedSpawnDetails.length === 0) { + return null; + } + if (failedSpawnDetails.length === 1) { + const [failed] = failedSpawnDetails; + return failed.reason + ? `${failed.name} failed to start - ${truncateFailureReason(failed.reason, 220)}` + : `${failed.name} failed to start`; + } + const listedFailures = failedSpawnDetails + .slice(0, 2) + .map((failed) => + failed.reason ? `${failed.name} - ${truncateFailureReason(failed.reason, 120)}` : failed.name + ) + .join('; '); + const remainingCount = failedSpawnDetails.length - Math.min(failedSpawnDetails.length, 2); + return `Failed teammates: ${listedFailures}${remainingCount > 0 ? `; +${remainingCount} more` : ''}`; +} + +function buildFailedSpawnCompactDetail( + failedSpawnDetails: readonly FailedSpawnDetail[] +): string | null { + if (failedSpawnDetails.length === 0) { + return null; + } + if (failedSpawnDetails.length === 1) { + return `${failedSpawnDetails[0].name} failed to start`; + } + return `${failedSpawnDetails.length} teammates failed to start`; +} + export interface TeamProvisioningPresentation { progress: TeamProvisioningProgress; isActive: boolean; @@ -99,6 +171,9 @@ export function buildTeamProvisioningPresentation({ memberSpawnStatuses, memberSpawnSnapshot, }); + const failedSpawnDetails = getFailedSpawnDetails(memberSpawnStatuses); + const failedSpawnPanelMessage = buildFailedSpawnPanelMessage(failedSpawnDetails); + const failedSpawnCompactDetail = buildFailedSpawnCompactDetail(failedSpawnDetails); const { allTeammatesConfirmedAlive, hasMembersStillJoining, remainingJoinCount } = getLaunchJoinState({ @@ -135,7 +210,7 @@ export function buildTeamProvisioningPresentation({ hasMembersStillJoining, remainingJoinCount, panelTitle: 'Launch failed', - panelMessage: progress.error ?? null, + panelMessage: progress.error ?? failedSpawnPanelMessage ?? null, panelTone: 'error', defaultLiveOutputOpen: true, compactTitle: 'Launch failed', @@ -151,7 +226,8 @@ export function buildTeamProvisioningPresentation({ : `${remainingJoinCount} teammates still joining`; const readyCompactDetail = failedSpawnCount > 0 - ? `${failedSpawnCount} teammate${failedSpawnCount === 1 ? '' : 's'} failed to start` + ? (failedSpawnCompactDetail ?? + `${failedSpawnCount} teammate${failedSpawnCount === 1 ? '' : 's'} failed to start`) : hasMembersStillJoining ? joiningPhrase : expectedTeammateCount === 0 @@ -159,7 +235,7 @@ export function buildTeamProvisioningPresentation({ : `All ${expectedTeammateCount} teammates joined`; const readyDetailMessage = failedSpawnCount > 0 - ? progress.message + ? (failedSpawnPanelMessage ?? progress.message) : expectedTeammateCount === 0 ? 'Team provisioned - lead online' : allTeammatesConfirmedAlive @@ -229,15 +305,19 @@ export function buildTeamProvisioningPresentation({ hasMembersStillJoining, remainingJoinCount, panelTitle: 'Launching team', - panelMessage: progress.message, - panelMessageSeverity: progress.messageSeverity, + panelMessage: + failedSpawnCount > 0 ? (failedSpawnPanelMessage ?? progress.message) : progress.message, + panelMessageSeverity: failedSpawnCount > 0 ? 'warning' : progress.messageSeverity, defaultLiveOutputOpen: true, compactTitle: 'Launching team', compactDetail: - expectedTeammateCount > 0 && progressStepIndex >= 2 - ? `${heartbeatConfirmedCount}/${expectedTeammateCount} teammates confirmed` - : progress.message, - compactTone: 'default', + failedSpawnCount > 0 + ? (failedSpawnCompactDetail ?? + `${failedSpawnCount} teammate${failedSpawnCount === 1 ? '' : 's'} failed to start`) + : expectedTeammateCount > 0 && progressStepIndex >= 2 + ? `${heartbeatConfirmedCount}/${expectedTeammateCount} teammates confirmed` + : progress.message, + compactTone: failedSpawnCount > 0 ? 'warning' : 'default', }; } diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index afc2aae7..1db3c89b 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -438,7 +438,9 @@ export interface TeamsAPI { prepareProvisioning: ( cwd?: string, providerId?: TeamLaunchRequest['providerId'], - providerIds?: TeamLaunchRequest['providerId'][] + providerIds?: TeamLaunchRequest['providerId'][], + selectedModels?: string[], + limitContext?: boolean ) => Promise; createTeam: (request: TeamCreateRequest) => Promise; getProvisioningStatus: (runId: string) => Promise; diff --git a/src/shared/types/cliInstaller.ts b/src/shared/types/cliInstaller.ts index 7eaf19c0..a68947e0 100644 --- a/src/shared/types/cliInstaller.ts +++ b/src/shared/types/cliInstaller.ts @@ -57,6 +57,19 @@ export interface CliExternalRuntimeDiagnostic { detailMessage?: string | null; } +export type CliProviderModelAvailabilityStatus = + | 'checking' + | 'available' + | 'unavailable' + | 'unknown'; + +export interface CliProviderModelAvailability { + modelId: string; + status: CliProviderModelAvailabilityStatus; + reason?: string | null; + checkedAt?: string | null; +} + export interface CliProviderStatus { providerId: CliProviderId; displayName: string; @@ -64,8 +77,10 @@ export interface CliProviderStatus { authenticated: boolean; authMethod: string | null; verificationState: 'verified' | 'unknown' | 'offline' | 'error'; + modelVerificationState?: 'idle' | 'verifying' | 'verified'; statusMessage?: string | null; models: string[]; + modelAvailability?: CliProviderModelAvailability[]; canLoginFromUi: boolean; capabilities: { teamLaunch: boolean; @@ -172,6 +187,8 @@ export interface CliInstallerAPI { getStatus: () => Promise; /** Get current runtime/auth status for a single provider */ getProviderStatus: (providerId: CliProviderId) => Promise; + /** Start on-demand model verification for a single runtime provider */ + verifyProviderModels: (providerId: CliProviderId) => Promise; /** Start install/update flow. Progress sent via onProgress events. */ install: () => Promise; /** Invalidate cached status (forces fresh check on next getStatus) */ diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index dcc8dfdf..1d970668 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -990,6 +990,7 @@ export interface TeamCreateResponse { export interface TeamProvisioningPrepareResult { ready: boolean; message: string; + details?: string[]; warnings?: string[]; } diff --git a/src/shared/utils/anthropicModelDefaults.ts b/src/shared/utils/anthropicModelDefaults.ts new file mode 100644 index 00000000..ce895df7 --- /dev/null +++ b/src/shared/utils/anthropicModelDefaults.ts @@ -0,0 +1,3 @@ +export function getAnthropicDefaultTeamModel(limitContext: boolean): string { + return limitContext ? 'opus' : 'opus[1m]'; +} diff --git a/src/shared/utils/providerModelSelection.ts b/src/shared/utils/providerModelSelection.ts new file mode 100644 index 00000000..bbc987dd --- /dev/null +++ b/src/shared/utils/providerModelSelection.ts @@ -0,0 +1,5 @@ +export const DEFAULT_PROVIDER_MODEL_SELECTION = '__provider_default__'; + +export function isDefaultProviderModelSelection(value: string | undefined): boolean { + return value?.trim() === DEFAULT_PROVIDER_MODEL_SELECTION; +} diff --git a/src/shared/utils/providerModelVisibility.ts b/src/shared/utils/providerModelVisibility.ts new file mode 100644 index 00000000..807ac8b0 --- /dev/null +++ b/src/shared/utils/providerModelVisibility.ts @@ -0,0 +1,47 @@ +import type { CliProviderId, TeamProviderId } from '@shared/types'; + +type SupportedProviderId = CliProviderId | TeamProviderId; + +export const GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL = 'gpt-5.1-codex-mini'; +export const GPT_5_2_CODEX_UI_DISABLED_MODEL = 'gpt-5.2-codex'; +export const GPT_5_3_CODEX_SPARK_UI_DISABLED_MODEL = 'gpt-5.3-codex-spark'; + +const UI_DISABLED_MODELS_BY_PROVIDER: Partial> = { + codex: [ + GPT_5_3_CODEX_SPARK_UI_DISABLED_MODEL, + GPT_5_2_CODEX_UI_DISABLED_MODEL, + GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL, + ], +}; + +export function isProviderRuntimeModelUiDisabled( + providerId: SupportedProviderId | undefined, + model: string | undefined +): boolean { + const trimmed = model?.trim(); + if (!providerId || !trimmed) { + return false; + } + + return UI_DISABLED_MODELS_BY_PROVIDER[providerId]?.includes(trimmed) ?? false; +} + +export function filterVisibleProviderRuntimeModels( + providerId: SupportedProviderId, + models: readonly string[] +): string[] { + const seen = new Set(); + const visible: string[] = []; + + for (const model of models) { + const trimmed = model.trim(); + if (!trimmed || seen.has(trimmed) || isProviderRuntimeModelUiDisabled(providerId, trimmed)) { + continue; + } + + seen.add(trimmed); + visible.push(trimmed); + } + + return visible; +} diff --git a/test/main/services/infrastructure/CliInstallerService.test.ts b/test/main/services/infrastructure/CliInstallerService.test.ts index fecd1d15..ffad35a7 100644 --- a/test/main/services/infrastructure/CliInstallerService.test.ts +++ b/test/main/services/infrastructure/CliInstallerService.test.ts @@ -72,12 +72,21 @@ vi.mock('@main/services/team/cliFlavor', () => ({ })), })); +vi.mock('@main/services/runtime/providerAwareCliEnv', () => ({ + buildProviderAwareCliEnv: vi.fn(async () => ({ + env: { HOME: '/Users/tester' }, + connectionIssues: {}, + })), +})); + import { CliInstallerService, isVersionOlder, normalizeVersion, } from '@main/services/infrastructure/CliInstallerService'; +import { ClaudeMultimodelBridgeService } from '@main/services/runtime/ClaudeMultimodelBridgeService'; import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; +import { getCliFlavorUiOptions, getConfiguredCliFlavor } from '@main/services/team/cliFlavor'; import { execCli } from '@main/utils/childProcess'; /** @@ -96,6 +105,13 @@ describe('CliInstallerService', () => { vi.clearAllMocks(); realpathMock.mockReset(); realpathMock.mockImplementation(async (value: string) => value); + vi.mocked(getConfiguredCliFlavor).mockReturnValue('claude'); + vi.mocked(getCliFlavorUiOptions).mockReturnValue({ + displayName: 'Claude CLI', + supportsSelfUpdate: true, + showVersionDetails: true, + showBinaryPath: true, + }); service = new CliInstallerService(); }); @@ -176,6 +192,146 @@ describe('CliInstallerService', () => { expect(status.installedVersion).toBe('2.1.101'); expect(status.authLoggedIn).toBe(true); }); + + it('publishes probe-enriched runtime model status snapshots only for explicit verification requests', async () => { + allowConsoleLogs(); + vi.mocked(getConfiguredCliFlavor).mockReturnValue('agent_teams_orchestrator'); + vi.mocked(getCliFlavorUiOptions).mockReturnValue({ + displayName: 'agent_teams_orchestrator', + supportsSelfUpdate: false, + showVersionDetails: false, + showBinaryPath: false, + }); + vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude'); + + vi.spyOn(ClaudeMultimodelBridgeService.prototype, 'getProviderStatuses').mockImplementation( + async (_binaryPath, onUpdate) => { + const providers = [ + { + providerId: 'anthropic', + displayName: 'Anthropic', + supported: true, + authenticated: true, + authMethod: 'oauth_token', + verificationState: 'verified', + modelVerificationState: 'idle', + statusMessage: null, + models: [], + modelAvailability: [], + canLoginFromUi: true, + capabilities: { teamLaunch: true, oneShot: true }, + backend: null, + }, + { + providerId: 'codex', + displayName: 'Codex', + supported: true, + authenticated: true, + authMethod: 'oauth_token', + verificationState: 'verified', + modelVerificationState: 'idle', + statusMessage: null, + models: ['gpt-5.4', 'gpt-5.2-codex'], + modelAvailability: [], + canLoginFromUi: true, + capabilities: { teamLaunch: true, oneShot: true }, + backend: { + kind: 'openai', + label: 'OpenAI', + endpointLabel: 'chatgpt.com/backend-api/codex/responses', + }, + }, + { + providerId: 'gemini', + displayName: 'Gemini', + supported: false, + authenticated: false, + authMethod: null, + verificationState: 'unknown', + modelVerificationState: 'idle', + statusMessage: null, + models: [], + modelAvailability: [], + canLoginFromUi: true, + capabilities: { teamLaunch: false, oneShot: false }, + backend: null, + }, + ]; + onUpdate?.(providers as never); + return providers as never; + } + ); + + vi.mocked(execCli).mockImplementation(async (_binaryPath, args) => { + const normalizedArgs = Array.isArray(args) ? args.join(' ') : ''; + if (normalizedArgs === '--version') { + return { stdout: '2.3.4', stderr: '' }; + } + if (normalizedArgs.includes('--model gpt-5.4')) { + return { stdout: 'PONG', stderr: '' }; + } + if (normalizedArgs.includes('--model gpt-5.2-codex')) { + throw new Error( + "The 'gpt-5.2-codex' model is not supported when using Codex with a ChatGPT account." + ); + } + throw new Error(`Unexpected execCli call: ${normalizedArgs}`); + }); + + const mockWindow = { + isDestroyed: () => false, + webContents: { send: vi.fn(), isDestroyed: () => false }, + }; + service.setMainWindow(mockWindow as unknown as import('electron').BrowserWindow); + + const status = await service.getStatus(); + expect(status.providers.find((provider) => provider.providerId === 'codex')?.modelAvailability).toEqual([]); + + const verifiedProvider = await service.verifyProviderModels('codex'); + expect(verifiedProvider?.modelAvailability).toEqual( + expect.arrayContaining([ + expect.objectContaining({ modelId: 'gpt-5.4', status: 'checking' }), + expect.objectContaining({ modelId: 'gpt-5.2-codex', status: 'checking' }), + ]) + ); + + await vi.waitFor(() => { + const latestCodexProvider = service + .getLatestStatusSnapshot() + ?.providers.find((provider) => provider.providerId === 'codex'); + + expect(latestCodexProvider?.modelAvailability).toEqual([ + expect.objectContaining({ modelId: 'gpt-5.4', status: 'available' }), + expect.objectContaining({ + modelId: 'gpt-5.2-codex', + status: 'unavailable', + }), + ]); + }); + + const statusEvents = mockWindow.webContents.send.mock.calls + .filter((call: unknown[]) => call[0] === 'cliInstaller:progress') + .map((call: unknown[]) => call[1] as { type?: string; status?: { providers?: unknown[] } }) + .filter((event) => event.type === 'status'); + + expect(statusEvents.length).toBeGreaterThan(1); + expect( + statusEvents.some((event) => + event.status?.providers?.some( + (provider) => + typeof provider === 'object' && + provider !== null && + 'providerId' in provider && + 'modelAvailability' in provider && + (provider as { providerId?: string }).providerId === 'codex' && + Array.isArray((provider as { modelAvailability?: unknown[] }).modelAvailability) && + (provider as { modelAvailability: Array<{ status?: string }> }).modelAvailability.some( + (item) => item.status === 'unavailable' + ) + ) + ) + ).toBe(true); + }); }); describe('install mutex', () => { diff --git a/test/main/services/runtime/CliProviderModelAvailabilityService.test.ts b/test/main/services/runtime/CliProviderModelAvailabilityService.test.ts new file mode 100644 index 00000000..a498882d --- /dev/null +++ b/test/main/services/runtime/CliProviderModelAvailabilityService.test.ts @@ -0,0 +1,153 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const execCliMock = vi.fn(); +const buildProviderAwareCliEnvMock = vi.fn(); + +vi.mock('@main/utils/childProcess', () => ({ + execCli: (...args: Parameters) => execCliMock(...args), +})); + +vi.mock('@main/services/runtime/providerAwareCliEnv', () => ({ + buildProviderAwareCliEnv: (...args: Parameters) => + buildProviderAwareCliEnvMock(...args), +})); + +import { + CliProviderModelAvailabilityService, + type ProviderModelAvailabilityContext, +} from '@main/services/runtime/CliProviderModelAvailabilityService'; + +function createContext(models: string[]): ProviderModelAvailabilityContext { + return { + binaryPath: '/usr/local/bin/claude', + installedVersion: '2.3.4', + provider: { + providerId: 'codex', + models, + supported: true, + authenticated: true, + authMethod: 'oauth_token', + selectedBackendId: 'chatgpt', + resolvedBackendId: 'chatgpt', + capabilities: { + teamLaunch: true, + oneShot: true, + }, + backend: { + kind: 'openai', + label: 'OpenAI', + endpointLabel: 'chatgpt.com/backend-api/codex/responses', + }, + }, + }; +} + +describe('CliProviderModelAvailabilityService', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('reuses probe cache for the same provider signature', async () => { + buildProviderAwareCliEnvMock.mockResolvedValue({ + env: { HOME: '/Users/tester' }, + connectionIssues: {}, + }); + execCliMock.mockResolvedValue({ stdout: 'PONG', stderr: '' }); + + const service = new CliProviderModelAvailabilityService(); + const context = createContext(['gpt-5.4', 'gpt-5.3-codex']); + + expect(service.getSnapshot(context).modelVerificationState).toBe('verifying'); + expect(service.getSnapshot(context).modelVerificationState).toBe('verifying'); + + await vi.waitFor(() => { + expect(execCliMock).toHaveBeenCalledTimes(2); + }); + + expect(service.getSnapshot(context).modelAvailability).toEqual([ + expect.objectContaining({ modelId: 'gpt-5.4', status: 'available' }), + expect.objectContaining({ modelId: 'gpt-5.3-codex', status: 'available' }), + ]); + expect(execCliMock).toHaveBeenCalledTimes(2); + }); + + it('marks unsupported models as unavailable with the runtime reason', async () => { + buildProviderAwareCliEnvMock.mockResolvedValue({ + env: { HOME: '/Users/tester' }, + connectionIssues: {}, + }); + execCliMock.mockRejectedValue( + new Error("The 'gpt-5.2-codex' model is not supported when using Codex with a ChatGPT account.") + ); + + const onUpdate = vi.fn(); + const service = new CliProviderModelAvailabilityService(onUpdate); + service.getSnapshot(createContext(['gpt-5.2-codex'])); + + await vi.waitFor(() => { + expect(onUpdate).toHaveBeenCalledWith( + 'codex', + expect.any(String), + expect.objectContaining({ + modelAvailability: [ + expect.objectContaining({ + modelId: 'gpt-5.2-codex', + status: 'unavailable', + reason: 'Not available with Codex ChatGPT subscription', + }), + ], + }) + ); + }); + }); + + it('marks timeout-like probe failures as unknown instead of unavailable', async () => { + buildProviderAwareCliEnvMock.mockResolvedValue({ + env: { HOME: '/Users/tester' }, + connectionIssues: {}, + }); + execCliMock.mockRejectedValue(new Error('Command timed out after 45000ms')); + + const onUpdate = vi.fn(); + const service = new CliProviderModelAvailabilityService(onUpdate); + service.getSnapshot(createContext(['gpt-5.4'])); + + await vi.waitFor(() => { + expect(onUpdate).toHaveBeenCalledWith( + 'codex', + expect.any(String), + expect.objectContaining({ + modelAvailability: [ + expect.objectContaining({ + modelId: 'gpt-5.4', + status: 'unknown', + reason: 'Model verification timed out', + }), + ], + }) + ); + }); + }); + + it('invalidates the cache when the provider signature changes', async () => { + buildProviderAwareCliEnvMock.mockResolvedValue({ + env: { HOME: '/Users/tester' }, + connectionIssues: {}, + }); + execCliMock.mockResolvedValue({ stdout: 'PONG', stderr: '' }); + + const service = new CliProviderModelAvailabilityService(); + service.getSnapshot(createContext(['gpt-5.4'])); + + await vi.waitFor(() => { + expect(execCliMock).toHaveBeenCalledTimes(1); + }); + + service.getSnapshot(createContext(['gpt-5.4', 'gpt-5.2'])); + + await vi.waitFor(() => { + expect(execCliMock).toHaveBeenCalledTimes(3); + }); + }); +}); diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 95ff14cd..659d5d68 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -673,4 +673,177 @@ describe('TeamProvisioningService', () => { expect(launchArgs).toContain('--resume'); expect(launchArgs).toContain(leadSessionId); }); + + it('marks persisted bootstrap as failed when member transcript shows an unsupported model error', async () => { + allowConsoleLogs(); + const teamName = 'zz-unit-bootstrap-unsupported-model'; + const leadSessionId = 'lead-session'; + const memberSessionId = 'jack-session'; + const projectPath = '/Users/test/proj'; + const projectId = '-Users-test-proj'; + const acceptedAt = new Date(Date.now() - 5_000).toISOString(); + const errorAt = new Date(Date.now() - 4_000).toISOString(); + + writeLaunchConfig(teamName, projectPath, leadSessionId, ['jack']); + writeLaunchState(teamName, leadSessionId, { + jack: { + launchState: 'runtime_pending_bootstrap', + agentToolAccepted: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + hardFailureReason: undefined, + firstSpawnAcceptedAt: acceptedAt, + }, + }); + + const projectRoot = path.join(tempProjectsBase, projectId); + fs.mkdirSync(projectRoot, { recursive: true }); + fs.writeFileSync( + path.join(projectRoot, `${leadSessionId}.jsonl`), + `${JSON.stringify({ + timestamp: new Date(Date.now() - 10_000).toISOString(), + teamName, + type: 'user', + message: { role: 'user', content: 'Lead bootstrap context' }, + })}\n`, + 'utf8' + ); + fs.writeFileSync( + path.join(projectRoot, `${memberSessionId}.jsonl`), + [ + JSON.stringify({ + timestamp: acceptedAt, + teamName, + agentName: 'jack', + type: 'user', + message: { + role: 'user', + content: `You are bootstrapping into team "${teamName}" as member "jack".`, + }, + }), + JSON.stringify({ + timestamp: errorAt, + teamName, + agentName: 'jack', + type: 'assistant', + isApiErrorMessage: true, + message: { + role: 'assistant', + content: [ + { + type: 'text', + text: `API Error: 400 {"type":"error","error":{"type":"api_error","message":"Codex API error (400): {\\"detail\\":\\"The 'gpt-5.2-codex' model is not supported when using Codex with a ChatGPT account.\\"}"}}`, + }, + ], + }, + }), + ].join('\n') + '\n', + 'utf8' + ); + + const svc = new TeamProvisioningService(); + const result = await svc.getMemberSpawnStatuses(teamName); + + expect(result.statuses.jack?.status).toBe('error'); + expect(result.statuses.jack?.launchState).toBe('failed_to_start'); + expect(result.statuses.jack?.error).toContain('gpt-5.2-codex'); + expect(result.statuses.jack?.hardFailureReason).toContain('not supported'); + expect(result.teamLaunchState).toBe('partial_failure'); + }); + + it('marks a live teammate bootstrap as failed when transcript shows model unavailability', async () => { + allowConsoleLogs(); + const teamName = 'zz-live-bootstrap-model-unavailable'; + const leadSessionId = 'lead-session'; + const memberSessionId = 'jack-session'; + const projectPath = '/Users/test/proj'; + const projectId = '-Users-test-proj'; + const acceptedAt = new Date(Date.now() - 5_000).toISOString(); + const errorAt = new Date(Date.now() - 4_000).toISOString(); + + writeLaunchConfig(teamName, projectPath, leadSessionId, ['jack']); + + const projectRoot = path.join(tempProjectsBase, projectId); + fs.mkdirSync(projectRoot, { recursive: true }); + fs.writeFileSync( + path.join(projectRoot, `${memberSessionId}.jsonl`), + [ + JSON.stringify({ + timestamp: acceptedAt, + teamName, + agentName: 'jack', + type: 'user', + message: { + role: 'user', + content: `You are bootstrapping into team "${teamName}" as member "jack".`, + }, + }), + JSON.stringify({ + timestamp: errorAt, + teamName, + agentName: 'jack', + type: 'assistant', + isApiErrorMessage: true, + message: { + role: 'assistant', + content: [ + { + type: 'text', + text: 'API Error: 400 {"detail":"The requested model is not available for your account."}', + }, + ], + }, + }), + ].join('\n') + '\n', + 'utf8' + ); + + const svc = new TeamProvisioningService(); + const run = { + runId: 'run-live-1', + teamName, + startedAt: new Date(Date.now() - 60_000).toISOString(), + request: { + members: [], + }, + expectedMembers: ['jack'], + memberSpawnStatuses: new Map([ + [ + 'jack', + { + status: 'waiting', + launchState: 'runtime_pending_bootstrap', + error: undefined, + updatedAt: acceptedAt, + runtimeAlive: false, + livenessSource: undefined, + bootstrapConfirmed: false, + hardFailure: false, + agentToolAccepted: true, + firstSpawnAcceptedAt: acceptedAt, + lastHeartbeatAt: undefined, + }, + ], + ]), + provisioningOutputParts: [], + activeToolCalls: new Map(), + isLaunch: false, + } as any; + + (svc as any).runs.set(run.runId, run); + (svc as any).provisioningRunByTeam.set(teamName, run.runId); + + await (svc as any).reconcileBootstrapTranscriptFailures(run); + + expect(run.memberSpawnStatuses.get('jack')).toMatchObject({ + status: 'error', + launchState: 'failed_to_start', + hardFailure: true, + }); + expect(run.memberSpawnStatuses.get('jack')?.error).toContain( + 'requested model is not available' + ); + expect(run.provisioningOutputParts.join('\n')).toContain('requested model is not available'); + }); }); diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts index 208a2385..bdd11285 100644 --- a/test/main/services/team/TeamProvisioningServicePrepare.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts @@ -3,6 +3,7 @@ import * as os from 'os'; import * as path from 'path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection'; vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({ ClaudeBinaryResolver: { resolve: vi.fn() }, @@ -123,6 +124,187 @@ describe('TeamProvisioningService prepare/auth behavior', () => { ]); }); + it('verifies the selected Codex model during prepare and records a success detail', async () => { + const svc = new TeamProvisioningService(); + vi.spyOn(svc as any, 'getCachedOrProbeResult').mockResolvedValue({ + claudePath: '/fake/claude', + authSource: 'codex_runtime', + }); + vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({ + env: { + PATH: '/usr/bin', + SHELL: '/bin/zsh', + }, + authSource: 'codex_runtime', + geminiRuntimeAuth: null, + }); + const spawnProbe = vi.spyOn(svc as any, 'spawnProbe').mockResolvedValue({ + stdout: 'PONG', + stderr: '', + exitCode: 0, + }); + + const result = await svc.prepareForProvisioning(tempRoot, { + forceFresh: true, + providerId: 'codex', + modelIds: ['gpt-5.4'], + }); + + expect(result.ready).toBe(true); + expect(result.details).toContain('Selected model gpt-5.4 verified for launch.'); + expect(spawnProbe).toHaveBeenCalledWith( + '/fake/claude', + expect.arrayContaining(['--model', 'gpt-5.4']), + tempRoot, + expect.any(Object), + 60_000, + expect.any(Object) + ); + }); + + it('verifies the resolved Codex default model during prepare', async () => { + const svc = new TeamProvisioningService(); + vi.spyOn(svc as any, 'getCachedOrProbeResult').mockResolvedValue({ + claudePath: '/fake/claude', + authSource: 'codex_runtime', + }); + vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({ + env: { + PATH: '/usr/bin', + SHELL: '/bin/zsh', + }, + authSource: 'codex_runtime', + geminiRuntimeAuth: null, + }); + vi.spyOn(svc as any, 'resolveProviderDefaultModel').mockResolvedValue('gpt-5.4-mini'); + const spawnProbe = vi.spyOn(svc as any, 'spawnProbe').mockResolvedValue({ + stdout: 'PONG', + stderr: '', + exitCode: 0, + }); + + const result = await svc.prepareForProvisioning(tempRoot, { + forceFresh: true, + providerId: 'codex', + modelIds: [DEFAULT_PROVIDER_MODEL_SELECTION], + }); + + expect(result.ready).toBe(true); + expect(result.details).toContain( + `Selected model ${DEFAULT_PROVIDER_MODEL_SELECTION} verified for launch.` + ); + expect(spawnProbe).toHaveBeenCalledWith( + '/fake/claude', + expect.arrayContaining(['--model', 'gpt-5.4-mini']), + tempRoot, + expect.any(Object), + 60_000, + expect.any(Object) + ); + }); + + it('verifies the resolved Anthropic default model during prepare with limitContext', async () => { + const svc = new TeamProvisioningService(); + vi.spyOn(svc as any, 'getCachedOrProbeResult').mockResolvedValue({ + claudePath: '/fake/claude', + authSource: 'oauth_token', + }); + vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({ + env: { + PATH: '/usr/bin', + SHELL: '/bin/zsh', + }, + authSource: 'oauth_token', + geminiRuntimeAuth: null, + }); + const spawnProbe = vi.spyOn(svc as any, 'spawnProbe').mockResolvedValue({ + stdout: 'PONG', + stderr: '', + exitCode: 0, + }); + + const result = await svc.prepareForProvisioning(tempRoot, { + forceFresh: true, + providerId: 'anthropic', + modelIds: [DEFAULT_PROVIDER_MODEL_SELECTION], + limitContext: true, + }); + + expect(result.ready).toBe(true); + expect(result.details).toContain( + `Selected model ${DEFAULT_PROVIDER_MODEL_SELECTION} verified for launch.` + ); + expect(spawnProbe).toHaveBeenCalledWith( + '/fake/claude', + expect.arrayContaining(['--model', 'opus']), + tempRoot, + expect.any(Object), + 60_000, + expect.any(Object) + ); + }); + + it('fails prepare when the selected Codex model is unavailable', async () => { + const svc = new TeamProvisioningService(); + vi.spyOn(svc as any, 'getCachedOrProbeResult').mockResolvedValue({ + claudePath: '/fake/claude', + authSource: 'codex_runtime', + }); + vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({ + env: { + PATH: '/usr/bin', + SHELL: '/bin/zsh', + }, + authSource: 'codex_runtime', + geminiRuntimeAuth: null, + }); + vi.spyOn(svc as any, 'spawnProbe').mockRejectedValue( + new Error("The 'gpt-5.2-codex' model is not supported when using Codex with a ChatGPT account.") + ); + + const result = await svc.prepareForProvisioning(tempRoot, { + forceFresh: true, + providerId: 'codex', + modelIds: ['gpt-5.2-codex'], + }); + + expect(result.ready).toBe(false); + expect(result.message).toContain('Selected model gpt-5.2-codex is unavailable.'); + expect(result.message).toContain('Not available with Codex ChatGPT subscription'); + }); + + it('keeps timed out Codex model verification as a warning with a clean generic reason', async () => { + const svc = new TeamProvisioningService(); + vi.spyOn(svc as any, 'getCachedOrProbeResult').mockResolvedValue({ + claudePath: '/fake/claude', + authSource: 'codex_runtime', + }); + vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({ + env: { + PATH: '/usr/bin', + SHELL: '/bin/zsh', + }, + authSource: 'codex_runtime', + geminiRuntimeAuth: null, + }); + vi.spyOn(svc as any, 'spawnProbe').mockRejectedValue( + new Error( + 'Timeout running: claude -p Output only the single word PONG. --output-format text --model gpt-5.3-codex --max-turns 1 --no-session-persistence' + ) + ); + + const result = await svc.prepareForProvisioning(tempRoot, { + forceFresh: true, + providerId: 'codex', + modelIds: ['gpt-5.3-codex'], + }); + + expect(result.ready).toBe(true); + expect(result.warnings).toContain( + 'Selected model gpt-5.3-codex could not be verified. Model verification timed out' + ); + }); + it('maps ANTHROPIC_AUTH_TOKEN into ANTHROPIC_API_KEY for headless preflight', async () => { const svc = new TeamProvisioningService(); vi.mocked(resolveInteractiveShellEnv).mockResolvedValue({ diff --git a/test/renderer/components/cli/CliStatusVisibility.test.ts b/test/renderer/components/cli/CliStatusVisibility.test.ts index 98883aa2..96527c46 100644 --- a/test/renderer/components/cli/CliStatusVisibility.test.ts +++ b/test/renderer/components/cli/CliStatusVisibility.test.ts @@ -537,4 +537,74 @@ describe('CLI status visibility during completed install state', () => { await Promise.resolve(); }); }); + + it('shows runtime model availability badges on the dashboard', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliInstallerState = 'idle'; + storeState.cliStatus = createInstalledCliStatus({ + flavor: 'agent_teams_orchestrator', + displayName: 'agent_teams_orchestrator', + supportsSelfUpdate: false, + showVersionDetails: false, + showBinaryPath: false, + authLoggedIn: true, + providers: [ + { + providerId: 'codex', + displayName: 'Codex', + supported: true, + authenticated: true, + authMethod: 'oauth_token', + verificationState: 'verified', + modelVerificationState: 'verified', + statusMessage: null, + models: ['gpt-5.4', 'gpt-5.1-codex-max', 'gpt-5.2-codex'], + modelAvailability: [ + { modelId: 'gpt-5.4', status: 'available', checkedAt: '2026-04-16T12:00:00.000Z' }, + { + modelId: 'gpt-5.1-codex-max', + status: 'unavailable', + reason: 'The requested model is not available for your account.', + checkedAt: '2026-04-16T12:00:00.000Z', + }, + { + modelId: 'gpt-5.2-codex', + status: 'unavailable', + reason: 'The requested model is not available for your account.', + checkedAt: '2026-04-16T12:00:00.000Z', + }, + ], + canLoginFromUi: true, + capabilities: { + teamLaunch: true, + oneShot: true, + }, + backend: { + kind: 'openai', + label: 'OpenAI', + endpointLabel: 'chatgpt.com/backend-api/codex/responses', + }, + }, + ], + }); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(CliStatusBanner)); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('5.4'); + expect(host.textContent).not.toContain('5.1-codex-max'); + expect(host.textContent).not.toContain('5.2-codex'); + expect(host.textContent).not.toContain('Unavailable'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/components/team/TeamModelSelector.test.ts b/test/renderer/components/team/TeamModelSelector.test.ts index f441903a..9762a168 100644 --- a/test/renderer/components/team/TeamModelSelector.test.ts +++ b/test/renderer/components/team/TeamModelSelector.test.ts @@ -6,7 +6,11 @@ import { } from '@renderer/components/team/dialogs/TeamModelSelector'; import { GPT_5_1_CODEX_MINI_UI_DISABLED_REASON, + GPT_5_1_CODEX_MAX_CHATGPT_UI_DISABLED_REASON, + GPT_5_2_CODEX_UI_DISABLED_REASON, GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON, + getAvailableTeamProviderModels, + getTeamModelSelectionError, getTeamModelUiDisabledReason, normalizeTeamModelForUi, } from '@renderer/utils/teamModelAvailability'; @@ -22,10 +26,13 @@ describe('formatTeamModelSummary', () => { expect(formatTeamModelSummary('codex', 'gpt-5.4', 'medium')).toBe('5.4 · Medium'); }); - it('marks 5.1 Codex Mini as disabled only for Codex team selection', () => { + it('marks the known disabled Codex models only for Codex team selection', () => { expect(getTeamModelUiDisabledReason('codex', 'gpt-5.1-codex-mini')).toBe( GPT_5_1_CODEX_MINI_UI_DISABLED_REASON ); + expect(getTeamModelUiDisabledReason('codex', 'gpt-5.2-codex')).toBe( + GPT_5_2_CODEX_UI_DISABLED_REASON + ); expect(getTeamModelUiDisabledReason('codex', 'gpt-5.3-codex-spark')).toBe( GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON ); @@ -33,10 +40,72 @@ describe('formatTeamModelSummary', () => { expect(getTeamModelUiDisabledReason('anthropic', 'gpt-5.1-codex-mini')).toBeNull(); }); + it('disables 5.1 Codex Max only on the Codex ChatGPT subscription path', () => { + const chatgptCodexProviderStatus = { + providerId: 'codex' as const, + models: ['gpt-5.4', 'gpt-5.1-codex-max'], + authMethod: 'oauth_token' as const, + backend: { + kind: 'adapter', + label: 'Default adapter', + endpointLabel: 'chatgpt.com/backend-api/codex/responses', + }, + modelVerificationState: 'verified' as const, + modelAvailability: [], + authenticated: true, + supported: true, + }; + + expect( + getTeamModelUiDisabledReason('codex', 'gpt-5.1-codex-max', chatgptCodexProviderStatus) + ).toBe(GPT_5_1_CODEX_MAX_CHATGPT_UI_DISABLED_REASON); + expect(normalizeTeamModelForUi('codex', 'gpt-5.1-codex-max', chatgptCodexProviderStatus)).toBe( + '' + ); + expect( + getTeamModelSelectionError('codex', 'gpt-5.1-codex-max', chatgptCodexProviderStatus) + ).toContain('Temporarily disabled for team agents when using Codex ChatGPT subscription'); + expect(getTeamModelUiDisabledReason('codex', 'gpt-5.1-codex-max')).toBeNull(); + }); + it('normalizes disabled Codex model selections back to default', () => { expect(normalizeTeamModelForUi('codex', 'gpt-5.1-codex-mini')).toBe(''); + expect(normalizeTeamModelForUi('codex', 'gpt-5.2-codex')).toBe(''); expect(normalizeTeamModelForUi('codex', 'gpt-5.3-codex-spark')).toBe(''); - expect(normalizeTeamModelForUi('codex', 'gpt-5.4-mini')).toBe('gpt-5.4-mini'); + expect(normalizeTeamModelForUi('codex', 'gpt-5.4-mini')).toBe(''); + }); + + it('uses the runtime-reported Codex model list when provider status is available', () => { + const codexProviderStatus = { + providerId: 'codex' as const, + models: ['gpt-5.4', 'gpt-5.3-codex'], + authMethod: 'oauth_token' as const, + backend: { + kind: 'adapter', + label: 'Default adapter', + endpointLabel: 'chatgpt.com/backend-api/codex/responses', + }, + modelVerificationState: 'verified' as const, + modelAvailability: [ + { modelId: 'gpt-5.4', status: 'available' as const, checkedAt: null }, + { modelId: 'gpt-5.3-codex', status: 'available' as const, checkedAt: null }, + ], + authenticated: true, + supported: true, + }; + + expect(getAvailableTeamProviderModels('codex', codexProviderStatus)).toEqual([ + 'gpt-5.4', + 'gpt-5.3-codex', + ]); + expect(normalizeTeamModelForUi('codex', 'gpt-5.2-codex', codexProviderStatus)).toBe(''); + expect(normalizeTeamModelForUi('codex', 'gpt-5.4', codexProviderStatus)).toBe('gpt-5.4'); + }); + + it('waits for the runtime model list before validating explicit Codex selections', () => { + expect(getTeamModelSelectionError('codex', 'gpt-5.4')).toContain('waiting for Codex runtime verification'); + expect(getTeamModelSelectionError('codex', '')).toBeNull(); + expect(getTeamModelSelectionError('anthropic', 'opus')).toBeNull(); }); }); @@ -60,6 +129,7 @@ describe('computeEffectiveTeamModel', () => { expect(computeEffectiveTeamModel('opus', true, 'anthropic')).toBe('opus'); expect(computeEffectiveTeamModel('opus[1m]', true, 'anthropic')).toBe('opus'); expect(computeEffectiveTeamModel('opus[1m][1m]', true, 'anthropic')).toBe('opus'); + expect(computeEffectiveTeamModel('', true, 'anthropic')).toBe('opus'); }); it('returns haiku as-is', () => { diff --git a/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts b/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts index fba39cb2..640cb4a6 100644 --- a/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts +++ b/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts @@ -61,27 +61,30 @@ vi.mock('@renderer/components/ui/tabs', () => { }; }); +const storeState = { + cliStatus: null as unknown, + cliStatusLoading: false, + appConfig: { general: { multimodelEnabled: true } }, + fetchCliProviderStatus: vi.fn().mockResolvedValue(undefined), +}; + vi.mock('@renderer/store', () => ({ - useStore: (selector: (state: unknown) => unknown) => - selector({ - cliStatus: null, - appConfig: { general: { multimodelEnabled: true } }, - }), + useStore: (selector: (state: unknown) => unknown) => selector(storeState), })); import { TeamModelSelector } from '@renderer/components/team/dialogs/TeamModelSelector'; -import { - GPT_5_1_CODEX_MINI_UI_DISABLED_REASON, - GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON, -} from '@renderer/utils/teamModelAvailability'; describe('TeamModelSelector disabled Codex models', () => { afterEach(() => { document.body.innerHTML = ''; + storeState.cliStatus = null; + storeState.cliStatusLoading = false; + storeState.fetchCliProviderStatus.mockClear(); }); - it('renders 5.1 Codex Mini as disabled with an explanation tooltip', async () => { + it('shows only Default while Codex runtime models are still loading', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliStatusLoading = true; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); @@ -98,37 +101,10 @@ describe('TeamModelSelector disabled Codex models', () => { await Promise.resolve(); }); - expect(host.textContent).toContain('5.1 Codex Mini'); - expect(host.textContent).toContain('Disabled'); - expect(host.textContent).toContain(GPT_5_1_CODEX_MINI_UI_DISABLED_REASON); - - await act(async () => { - root.unmount(); - await Promise.resolve(); - }); - }); - - it('renders 5.3 Codex Spark as disabled with an explanation tooltip', async () => { - vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); - const host = document.createElement('div'); - document.body.appendChild(host); - const root = createRoot(host); - - await act(async () => { - root.render( - React.createElement(TeamModelSelector, { - providerId: 'codex', - onProviderChange: () => undefined, - value: '', - onValueChange: () => undefined, - }) - ); - await Promise.resolve(); - }); - - expect(host.textContent).toContain('5.3 Codex Spark'); - expect(host.textContent).toContain('Disabled'); - expect(host.textContent).toContain(GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON); + expect(host.textContent).toContain('Default'); + expect(host.textContent).toContain('Explicit models load from the current runtime'); + expect(host.textContent).not.toContain('5.1 Codex Mini'); + expect(host.textContent).not.toContain('5.3 Codex Spark'); await act(async () => { root.unmount(); @@ -190,6 +166,256 @@ describe('TeamModelSelector disabled Codex models', () => { }); }); + it('uses the runtime-reported Codex list and clears stale unsupported selections', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliStatus = { + providers: [ + { + providerId: 'codex', + models: ['gpt-5.4', 'gpt-5.3-codex'], + }, + ], + }; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const onValueChange = vi.fn(); + + await act(async () => { + root.render( + React.createElement(TeamModelSelector, { + providerId: 'codex', + onProviderChange: () => undefined, + value: 'gpt-5.2-codex', + onValueChange, + }) + ); + await Promise.resolve(); + }); + + expect(onValueChange).toHaveBeenCalledWith(''); + expect(host.textContent).toContain('5.4'); + expect(host.textContent).toContain('5.3 Codex'); + expect(host.textContent).not.toContain('5.2 Codex'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('shows 5.2 Codex as a disabled tile when the runtime still reports it', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliStatus = { + providers: [ + { + providerId: 'codex', + models: ['gpt-5.4', 'gpt-5.2-codex'], + modelVerificationState: 'idle', + modelAvailability: [], + }, + ], + }; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const onValueChange = vi.fn(); + + await act(async () => { + root.render( + React.createElement(TeamModelSelector, { + providerId: 'codex', + onProviderChange: () => undefined, + value: '', + onValueChange, + }) + ); + await Promise.resolve(); + }); + + const disabledButton = Array.from(host.querySelectorAll('button')).find((button) => + button.textContent?.includes('5.2 Codex') + ); + + expect(disabledButton).not.toBeNull(); + expect(disabledButton?.getAttribute('aria-disabled')).toBe('true'); + expect(disabledButton?.textContent).toContain('Disabled'); + expect(disabledButton?.getAttribute('title')).toContain( + 'Not available with Codex ChatGPT subscription' + ); + + await act(async () => { + disabledButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(onValueChange).not.toHaveBeenCalled(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('shows 5.1 Codex Max as a disabled tile on the ChatGPT subscription path', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliStatus = { + providers: [ + { + providerId: 'codex', + authMethod: 'oauth_token', + backend: { + kind: 'adapter', + label: 'Default adapter', + endpointLabel: 'chatgpt.com/backend-api/codex/responses', + }, + models: ['gpt-5.4', 'gpt-5.1-codex-max'], + modelVerificationState: 'idle', + modelAvailability: [], + }, + ], + }; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const onValueChange = vi.fn(); + + await act(async () => { + root.render( + React.createElement(TeamModelSelector, { + providerId: 'codex', + onProviderChange: () => undefined, + value: '', + onValueChange, + }) + ); + await Promise.resolve(); + }); + + const disabledButton = Array.from(host.querySelectorAll('button')).find((button) => + button.textContent?.includes('5.1 Codex Max') + ); + + expect(disabledButton).not.toBeNull(); + expect(disabledButton?.getAttribute('aria-disabled')).toBe('true'); + expect(disabledButton?.textContent).toContain('Disabled'); + expect(disabledButton?.getAttribute('title')).toContain( + 'Not available with Codex ChatGPT subscription' + ); + + await act(async () => { + disabledButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(onValueChange).not.toHaveBeenCalled(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('keeps runtime model buttons selectable without starting automatic model probes', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliStatus = { + providers: [ + { + providerId: 'codex', + models: ['gpt-5.4', 'gpt-5.4-mini'], + modelVerificationState: 'idle', + modelAvailability: [], + }, + ], + }; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const onValueChange = vi.fn(); + + await act(async () => { + root.render( + React.createElement(TeamModelSelector, { + providerId: 'codex', + onProviderChange: () => undefined, + value: '', + onValueChange, + }) + ); + await Promise.resolve(); + }); + + expect(storeState.fetchCliProviderStatus).not.toHaveBeenCalled(); + + const gpt54Button = Array.from(host.querySelectorAll('button')).find((button) => + button.textContent?.includes('5.4') + ); + expect(gpt54Button?.getAttribute('aria-disabled')).toBe('false'); + + await act(async () => { + gpt54Button?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(onValueChange).toHaveBeenCalledWith('gpt-5.4'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('highlights the specific model tile when preflight found a model issue', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliStatus = { + providers: [ + { + providerId: 'codex', + models: ['gpt-5.4', 'gpt-5.2-codex'], + modelVerificationState: 'idle', + modelAvailability: [], + }, + ], + }; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(TeamModelSelector, { + providerId: 'codex', + onProviderChange: () => undefined, + value: 'gpt-5.2-codex', + onValueChange: () => undefined, + modelIssueReasonByValue: { + 'gpt-5.2-codex': 'Not available with Codex ChatGPT subscription', + }, + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Issue'); + const issueButton = Array.from(host.querySelectorAll('button')).find((button) => + button.textContent?.includes('5.2 Codex') + ); + expect(issueButton?.className).toContain('border-red-500/40'); + expect(issueButton?.getAttribute('title')).toBe( + 'Not available with Codex ChatGPT subscription' + ); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('shows OpenCode as an in-development provider and keeps it non-selectable', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); const host = document.createElement('div'); diff --git a/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts b/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts index 6d5f69cf..7c69269d 100644 --- a/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts +++ b/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts @@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { + getPrimaryProvisioningFailureDetail, ProvisioningProviderStatusList, createInitialProviderChecks, } from '@renderer/components/team/dialogs/ProvisioningProviderStatusList'; @@ -35,4 +36,96 @@ describe('ProvisioningProviderStatusList', () => { await Promise.resolve(); }); }); + + it('surfaces mixed selected model diagnostics without hiding verified results', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(ProvisioningProviderStatusList, { + checks: [ + { + providerId: 'codex', + status: 'failed', + backendSummary: 'Default adapter', + details: [ + '5.4 Mini - verified', + '5.1 Codex Max - unavailable - Not available with Codex ChatGPT subscription', + ], + }, + ], + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain( + 'Codex (Default adapter): Selected model checks - 1 model unavailable, 1 verified' + ); + expect(host.textContent).toContain('5.4 Mini - verified'); + expect(host.textContent).toContain( + '5.1 Codex Max - unavailable - Not available with Codex ChatGPT subscription' + ); + + const detailLines = Array.from(host.querySelectorAll('p')); + expect(detailLines[0]?.className).toContain('text-emerald-400'); + expect(detailLines[1]?.className).toContain('text-red-300'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('picks the first real failure detail instead of a verified line', () => { + expect( + getPrimaryProvisioningFailureDetail([ + { + providerId: 'codex', + status: 'failed', + details: [ + '5.2 - verified', + '5.3 Codex - check failed - Model verification timed out', + '5.1 Codex Max - unavailable - Not available with Codex ChatGPT subscription', + ], + }, + ]) + ).toBe('5.1 Codex Max - unavailable - Not available with Codex ChatGPT subscription'); + }); + + it('summarizes timed out model verification separately from hard failures', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(ProvisioningProviderStatusList, { + checks: [ + { + providerId: 'codex', + status: 'notes', + backendSummary: 'Default adapter', + details: ['5.3 Codex - check failed - Model verification timed out'], + }, + ], + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain( + 'Codex (Default adapter): Selected model checks - 1 model timed out' + ); + expect(host.textContent).toContain('5.3 Codex - check failed - Model verification timed out'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts b/test/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts new file mode 100644 index 00000000..ecd70446 --- /dev/null +++ b/test/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts @@ -0,0 +1,352 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { runProviderPrepareDiagnostics } from '@renderer/components/team/dialogs/providerPrepareDiagnostics'; +import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection'; + +import type { TeamProvisioningPrepareResult } from '@shared/types'; + +function createDeferred(): { + promise: Promise; + resolve: (value: T) => void; +} { + let resolve!: (value: T) => void; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + return { promise, resolve }; +} + +describe('runProviderPrepareDiagnostics', () => { + it('returns a failed provider result immediately when runtime preflight fails', async () => { + const prepareProvisioning = vi.fn< + ( + cwd?: string, + providerId?: 'anthropic' | 'codex' | 'gemini', + providerIds?: ('anthropic' | 'codex' | 'gemini')[], + selectedModels?: string[] + ) => Promise + >().mockResolvedValue({ + ready: false, + message: 'Codex runtime is not authenticated.', + }); + + const result = await runProviderPrepareDiagnostics({ + cwd: '/tmp/project', + providerId: 'codex', + selectedModelIds: ['gpt-5.4'], + prepareProvisioning, + }); + + expect(result.status).toBe('failed'); + expect(result.details).toEqual(['Codex runtime is not authenticated.']); + expect(prepareProvisioning).toHaveBeenCalledTimes(1); + }); + + it('emits per-model progress updates and keeps failures scoped to the affected model', async () => { + const deferred54 = createDeferred(); + const deferred52 = createDeferred(); + const progressUpdates: Array<{ details: string[]; completedCount: number; totalCount: number }> = + []; + + const prepareProvisioning = vi.fn< + ( + cwd?: string, + providerId?: 'anthropic' | 'codex' | 'gemini', + providerIds?: ('anthropic' | 'codex' | 'gemini')[], + selectedModels?: string[] + ) => Promise + >((_, __, ___, selectedModels) => { + if (!selectedModels || selectedModels.length === 0) { + return Promise.resolve({ + ready: true, + message: 'CLI is warmed up and ready to launch', + }); + } + if (selectedModels[0] === 'gpt-5.4') { + return deferred54.promise; + } + return deferred52.promise; + }); + + const resultPromise = runProviderPrepareDiagnostics({ + cwd: '/tmp/project', + providerId: 'codex', + selectedModelIds: ['gpt-5.4', 'gpt-5.2-codex'], + prepareProvisioning, + onModelProgress: (progress) => progressUpdates.push(progress), + }); + + await Promise.resolve(); + expect(progressUpdates[0]).toEqual({ + completedCount: 0, + totalCount: 2, + details: ['5.4 - checking...', '5.2 Codex - checking...'], + }); + + deferred54.resolve({ + ready: true, + message: 'CLI is warmed up and ready to launch', + details: ['Selected model gpt-5.4 verified for launch.'], + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(progressUpdates.at(-1)).toEqual({ + completedCount: 1, + totalCount: 2, + details: ['5.4 - verified', '5.2 Codex - checking...'], + }); + + deferred52.resolve({ + ready: false, + message: + "Selected model gpt-5.2-codex is unavailable. The 'gpt-5.2-codex' model is not supported when using Codex with a ChatGPT account.", + }); + const result = await resultPromise; + + expect(result.status).toBe('failed'); + expect(result.details).toEqual([ + '5.4 - verified', + '5.2 Codex - unavailable - Not available with Codex ChatGPT subscription', + ]); + expect(progressUpdates.at(-1)).toEqual({ + completedCount: 2, + totalCount: 2, + details: [ + '5.4 - verified', + '5.2 Codex - unavailable - Not available with Codex ChatGPT subscription', + ], + }); + }); + + it('normalizes raw Codex API error envelopes into a clean model reason', async () => { + const prepareProvisioning = vi.fn< + ( + cwd?: string, + providerId?: 'anthropic' | 'codex' | 'gemini', + providerIds?: ('anthropic' | 'codex' | 'gemini')[], + selectedModels?: string[] + ) => Promise + >((_, __, ___, selectedModels) => { + if (!selectedModels || selectedModels.length === 0) { + return Promise.resolve({ + ready: true, + message: 'CLI is warmed up and ready to launch', + }); + } + return Promise.resolve({ + ready: false, + message: + `API Error: 400 {"type":"error","error":{"type":"api_error","message":"Codex API error (400): {\\"detail\\":\\"The 'gpt-5.1-codex-max' model is not supported when using Codex with a ChatGPT account.\\"}"}}`, + }); + }); + + const result = await runProviderPrepareDiagnostics({ + cwd: '/tmp/project', + providerId: 'codex', + selectedModelIds: ['gpt-5.1-codex-max'], + prepareProvisioning, + }); + + expect(result.status).toBe('failed'); + expect(result.details).toEqual([ + '5.1 Codex Max - unavailable - Not available with Codex ChatGPT subscription', + ]); + }); + + it('normalizes raw timeout probe errors into a provider-agnostic reason', async () => { + const prepareProvisioning = vi.fn< + ( + cwd?: string, + providerId?: 'anthropic' | 'codex' | 'gemini', + providerIds?: ('anthropic' | 'codex' | 'gemini')[], + selectedModels?: string[] + ) => Promise + >((_, __, ___, selectedModels) => { + if (!selectedModels || selectedModels.length === 0) { + return Promise.resolve({ + ready: true, + message: 'CLI is warmed up and ready to launch', + }); + } + return Promise.resolve({ + ready: true, + message: 'CLI is warmed up and ready to launch', + warnings: [ + 'Selected model gpt-5.3-codex could not be verified. Timeout running: claude -p Output only the single word PONG. --output-format text --model gpt-5.3-codex --max-turns 1 --no-session-persistence', + ], + }); + }); + + const result = await runProviderPrepareDiagnostics({ + cwd: '/tmp/project', + providerId: 'codex', + selectedModelIds: ['gpt-5.3-codex'], + prepareProvisioning, + }); + + expect(result.status).toBe('notes'); + expect(result.details).toEqual(['5.3 Codex - check failed - Model verification timed out']); + }); + + it('renders the provider default model as a dedicated Default check line', async () => { + const progressUpdates: Array<{ details: string[]; completedCount: number; totalCount: number }> = + []; + const prepareProvisioning = vi.fn< + ( + cwd?: string, + providerId?: 'anthropic' | 'codex' | 'gemini', + providerIds?: ('anthropic' | 'codex' | 'gemini')[], + selectedModels?: string[] + ) => Promise + >((_, __, ___, selectedModels) => { + if (!selectedModels || selectedModels.length === 0) { + return Promise.resolve({ + ready: true, + message: 'CLI is warmed up and ready to launch', + }); + } + return Promise.resolve({ + ready: true, + message: 'CLI is warmed up and ready to launch', + details: [`Selected model ${DEFAULT_PROVIDER_MODEL_SELECTION} verified for launch.`], + }); + }); + + const result = await runProviderPrepareDiagnostics({ + cwd: '/tmp/project', + providerId: 'codex', + selectedModelIds: [DEFAULT_PROVIDER_MODEL_SELECTION], + prepareProvisioning, + onModelProgress: (progress) => progressUpdates.push(progress), + }); + + expect(progressUpdates[0]).toEqual({ + completedCount: 0, + totalCount: 1, + details: ['Default - checking...'], + }); + expect(result.status).toBe('ready'); + expect(result.details).toEqual(['Default - verified']); + }); + + it('forwards limitContext through model diagnostics for Anthropic default checks', async () => { + const prepareProvisioning = vi.fn< + ( + cwd?: string, + providerId?: 'anthropic' | 'codex' | 'gemini', + providerIds?: ('anthropic' | 'codex' | 'gemini')[], + selectedModels?: string[], + limitContext?: boolean + ) => Promise + >((_, __, ___, selectedModels) => { + if (!selectedModels || selectedModels.length === 0) { + return Promise.resolve({ + ready: true, + message: 'CLI is warmed up and ready to launch', + }); + } + return Promise.resolve({ + ready: true, + message: 'CLI is warmed up and ready to launch', + details: [`Selected model ${DEFAULT_PROVIDER_MODEL_SELECTION} verified for launch.`], + }); + }); + + const result = await runProviderPrepareDiagnostics({ + cwd: '/tmp/project', + providerId: 'anthropic', + selectedModelIds: [DEFAULT_PROVIDER_MODEL_SELECTION], + limitContext: true, + prepareProvisioning, + }); + + expect(result.details).toEqual(['Default - verified']); + expect(prepareProvisioning).toHaveBeenNthCalledWith( + 1, + '/tmp/project', + 'anthropic', + ['anthropic'], + undefined, + true + ); + expect(prepareProvisioning).toHaveBeenNthCalledWith( + 2, + '/tmp/project', + 'anthropic', + ['anthropic'], + [DEFAULT_PROVIDER_MODEL_SELECTION], + true + ); + }); + + it('reuses cached model results and probes only newly selected models', async () => { + const progressUpdates: Array<{ details: string[]; completedCount: number; totalCount: number }> = + []; + const prepareProvisioning = vi.fn< + ( + cwd?: string, + providerId?: 'anthropic' | 'codex' | 'gemini', + providerIds?: ('anthropic' | 'codex' | 'gemini')[], + selectedModels?: string[] + ) => Promise + >((_, __, ___, selectedModels) => { + if (!selectedModels || selectedModels.length === 0) { + return Promise.resolve({ + ready: true, + message: 'CLI is warmed up and ready to launch', + }); + } + + expect(selectedModels).toEqual(['gpt-5.2-codex']); + return Promise.resolve({ + ready: false, + message: + "Selected model gpt-5.2-codex is unavailable. The 'gpt-5.2-codex' model is not supported when using Codex with a ChatGPT account.", + }); + }); + + const result = await runProviderPrepareDiagnostics({ + cwd: '/tmp/project', + providerId: 'codex', + selectedModelIds: ['gpt-5.2', 'gpt-5.4-mini', 'gpt-5.2-codex'], + prepareProvisioning, + cachedModelResultsById: { + 'gpt-5.2': { + status: 'ready', + line: '5.2 - verified', + warningLine: null, + }, + 'gpt-5.4-mini': { + status: 'ready', + line: '5.4 Mini - verified', + warningLine: null, + }, + }, + onModelProgress: (progress) => progressUpdates.push(progress), + }); + + expect(progressUpdates[0]).toEqual({ + completedCount: 2, + totalCount: 3, + details: ['5.2 - verified', '5.4 Mini - verified', '5.2 Codex - checking...'], + }); + expect(result.details).toEqual([ + '5.2 - verified', + '5.4 Mini - verified', + '5.2 Codex - unavailable - Not available with Codex ChatGPT subscription', + ]); + expect(prepareProvisioning).toHaveBeenCalledTimes(2); + expect(prepareProvisioning).toHaveBeenNthCalledWith( + 1, + '/tmp/project', + 'codex', + ['codex'], + undefined, + undefined + ); + expect(prepareProvisioning).toHaveBeenNthCalledWith(2, '/tmp/project', 'codex', ['codex'], [ + 'gpt-5.2-codex', + ], undefined); + }); +}); diff --git a/test/renderer/components/team/dialogs/provisioningModelIssues.test.ts b/test/renderer/components/team/dialogs/provisioningModelIssues.test.ts new file mode 100644 index 00000000..69399be0 --- /dev/null +++ b/test/renderer/components/team/dialogs/provisioningModelIssues.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; + +import { getProvisioningModelIssue } from '@renderer/components/team/dialogs/provisioningModelIssues'; + +describe('getProvisioningModelIssue', () => { + it('extracts a formatted Codex model failure with clean reason', () => { + expect( + getProvisioningModelIssue( + [ + { + providerId: 'codex', + status: 'failed', + details: [ + '5.4 Mini - verified', + '5.1 Codex Max - unavailable - Not available with Codex ChatGPT subscription', + ], + }, + ], + 'codex', + 'gpt-5.1-codex-max' + ) + ).toEqual({ + providerId: 'codex', + modelId: 'gpt-5.1-codex-max', + kind: 'unavailable', + reason: 'Not available with Codex ChatGPT subscription', + detail: '5.1 Codex Max - unavailable - Not available with Codex ChatGPT subscription', + }); + }); + + it('returns null for verified models without their own failure line', () => { + expect( + getProvisioningModelIssue( + [ + { + providerId: 'codex', + status: 'failed', + details: [ + '5.4 Mini - verified', + '5.1 Codex Max - unavailable - Not available with Codex ChatGPT subscription', + ], + }, + ], + 'codex', + 'gpt-5.4-mini' + ) + ).toBeNull(); + }); +}); diff --git a/test/renderer/store/cliInstallerSlice.test.ts b/test/renderer/store/cliInstallerSlice.test.ts index 9025be90..32a5cf15 100644 --- a/test/renderer/store/cliInstallerSlice.test.ts +++ b/test/renderer/store/cliInstallerSlice.test.ts @@ -6,6 +6,7 @@ vi.mock('@renderer/api', () => ({ cliInstaller: { getStatus: vi.fn(), getProviderStatus: vi.fn(), + verifyProviderModels: vi.fn(), invalidateStatus: vi.fn(), install: vi.fn(), onProgress: vi.fn(() => vi.fn()), diff --git a/test/renderer/utils/teamModelAvailability.test.ts b/test/renderer/utils/teamModelAvailability.test.ts new file mode 100644 index 00000000..d9f0a21e --- /dev/null +++ b/test/renderer/utils/teamModelAvailability.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest'; + +import { + getAvailableTeamProviderModelOptions, + getAvailableTeamProviderModels, + getTeamModelSelectionError, + normalizeTeamModelForUi, + type TeamModelRuntimeProviderStatus, +} from '@renderer/utils/teamModelAvailability'; + +function createCodexProviderStatus( + models: string[], + overrides: Partial = {} +): TeamModelRuntimeProviderStatus { + return { + providerId: 'codex', + models, + authMethod: 'oauth_token', + backend: { + kind: 'adapter', + label: 'Default adapter', + endpointLabel: 'chatgpt.com/backend-api/codex/responses', + }, + authenticated: true, + supported: true, + modelVerificationState: 'idle', + modelAvailability: [], + ...overrides, + }; +} + +describe('teamModelAvailability', () => { + it('uses runtime-reported Codex models as the source of truth', () => { + const providerStatus = createCodexProviderStatus(['gpt-5.4', 'gpt-5.3-codex']); + + expect(getAvailableTeamProviderModels('codex', providerStatus)).toEqual([ + 'gpt-5.4', + 'gpt-5.3-codex', + ]); + }); + + it('filters Codex models that are UI-disabled even if runtime reports them', () => { + const providerStatus = createCodexProviderStatus([ + 'gpt-5.4', + 'gpt-5.3-codex-spark', + 'gpt-5.2-codex', + 'gpt-5.1-codex-mini', + 'gpt-5.1-codex-max', + ]); + + expect(getAvailableTeamProviderModels('codex', providerStatus)).toEqual(['gpt-5.4']); + }); + + it('keeps 5.1 Codex Max available outside the ChatGPT subscription path', () => { + const providerStatus = createCodexProviderStatus(['gpt-5.4', 'gpt-5.1-codex-max'], { + authMethod: 'api_key', + backend: { + kind: 'openai', + label: 'OpenAI', + endpointLabel: 'api.openai.com/v1/responses', + }, + }); + + expect(getAvailableTeamProviderModels('codex', providerStatus)).toEqual([ + 'gpt-5.4', + 'gpt-5.1-codex-max', + ]); + }); + + it('builds Codex model options from the runtime list instead of the hardcoded fallback', () => { + const providerStatus = createCodexProviderStatus(['gpt-5.4', 'gpt-5.3-codex']); + + expect(getAvailableTeamProviderModelOptions('codex', providerStatus)).toEqual([ + { value: '', label: 'Default', badgeLabel: 'Default' }, + { value: 'gpt-5.4', label: '5.4', availabilityStatus: 'available', availabilityReason: null }, + { + value: 'gpt-5.3-codex', + label: '5.3 Codex', + availabilityStatus: 'available', + availabilityReason: null, + }, + ]); + }); + + it('clears stale Codex selections when runtime no longer reports that model', () => { + const providerStatus = createCodexProviderStatus(['gpt-5.4', 'gpt-5.3-codex']); + + expect(normalizeTeamModelForUi('codex', 'gpt-5.2-codex', providerStatus)).toBe(''); + expect(normalizeTeamModelForUi('codex', 'gpt-5.4', providerStatus)).toBe('gpt-5.4'); + }); + + it('reports an explicit error when a Codex model is unsupported by the current runtime', () => { + const providerStatus = createCodexProviderStatus(['gpt-5.4', 'gpt-5.3-codex']); + + expect(getTeamModelSelectionError('codex', 'gpt-5.2-codex', providerStatus)).toContain( + 'Temporarily disabled for team agents' + ); + expect(getTeamModelSelectionError('codex', 'gpt-5.4', providerStatus)).toBeNull(); + }); + + it('waits for the runtime model list before validating explicit Codex selections', () => { + expect(getTeamModelSelectionError('codex', 'gpt-5.4')).toContain( + 'waiting for Codex runtime verification' + ); + expect(getTeamModelSelectionError('codex', '')).toBeNull(); + }); + + it('keeps runtime models selectable without per-model verification state', () => { + const providerStatus = createCodexProviderStatus(['gpt-5.4']); + expect(normalizeTeamModelForUi('codex', 'gpt-5.4', providerStatus)).toBe('gpt-5.4'); + expect(getAvailableTeamProviderModels('codex', providerStatus)).toEqual(['gpt-5.4']); + expect(getTeamModelSelectionError('codex', 'gpt-5.4', providerStatus)).toBeNull(); + }); + + it('does not require runtime verification for Anthropic curated models', () => { + expect(normalizeTeamModelForUi('anthropic', 'opus')).toBe('opus'); + expect(getTeamModelSelectionError('anthropic', 'opus')).toBeNull(); + }); +}); diff --git a/test/renderer/utils/teamModelCatalog.test.ts b/test/renderer/utils/teamModelCatalog.test.ts index d332928d..25d0f55f 100644 --- a/test/renderer/utils/teamModelCatalog.test.ts +++ b/test/renderer/utils/teamModelCatalog.test.ts @@ -20,7 +20,6 @@ describe('teamModelCatalog', () => { 'gpt-5.4-mini', 'gpt-5.3-codex', 'gpt-5.2', - 'gpt-5.2-codex', 'gpt-5.1-codex-max', ]); }); diff --git a/test/renderer/utils/teamProvisioningPresentation.test.ts b/test/renderer/utils/teamProvisioningPresentation.test.ts index f24f8896..723e8e85 100644 --- a/test/renderer/utils/teamProvisioningPresentation.test.ts +++ b/test/renderer/utils/teamProvisioningPresentation.test.ts @@ -35,4 +35,128 @@ describe('buildTeamProvisioningPresentation', () => { expect(presentation?.compactTitle).toBe('Team launched'); expect(presentation?.compactDetail).toBe('Lead online'); }); + + it('surfaces the failed teammate reason while launch is still active', () => { + const presentation = buildTeamProvisioningPresentation({ + progress: { + runId: 'run-2', + teamName: 'codex-team', + state: 'assembling', + startedAt: '2026-04-13T10:00:00.000Z', + updatedAt: '2026-04-13T10:00:05.000Z', + message: 'Spawning member jack...', + messageSeverity: undefined, + pid: 4321, + cliLogsTail: '', + assistantOutput: '', + }, + members: [ + { + name: 'team-lead', + agentType: 'team-lead', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }, + { + name: 'jack', + agentType: 'engineer', + status: 'unknown', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }, + ], + memberSpawnStatuses: { + jack: { + status: 'error', + launchState: 'failed_to_start', + error: + "The 'gpt-5.2-codex' model is not supported when using Codex with a ChatGPT account.", + hardFailureReason: + "The 'gpt-5.2-codex' model is not supported when using Codex with a ChatGPT account.", + updatedAt: '2026-04-13T10:00:03.000Z', + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + agentToolAccepted: true, + firstSpawnAcceptedAt: '2026-04-13T10:00:01.000Z', + }, + }, + memberSpawnSnapshot: undefined, + }); + + expect(presentation?.panelMessage).toContain('jack failed to start'); + expect(presentation?.panelMessage).toContain('gpt-5.2-codex'); + expect(presentation?.panelMessageSeverity).toBe('warning'); + expect(presentation?.compactDetail).toBe('jack failed to start'); + expect(presentation?.compactTone).toBe('warning'); + }); + + it('surfaces the failed teammate reason after launch completes with errors', () => { + const presentation = buildTeamProvisioningPresentation({ + progress: { + runId: 'run-3', + teamName: 'codex-team', + state: 'ready', + startedAt: '2026-04-13T10:00:00.000Z', + updatedAt: '2026-04-13T10:00:08.000Z', + message: 'Launch completed with teammate errors - jack failed to start', + messageSeverity: 'warning', + pid: 4321, + cliLogsTail: '', + assistantOutput: '', + }, + members: [ + { + name: 'team-lead', + agentType: 'team-lead', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }, + { + name: 'jack', + agentType: 'engineer', + status: 'unknown', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }, + ], + memberSpawnStatuses: { + jack: { + status: 'error', + launchState: 'failed_to_start', + error: 'The requested model is not available for your account.', + hardFailureReason: 'The requested model is not available for your account.', + updatedAt: '2026-04-13T10:00:03.000Z', + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + agentToolAccepted: true, + firstSpawnAcceptedAt: '2026-04-13T10:00:01.000Z', + }, + }, + memberSpawnSnapshot: { + expectedMembers: ['jack'], + summary: { + confirmedCount: 0, + pendingCount: 0, + failedCount: 1, + runtimeAlivePendingCount: 0, + }, + }, + }); + + expect(presentation?.successMessage).toBe('Launch finished with errors - 1/1 teammates failed to start'); + expect(presentation?.panelMessage).toContain('requested model is not available'); + expect(presentation?.compactDetail).toBe('jack failed to start'); + }); }); From 53c4204d89e13f578760185d78becace35eef1a8 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 20:58:40 +0300 Subject: [PATCH 049/121] fix(agent-graph): add launch status labels and pan guards --- .../agent-graph/src/canvas/draw-agents.ts | 94 ++++++++++++------- .../src/layout/stableSlotGeometry.ts | 2 +- packages/agent-graph/src/ports/types.ts | 2 + packages/agent-graph/src/ui/GraphView.tsx | 54 ++++++++++- .../renderer/adapters/TeamGraphAdapter.ts | 2 + .../renderer/ui/GraphActivityHud.tsx | 7 +- .../renderer/ui/GraphNodePopover.tsx | 12 ++- src/renderer/utils/memberHelpers.ts | 20 ++++ .../agent-graph/GraphNodePopover.test.ts | 6 +- .../agent-graph/TeamGraphAdapter.test.ts | 10 +- .../features/agent-graph/drawAgents.test.ts | 32 +++++++ test/renderer/utils/memberHelpers.test.ts | 83 +++++++++++++--- 12 files changed, 261 insertions(+), 63 deletions(-) diff --git a/packages/agent-graph/src/canvas/draw-agents.ts b/packages/agent-graph/src/canvas/draw-agents.ts index 21a1d228..9a31800a 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 @@ -257,11 +267,11 @@ function drawLaunchStage( 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 pulseAlpha = 0.2 + 0.14 * (0.5 + 0.5 * Math.sin(time * 3.2)); ctx.beginPath(); ctx.arc(x, y, ringR, 0, Math.PI * 2); ctx.strokeStyle = hexWithAlpha('#d4d4d8', pulseAlpha); - ctx.lineWidth = 2; + ctx.lineWidth = 2.2; ctx.stroke(); break; } @@ -270,8 +280,8 @@ function drawLaunchStage( const rotation = time * 2.4; ctx.beginPath(); ctx.arc(x, y, ringR, rotation, rotation + Math.PI * 1.15); - ctx.strokeStyle = hexWithAlpha('#f59e0b', 0.72); - ctx.lineWidth = 2; + ctx.strokeStyle = hexWithAlpha('#f59e0b', 0.8); + ctx.lineWidth = 2.2; ctx.lineCap = 'round'; ctx.stroke(); break; @@ -280,8 +290,8 @@ function drawLaunchStage( 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.75; ctx.stroke(); const orbit = time * 1.6; @@ -300,8 +310,8 @@ function drawLaunchStage( const rotation = time * 1.25; ctx.beginPath(); ctx.arc(x, y, ringR, rotation, rotation + Math.PI * arc); - ctx.strokeStyle = hexWithAlpha('#22c55e', 0.55); - ctx.lineWidth = 1.75; + ctx.strokeStyle = hexWithAlpha('#22c55e', 0.62); + ctx.lineWidth = 2; ctx.lineCap = 'round'; ctx.stroke(); break; @@ -310,8 +320,8 @@ 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.2; ctx.lineCap = 'round'; ctx.stroke(); break; @@ -467,12 +477,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 +511,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 +532,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 +643,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 +655,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 +686,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/layout/stableSlotGeometry.ts b/packages/agent-graph/src/layout/stableSlotGeometry.ts index a9e58b13..d1e5265f 100644 --- a/packages/agent-graph/src/layout/stableSlotGeometry.ts +++ b/packages/agent-graph/src/layout/stableSlotGeometry.ts @@ -9,7 +9,7 @@ export const STABLE_SLOT_GEOMETRY = { unassignedGap: 72, maxGeneratedRings: 12, ownerCollisionPadding: 28, - ownerBandHeight: 72, + ownerBandHeight: 96, ownerMinWidth: 200, processBandHeight: 32, processRailWidth: 220, diff --git a/packages/agent-graph/src/ports/types.ts b/packages/agent-graph/src/ports/types.ts index df378f75..af439ae4 100644 --- a/packages/agent-graph/src/ports/types.ts +++ b/packages/agent-graph/src/ports/types.ts @@ -84,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/GraphView.tsx b/packages/agent-graph/src/ui/GraphView.tsx index a842bbf9..bb3a43af 100644 --- a/packages/agent-graph/src/ui/GraphView.tsx +++ b/packages/agent-graph/src/ui/GraphView.tsx @@ -135,6 +135,7 @@ export function GraphView({ y: number; color?: string | null; } | null>(null); + const selectionLockRef = useRef<{ userSelect: string; webkitUserSelect: string } | null>(null); // ─── Hooks ────────────────────────────────────────────────────────────── const simulation = useGraphSimulation(); @@ -255,6 +256,30 @@ export function GraphView({ 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 animate = useCallback(() => { if (!runningRef.current) return; @@ -405,10 +430,15 @@ export function GraphView({ const handleMouseDown = useCallback( (e: React.MouseEvent) => { if (e.button !== 0) return; // only left click + e.preventDefault(); dragPreviewRef.current = null; + setInteractionSelectionDisabled(true); const canvas = canvasHandle.current?.getCanvas(); - if (!canvas) return; + if (!canvas) { + setInteractionSelectionDisabled(false); + return; + } const rect = canvas.getBoundingClientRect(); const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top); const nodes = getVisibleNodes(simulation.stateRef.current.nodes); @@ -464,6 +494,9 @@ export function GraphView({ } if (isPanningRef.current) { + if (typeof document !== 'undefined') { + document.getSelection()?.removeAllRanges(); + } camera.handlePanMove(clientX, clientY); return true; } @@ -480,6 +513,9 @@ export function GraphView({ 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); @@ -509,6 +545,7 @@ export function GraphView({ if (isPanningRef.current) { camera.handlePanEnd(); isPanningRef.current = false; + setInteractionSelectionDisabled(false); dragPreviewRef.current = null; setSelectedNodeId(null); setSelectedEdgeId(null); @@ -519,6 +556,7 @@ export function GraphView({ const clickedId = interaction.handleMouseUp(); if (wasDragging && draggedNodeId) { + setInteractionSelectionDisabled(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( @@ -547,6 +585,7 @@ export function GraphView({ return; } + setInteractionSelectionDisabled(false); if (clickedId) { setSelectedNodeId(clickedId); setSelectedEdgeId(null); @@ -585,7 +624,7 @@ export function GraphView({ } dragPreviewRef.current = null; }, - [camera, events, interaction, onOwnerSlotDrop, simulation] + [camera, events, interaction, onOwnerSlotDrop, setInteractionSelectionDisabled, simulation] ); const handleMouseMove = useCallback( @@ -640,6 +679,7 @@ export function GraphView({ useEffect(() => { const handleWindowMouseMove = (event: MouseEvent): void => { if ((event.buttons & 1) === 0) { + setInteractionSelectionDisabled(false); return; } if ( @@ -650,6 +690,7 @@ export function GraphView({ ) { return; } + event.preventDefault(); processActivePointerMove(event.clientX, event.clientY, event.buttons); }; @@ -660,6 +701,7 @@ export function GraphView({ !interaction.isDragging.current && !edgeMouseDownRef.current ) { + setInteractionSelectionDisabled(false); return; } completePointerInteraction(event.clientX, event.clientY); @@ -670,8 +712,9 @@ export function GraphView({ return () => { window.removeEventListener('mousemove', handleWindowMouseMove); window.removeEventListener('mouseup', handleWindowMouseUp); + setInteractionSelectionDisabled(false); }; - }, [completePointerInteraction, interaction, processActivePointerMove]); + }, [completePointerInteraction, interaction, processActivePointerMove, setInteractionSelectionDisabled]); const handleDoubleClick = useCallback( (e: React.MouseEvent) => { @@ -819,7 +862,10 @@ export function GraphView({ // ─── Render ───────────────────────────────────────────────────────────── return ( -
+
{visibleLanes.map((lane) => (
@@ -422,12 +422,15 @@ export const GraphActivityHud = ({ ref={(element) => { shellRefs.current.set(lane.node.id, element); }} - className="pointer-events-auto absolute z-10 origin-top-left opacity-0" + 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(); + }} >
diff --git a/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx b/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx index afc454ac..a341ec4a 100644 --- a/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx +++ b/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx @@ -320,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' diff --git a/src/renderer/utils/memberHelpers.ts b/src/renderer/utils/memberHelpers.ts index da447536..ce19b342 100644 --- a/src/renderer/utils/memberHelpers.ts +++ b/src/renderer/utils/memberHelpers.ts @@ -423,9 +423,27 @@ export interface MemberLaunchPresentation { runtimeAdvisoryLabel: string | null; runtimeAdvisoryTitle?: string; launchVisualState: MemberLaunchVisualState; + launchStatusLabel: string | null; spawnBadgeLabel: string | null; } +export function getMemberLaunchStatusLabel(visualState: MemberLaunchVisualState): string | null { + switch (visualState) { + case 'waiting': + return 'waiting to start'; + case 'spawning': + return 'starting'; + case 'runtime_pending': + return 'connecting'; + case 'settling': + return 'joining team'; + case 'error': + return 'failed'; + default: + return null; + } +} + export function buildMemberLaunchPresentation({ member, spawnStatus, @@ -511,6 +529,7 @@ export function buildMemberLaunchPresentation({ } } + const launchStatusLabel = getMemberLaunchStatusLabel(launchVisualState); const spawnBadgeLabel = spawnStatus && spawnStatus !== 'online' ? spawnStatus === 'waiting' || spawnStatus === 'spawning' @@ -525,6 +544,7 @@ export function buildMemberLaunchPresentation({ runtimeAdvisoryLabel, runtimeAdvisoryTitle, launchVisualState, + launchStatusLabel, spawnBadgeLabel, }; } diff --git a/test/renderer/features/agent-graph/GraphNodePopover.test.ts b/test/renderer/features/agent-graph/GraphNodePopover.test.ts index 7eaa65b5..e5d8ce6b 100644 --- a/test/renderer/features/agent-graph/GraphNodePopover.test.ts +++ b/test/renderer/features/agent-graph/GraphNodePopover.test.ts @@ -70,7 +70,7 @@ describe('GraphNodePopover spawn badge labels', () => { vi.unstubAllGlobals(); }); - it('shows human-facing starting for raw waiting/spawning spawn statuses', async () => { + it('shows human-readable launch-status labels for waiting and spawning spawn states', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); const host = document.createElement('div'); document.body.appendChild(host); @@ -96,9 +96,9 @@ describe('GraphNodePopover spawn badge labels', () => { await Promise.resolve(); }); + expect(host.textContent).toContain('waiting to start'); expect(host.textContent).toContain('starting'); expect(host.textContent).toContain('Codex · GPT-5.4 Mini · Medium'); - expect(host.textContent).not.toContain('waiting'); expect(host.textContent).not.toContain('spawning'); await act(async () => { @@ -193,7 +193,7 @@ describe('GraphNodePopover spawn badge labels', () => { await Promise.resolve(); }); - expect(host.textContent).toContain('online'); + expect(host.textContent).toContain('connecting'); expect(host.textContent).not.toContain('Idle'); await act(async () => { diff --git a/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts index 41ebe1bf..77173f65 100644 --- a/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts +++ b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts @@ -1074,7 +1074,10 @@ describe('TeamGraphAdapter particles', () => { } as never ); - expect(findNode(graph, 'member:my-team:alice')?.launchVisualState).toBe('runtime_pending'); + expect(findNode(graph, 'member:my-team:alice')).toMatchObject({ + launchVisualState: 'runtime_pending', + launchStatusLabel: 'connecting', + }); }); it('keeps confirmed teammates in settling visuals while launch is still joining', () => { @@ -1121,7 +1124,10 @@ describe('TeamGraphAdapter particles', () => { } as never ); - expect(findNode(graph, 'member:my-team:alice')?.launchVisualState).toBe('settling'); + expect(findNode(graph, 'member:my-team:alice')).toMatchObject({ + launchVisualState: 'settling', + launchStatusLabel: 'joining team', + }); }); it('scopes inbox particle ids by team name to avoid cross-team collisions', () => { diff --git a/test/renderer/features/agent-graph/drawAgents.test.ts b/test/renderer/features/agent-graph/drawAgents.test.ts index 4d5bcbb7..1208a2c6 100644 --- a/test/renderer/features/agent-graph/drawAgents.test.ts +++ b/test/renderer/features/agent-graph/drawAgents.test.ts @@ -108,4 +108,36 @@ describe('drawAgents', () => { expect(runtimeCall!.y).toBeGreaterThan(labelCall!.y); expect(toolCall!.y).toBeLessThan(node.y!); }); + + it('renders launch text as a third label line and removes old ad-hoc waiting text', () => { + const { ctx, fillTextCalls } = createMockContext(); + const node: GraphNode = { + id: 'member:demo:alice', + kind: 'member', + label: 'alice', + state: 'idle', + color: '#60a5fa', + runtimeLabel: 'Codex · GPT-5.4 Mini · Medium', + launchVisualState: 'runtime_pending', + launchStatusLabel: 'connecting', + spawnStatus: 'online', + domainRef: { kind: 'member', teamName: 'demo', memberName: 'alice' }, + x: 320, + y: 240, + }; + + drawAgents(ctx, [node], 0, null, null, null, 1); + + const labelCall = fillTextCalls.find((call) => call.text === 'alice'); + const runtimeCall = fillTextCalls.find((call) => call.text.includes('Codex')); + const launchCall = fillTextCalls.find((call) => call.text === 'connecting'); + + expect(labelCall).toBeDefined(); + expect(runtimeCall).toBeDefined(); + expect(launchCall).toBeDefined(); + expect(runtimeCall!.y).toBeGreaterThan(labelCall!.y); + expect(launchCall!.y).toBeGreaterThan(runtimeCall!.y); + expect(fillTextCalls.some((call) => call.text === 'waiting...')).toBe(false); + expect(fillTextCalls.some((call) => call.text === 'connecting...')).toBe(false); + }); }); diff --git a/test/renderer/utils/memberHelpers.test.ts b/test/renderer/utils/memberHelpers.test.ts index 56a8dfe9..b626c0fe 100644 --- a/test/renderer/utils/memberHelpers.test.ts +++ b/test/renderer/utils/memberHelpers.test.ts @@ -177,33 +177,90 @@ describe('memberHelpers spawn-aware presence', () => { }); it('derives runtime-pending and settling visual states from the same launch inputs', () => { + const runtimePending = buildMemberLaunchPresentation({ + member, + spawnStatus: 'online', + spawnLaunchState: 'runtime_pending_bootstrap', + spawnLivenessSource: 'process', + spawnRuntimeAlive: true, + runtimeAdvisory: undefined, + isLaunchSettling: false, + isTeamAlive: true, + isTeamProvisioning: false, + }); + + const settling = buildMemberLaunchPresentation({ + member, + spawnStatus: 'online', + spawnLaunchState: 'confirmed_alive', + spawnLivenessSource: 'heartbeat', + spawnRuntimeAlive: true, + runtimeAdvisory: undefined, + isLaunchSettling: true, + isTeamAlive: true, + isTeamProvisioning: false, + }); + + expect(runtimePending.launchVisualState).toBe('runtime_pending'); + expect(runtimePending.launchStatusLabel).toBe('connecting'); + expect(settling.launchVisualState).toBe('settling'); + expect(settling.launchStatusLabel).toBe('joining team'); + }); + + it('returns shared launch status labels without changing generic presence labels', () => { expect( buildMemberLaunchPresentation({ member, - spawnStatus: 'online', - spawnLaunchState: 'runtime_pending_bootstrap', - spawnLivenessSource: 'process', - spawnRuntimeAlive: true, + spawnStatus: 'waiting', + spawnLaunchState: 'starting', + spawnLivenessSource: undefined, + spawnRuntimeAlive: false, runtimeAdvisory: undefined, isLaunchSettling: false, isTeamAlive: true, isTeamProvisioning: false, - }).launchVisualState - ).toBe('runtime_pending'); + }) + ).toMatchObject({ + presenceLabel: 'starting', + launchVisualState: 'waiting', + launchStatusLabel: 'waiting to start', + }); expect( buildMemberLaunchPresentation({ member, - spawnStatus: 'online', - spawnLaunchState: 'confirmed_alive', - spawnLivenessSource: 'heartbeat', - spawnRuntimeAlive: true, + spawnStatus: 'spawning', + spawnLaunchState: 'starting', + spawnLivenessSource: undefined, + spawnRuntimeAlive: false, runtimeAdvisory: undefined, - isLaunchSettling: true, + isLaunchSettling: false, isTeamAlive: true, isTeamProvisioning: false, - }).launchVisualState - ).toBe('settling'); + }) + ).toMatchObject({ + presenceLabel: 'starting', + launchVisualState: 'spawning', + launchStatusLabel: 'starting', + }); + + expect( + buildMemberLaunchPresentation({ + member, + spawnStatus: 'error', + spawnLaunchState: 'failed_to_start', + spawnLivenessSource: undefined, + spawnRuntimeAlive: false, + runtimeAdvisory: undefined, + isLaunchSettling: false, + isTeamAlive: true, + isTeamProvisioning: false, + }) + ).toMatchObject({ + presenceLabel: 'spawn failed', + launchVisualState: 'error', + launchStatusLabel: 'failed', + }); }); it('renders unified retry advisory labels for provider retries', () => { From ece2991f965cb9c54674342b1ec7c937b6536135 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 21:02:33 +0300 Subject: [PATCH 050/121] feat(team): enhance team provisioning with runtime model handling - Added support for live runtime model metadata in team provisioning. - Implemented functions to extract and manage CLI flag values for team members. - Updated member specifications to include effective models based on provider defaults. - Enhanced UI dialogs to check selected providers in parallel, improving responsiveness. - Added tests for handling model unavailability during team bootstrap and launch processes. --- .../services/team/TeamProvisioningService.ts | 256 ++++++++++++++---- .../team/dialogs/CreateTeamDialog.tsx | 208 +++++++------- .../team/dialogs/LaunchTeamDialog.tsx | 130 +++++---- .../components/team/members/MemberCard.tsx | 3 +- .../components/team/members/MemberList.tsx | 23 +- src/renderer/store/slices/teamSlice.ts | 1 + src/renderer/utils/memberRuntimeSummary.ts | 41 +++ src/shared/types/team.ts | 2 + .../team/TeamProvisioningService.test.ts | 79 +++++- .../TeamProvisioningServicePrompts.test.ts | 133 +++++++++ .../utils/memberRuntimeSummary.test.ts | 66 +++++ 11 files changed, 719 insertions(+), 223 deletions(-) create mode 100644 src/renderer/utils/memberRuntimeSummary.ts create mode 100644 test/renderer/utils/memberRuntimeSummary.test.ts diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 18b4de9f..9ace3693 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -783,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; @@ -911,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, }; @@ -3396,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) { @@ -3426,6 +3465,7 @@ export class TeamProvisioningService { }); const snapshot = persisted ?? liveSnapshot; const statuses = snapshotToMemberSpawnStatuses(snapshot); + this.attachLiveRuntimeMetadataToStatuses(teamName, statuses); return { statuses, runId, @@ -3552,7 +3592,6 @@ export class TeamProvisioningService { !current || current.launchState === 'failed_to_start' || current.launchState === 'confirmed_alive' || - current.runtimeAlive === true || current.hardFailure === true || current.agentToolAccepted !== true ) { @@ -3902,6 +3941,89 @@ export class TeamProvisioningService { : 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 @@ -4756,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(); @@ -4864,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) { @@ -4963,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(), @@ -5300,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, @@ -5448,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) { @@ -6842,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 = ''; @@ -6857,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; @@ -6869,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 { @@ -7107,7 +7271,7 @@ export class TeamProvisioningService { current.hardFailure = false; current.hardFailureReason = undefined; } - if (!current.bootstrapConfirmed && !runtimeAlive && !current.hardFailure) { + if (!current.bootstrapConfirmed && !current.hardFailure) { const transcriptFailureReason = await this.findBootstrapTranscriptFailureReason( teamName, expected, diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 0dd81283..1a3037f6 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -593,7 +593,7 @@ export const CreateTeamDialog = ({ selectedMemberProviders ); setPrepareState('loading'); - setPrepareMessage('Checking selected providers...'); + setPrepareMessage('Checking selected providers in parallel...'); setPrepareWarnings([]); setPrepareChecks(initialChecks); @@ -601,118 +601,128 @@ export const CreateTeamDialog = ({ const timer = setTimeout(() => { void (async () => { let checks = initialChecks; - let anyFailure = false; - let anyNotes = false; - const collectedWarnings: string[] = []; - - try { - for (const providerId of selectedMemberProviders) { - const selectedModelChecks = (() => { - const next = new Set(); - let hasDefaultSelection = false; - const supportsProviderDefaultCheck = - providerId === 'codex' || - providerId === 'gemini' || - (providerId === 'anthropic' && selectedProviderId === 'anthropic'); - const leadModel = computeEffectiveTeamModel( - selectedModel, - limitContext, - selectedProviderId - ); - if (selectedProviderId === providerId && selectedModel.trim()) { - if (leadModel?.trim()) { - next.add(leadModel.trim()); - } - } else if (selectedProviderId === providerId && supportsProviderDefaultCheck) { + const providerPlans = selectedMemberProviders.map((providerId) => { + const selectedModelChecks = (() => { + const next = new Set(); + let hasDefaultSelection = false; + const supportsProviderDefaultCheck = + providerId === 'codex' || + providerId === 'gemini' || + (providerId === 'anthropic' && selectedProviderId === 'anthropic'); + const leadModel = computeEffectiveTeamModel( + selectedModel, + limitContext, + selectedProviderId + ); + if (selectedProviderId === providerId && selectedModel.trim()) { + if (leadModel?.trim()) { + next.add(leadModel.trim()); + } + } else if (selectedProviderId === providerId && supportsProviderDefaultCheck) { + hasDefaultSelection = true; + } + for (const member of effectiveMemberDrafts) { + if (member.removedAt) { + continue; + } + const memberProviderId = + normalizeOptionalTeamProviderId(member.providerId) ?? selectedProviderId; + if (memberProviderId !== providerId) { + continue; + } + const memberModel = member.model?.trim(); + if (memberModel) { + next.add(memberModel); + } else if (supportsProviderDefaultCheck) { hasDefaultSelection = true; } - for (const member of effectiveMemberDrafts) { - if (member.removedAt) { - continue; - } - const memberProviderId = - normalizeOptionalTeamProviderId(member.providerId) ?? selectedProviderId; - if (memberProviderId !== providerId) { - continue; - } - const memberModel = member.model?.trim(); - if (memberModel) { - next.add(memberModel); - } else if (supportsProviderDefaultCheck) { - hasDefaultSelection = true; - } - } - if (supportsProviderDefaultCheck && hasDefaultSelection) { - next.add(DEFAULT_PROVIDER_MODEL_SELECTION); - } - return Array.from(next); - })(); - const backendSummary = - runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null; - const cacheKey = buildPrepareModelCacheKey(effectiveCwd, providerId, backendSummary); - const cachedModelResultsById = prepareModelResultsCacheRef.current.get(cacheKey) ?? {}; - const cachedSnapshot = getProviderPrepareCachedSnapshot({ - providerId, - selectedModelIds: selectedModelChecks, - cachedModelResultsById, - }); - checks = updateProviderCheck(checks, providerId, { - status: selectedModelChecks.length > 0 ? cachedSnapshot.status : 'checking', - backendSummary, - details: cachedSnapshot.details, - }); - if (!cancelled && prepareRequestSeqRef.current === requestSeq) { - setPrepareChecks(checks); - setPrepareMessage( - selectedModelChecks.length > 0 - ? `Checking ${getProviderLabel(providerId)} runtime and selected model checks ${cachedSnapshot.completedCount}/${cachedSnapshot.totalCount}...` - : `Checking ${getProviderLabel(providerId)} runtime...` - ); } + if (supportsProviderDefaultCheck && hasDefaultSelection) { + next.add(DEFAULT_PROVIDER_MODEL_SELECTION); + } + return Array.from(next); + })(); + const backendSummary = runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null; + const cacheKey = buildPrepareModelCacheKey(effectiveCwd, providerId, backendSummary); + const cachedModelResultsById = prepareModelResultsCacheRef.current.get(cacheKey) ?? {}; + const cachedSnapshot = getProviderPrepareCachedSnapshot({ + providerId, + selectedModelIds: selectedModelChecks, + cachedModelResultsById, + }); + return { + providerId, + selectedModelChecks, + backendSummary, + cacheKey, + cachedModelResultsById, + cachedSnapshot, + }; + }); - const prepResult = await runProviderPrepareDiagnostics({ - cwd: effectiveCwd, - providerId, - selectedModelIds: selectedModelChecks, - prepareProvisioning: api.teams.prepareProvisioning, - limitContext, - cachedModelResultsById, - onModelProgress: ({ details, completedCount, totalCount }) => { - checks = updateProviderCheck(checks, providerId, { - status: 'checking', - backendSummary, - details, - }); - if (!cancelled && prepareRequestSeqRef.current === requestSeq) { - setPrepareChecks(checks); - setPrepareMessage( - `Checking ${getProviderLabel(providerId)} runtime and selected model checks ${completedCount}/${totalCount}...` - ); - } - }, + try { + for (const plan of providerPlans) { + checks = updateProviderCheck(checks, plan.providerId, { + status: plan.selectedModelChecks.length > 0 ? plan.cachedSnapshot.status : 'checking', + backendSummary: plan.backendSummary, + details: plan.cachedSnapshot.details, }); - if (prepResult.warnings.length > 0) { + } + if (!cancelled && prepareRequestSeqRef.current === requestSeq) { + setPrepareChecks(checks); + } + const providerResults = await Promise.all( + providerPlans.map(async (plan) => { + const prepResult = await runProviderPrepareDiagnostics({ + cwd: effectiveCwd, + providerId: plan.providerId, + selectedModelIds: plan.selectedModelChecks, + prepareProvisioning: api.teams.prepareProvisioning, + limitContext, + cachedModelResultsById: plan.cachedModelResultsById, + onModelProgress: ({ details }) => { + checks = updateProviderCheck(checks, plan.providerId, { + status: 'checking', + backendSummary: plan.backendSummary, + details, + }); + if (!cancelled && prepareRequestSeqRef.current === requestSeq) { + setPrepareChecks(checks); + } + }, + }); + return { ...plan, prepResult }; + }) + ); + let anyFailure = false; + let anyNotes = false; + const collectedWarnings: string[] = []; + for (const plan of providerResults) { + if (plan.prepResult.warnings.length > 0) { anyNotes = true; collectedWarnings.push( - ...prepResult.warnings.map( - (warning) => `${getProviderLabel(providerId)}: ${warning}` + ...plan.prepResult.warnings.map( + (warning) => `${getProviderLabel(plan.providerId)}: ${warning}` ) ); } - if (prepResult.status === 'failed') { + if (plan.prepResult.status === 'failed') { anyFailure = true; - } else if (prepResult.status === 'notes') { + } else if (plan.prepResult.status === 'notes') { anyNotes = true; } - prepareModelResultsCacheRef.current.set(cacheKey, prepResult.modelResultsById); - checks = updateProviderCheck(checks, providerId, { - status: prepResult.status, - backendSummary, - details: prepResult.details, + prepareModelResultsCacheRef.current.set( + plan.cacheKey, + plan.prepResult.modelResultsById + ); + checks = updateProviderCheck(checks, plan.providerId, { + status: plan.prepResult.status, + backendSummary: plan.backendSummary, + details: plan.prepResult.details, }); - if (!cancelled && prepareRequestSeqRef.current === requestSeq) { - setPrepareChecks(checks); - } + } + if (!cancelled && prepareRequestSeqRef.current === requestSeq) { + setPrepareChecks(checks); } if (cancelled || prepareRequestSeqRef.current !== requestSeq) return; const failureMessage = diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 16d8993b..fb9b0a30 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -920,82 +920,92 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen selectedMemberProviders ); setPrepareState('loading'); - setPrepareMessage('Checking selected providers...'); + setPrepareMessage('Checking selected providers in parallel...'); setPrepareWarnings([]); setPrepareChecks(initialChecks); void (async () => { let checks = initialChecks; - let anyFailure = false; - let anyNotes = false; - const collectedWarnings: string[] = []; + const providerPlans = selectedMemberProviders.map((providerId) => { + const selectedModelChecks = selectedModelChecksByProvider.get(providerId) ?? []; + const backendSummary = runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null; + const cacheKey = buildPrepareModelCacheKey(effectiveCwd, providerId, backendSummary); + const cachedModelResultsById = prepareModelResultsCacheRef.current.get(cacheKey) ?? {}; + const cachedSnapshot = getProviderPrepareCachedSnapshot({ + providerId, + selectedModelIds: selectedModelChecks, + cachedModelResultsById, + }); + return { + providerId, + selectedModelChecks, + backendSummary, + cacheKey, + cachedModelResultsById, + cachedSnapshot, + }; + }); try { - for (const providerId of selectedMemberProviders) { - const selectedModelChecks = selectedModelChecksByProvider.get(providerId) ?? []; - const backendSummary = runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null; - const cacheKey = buildPrepareModelCacheKey(effectiveCwd, providerId, backendSummary); - const cachedModelResultsById = prepareModelResultsCacheRef.current.get(cacheKey) ?? {}; - const cachedSnapshot = getProviderPrepareCachedSnapshot({ - providerId, - selectedModelIds: selectedModelChecks, - cachedModelResultsById, + for (const plan of providerPlans) { + checks = updateProviderCheck(checks, plan.providerId, { + status: plan.selectedModelChecks.length > 0 ? plan.cachedSnapshot.status : 'checking', + backendSummary: plan.backendSummary, + details: plan.cachedSnapshot.details, }); - checks = updateProviderCheck(checks, providerId, { - status: selectedModelChecks.length > 0 ? cachedSnapshot.status : 'checking', - backendSummary, - details: cachedSnapshot.details, - }); - if (!cancelled && prepareRequestSeqRef.current === requestSeq) { - setPrepareChecks(checks); - setPrepareMessage( - selectedModelChecks.length > 0 - ? `Checking ${getProviderLabel(providerId)} runtime and selected model checks ${cachedSnapshot.completedCount}/${cachedSnapshot.totalCount}...` - : `Checking ${getProviderLabel(providerId)} runtime...` - ); - } - - const prepResult = await runProviderPrepareDiagnostics({ - cwd: effectiveCwd, - providerId, - selectedModelIds: selectedModelChecks, - prepareProvisioning: api.teams.prepareProvisioning, - limitContext, - cachedModelResultsById, - onModelProgress: ({ details, completedCount, totalCount }) => { - checks = updateProviderCheck(checks, providerId, { - status: 'checking', - backendSummary, - details, - }); - if (!cancelled && prepareRequestSeqRef.current === requestSeq) { - setPrepareChecks(checks); - setPrepareMessage( - `Checking ${getProviderLabel(providerId)} runtime and selected model checks ${completedCount}/${totalCount}...` - ); - } - }, - }); - if (prepResult.warnings.length > 0) { + } + if (!cancelled && prepareRequestSeqRef.current === requestSeq) { + setPrepareChecks(checks); + } + const providerResults = await Promise.all( + providerPlans.map(async (plan) => { + const prepResult = await runProviderPrepareDiagnostics({ + cwd: effectiveCwd, + providerId: plan.providerId, + selectedModelIds: plan.selectedModelChecks, + prepareProvisioning: api.teams.prepareProvisioning, + limitContext, + cachedModelResultsById: plan.cachedModelResultsById, + onModelProgress: ({ details }) => { + checks = updateProviderCheck(checks, plan.providerId, { + status: 'checking', + backendSummary: plan.backendSummary, + details, + }); + if (!cancelled && prepareRequestSeqRef.current === requestSeq) { + setPrepareChecks(checks); + } + }, + }); + return { ...plan, prepResult }; + }) + ); + let anyFailure = false; + let anyNotes = false; + const collectedWarnings: string[] = []; + for (const plan of providerResults) { + if (plan.prepResult.warnings.length > 0) { anyNotes = true; collectedWarnings.push( - ...prepResult.warnings.map((warning) => `${getProviderLabel(providerId)}: ${warning}`) + ...plan.prepResult.warnings.map( + (warning) => `${getProviderLabel(plan.providerId)}: ${warning}` + ) ); } - if (prepResult.status === 'failed') { + if (plan.prepResult.status === 'failed') { anyFailure = true; - } else if (prepResult.status === 'notes') { + } else if (plan.prepResult.status === 'notes') { anyNotes = true; } - prepareModelResultsCacheRef.current.set(cacheKey, prepResult.modelResultsById); - checks = updateProviderCheck(checks, providerId, { - status: prepResult.status, - backendSummary, - details: prepResult.details, + prepareModelResultsCacheRef.current.set(plan.cacheKey, plan.prepResult.modelResultsById); + checks = updateProviderCheck(checks, plan.providerId, { + status: plan.prepResult.status, + backendSummary: plan.backendSummary, + details: plan.prepResult.details, }); - if (!cancelled && prepareRequestSeqRef.current === requestSeq) { - setPrepareChecks(checks); - } + } + if (!cancelled && prepareRequestSeqRef.current === requestSeq) { + setPrepareChecks(checks); } if (cancelled || prepareRequestSeqRef.current !== requestSeq) return; const failureMessage = diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index 3937e09f..bcf7ebc2 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -111,7 +111,8 @@ export const MemberCard = ({ !isRemoved && presenceLabel === 'starting' && spawnLaunchState !== 'failed_to_start' && - !activityTask; + !activityTask && + !runtimeSummary; const showStartingBadge = !isRemoved && presenceLabel === 'starting' && !activityTask; const showRuntimeAdvisoryBadge = !isRemoved && diff --git a/src/renderer/components/team/members/MemberList.tsx b/src/renderer/components/team/members/MemberList.tsx index c19f4b10..58db6a84 100644 --- a/src/renderer/components/team/members/MemberList.tsx +++ b/src/renderer/components/team/members/MemberList.tsx @@ -1,13 +1,8 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { - formatTeamModelSummary, - getTeamEffortLabel, - getTeamModelLabel, - getTeamProviderLabel, -} from '@renderer/components/team/dialogs/TeamModelSelector'; +import { resolveMemberRuntimeSummary } from '@renderer/utils/memberRuntimeSummary'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; -import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection'; +import { isLeadMember } from '@shared/utils/leadDetection'; import { MemberCard } from './MemberCard'; @@ -152,6 +147,7 @@ function areMemberSpawnStatusesEquivalent( leftEntry.launchState !== rightEntry.launchState || leftEntry.error !== rightEntry.error || leftEntry.livenessSource !== rightEntry.livenessSource || + leftEntry.runtimeModel !== rightEntry.runtimeModel || leftEntry.runtimeAlive !== rightEntry.runtimeAlive ) { return false; @@ -242,12 +238,11 @@ export const MemberList = memo(function MemberList({ const colorMap = useMemo(() => buildMemberColorMap(members), [members]); const buildRuntimeSummary = useCallback( - (member: ResolvedTeamMember): string | undefined => { - const effectiveProvider = member.providerId ?? launchParams?.providerId ?? 'anthropic'; - const effectiveModel = member.model?.trim() || launchParams?.model?.trim() || ''; - const effectiveEffort = member.effort ?? launchParams?.effort; - - return formatTeamModelSummary(effectiveProvider, effectiveModel, effectiveEffort); + ( + member: ResolvedTeamMember, + spawnEntry: MemberSpawnStatusEntry | undefined + ): string | undefined => { + return resolveMemberRuntimeSummary(member, launchParams, spawnEntry); }, [launchParams] ); @@ -293,7 +288,7 @@ export const MemberList = memo(function MemberList({ reviewTask={isRemoved ? null : reviewTask} isAwaitingReply={isRemoved ? false : awaitingReply} isRemoved={isRemoved} - runtimeSummary={isRemoved ? buildRuntimeSummary(member) : buildRuntimeSummary(member)} + runtimeSummary={buildRuntimeSummary(member, isRemoved ? undefined : spawnEntry)} spawnStatus={isRemoved ? undefined : spawnEntry?.status} spawnError={isRemoved ? undefined : spawnEntry?.error} spawnLivenessSource={isRemoved ? undefined : spawnEntry?.livenessSource} diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 549cad1f..8060f2f1 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -372,6 +372,7 @@ function areMemberSpawnStatusEntriesEqual( left.error === right.error && left.livenessSource === right.livenessSource && left.runtimeAlive === right.runtimeAlive && + left.runtimeModel === right.runtimeModel && left.bootstrapConfirmed === right.bootstrapConfirmed && left.hardFailure === right.hardFailure ); diff --git a/src/renderer/utils/memberRuntimeSummary.ts b/src/renderer/utils/memberRuntimeSummary.ts new file mode 100644 index 00000000..937a4f0f --- /dev/null +++ b/src/renderer/utils/memberRuntimeSummary.ts @@ -0,0 +1,41 @@ +import { formatTeamModelSummary } from '@renderer/components/team/dialogs/TeamModelSelector'; + +import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice'; +import type { MemberSpawnStatusEntry, ResolvedTeamMember, TeamProviderId } from '@shared/types'; +import { inferTeamProviderIdFromModel } from '@shared/utils/teamProvider'; + +function isMemberLaunchPending(spawnEntry: MemberSpawnStatusEntry | undefined): boolean { + if (!spawnEntry) { + return false; + } + + return ( + spawnEntry.launchState === 'starting' || + spawnEntry.launchState === 'runtime_pending_bootstrap' || + spawnEntry.status === 'waiting' || + spawnEntry.status === 'spawning' + ); +} + +export function resolveMemberRuntimeSummary( + member: ResolvedTeamMember, + launchParams: TeamLaunchParams | undefined, + spawnEntry: MemberSpawnStatusEntry | undefined +): string | undefined { + const configuredProvider: TeamProviderId = + member.providerId ?? launchParams?.providerId ?? 'anthropic'; + const configuredModel = member.model?.trim() || launchParams?.model?.trim() || ''; + const configuredEffort = member.effort ?? launchParams?.effort; + const runtimeModel = spawnEntry?.runtimeModel?.trim(); + + if (runtimeModel && (isMemberLaunchPending(spawnEntry) || configuredModel.length === 0)) { + const runtimeProvider = inferTeamProviderIdFromModel(runtimeModel) ?? configuredProvider; + return formatTeamModelSummary(runtimeProvider, runtimeModel, configuredEffort); + } + + if (isMemberLaunchPending(spawnEntry)) { + return undefined; + } + + return formatTeamModelSummary(configuredProvider, configuredModel, configuredEffort); +} diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 1d970668..bbdaa950 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -908,6 +908,8 @@ export interface MemberSpawnStatusEntry { firstSpawnAcceptedAt?: string; /** ISO timestamp of the latest confirmed heartbeat/bootstrap message. */ lastHeartbeatAt?: string; + /** Live runtime model observed from the teammate process, when available. */ + runtimeModel?: string; /** ISO timestamp of the last status change. */ updatedAt: string; } diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 659d5d68..889c74c8 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -752,7 +752,7 @@ describe('TeamProvisioningService', () => { expect(result.teamLaunchState).toBe('partial_failure'); }); - it('marks a live teammate bootstrap as failed when transcript shows model unavailability', async () => { + it('marks an online teammate bootstrap as failed when transcript shows model unavailability', async () => { allowConsoleLogs(); const teamName = 'zz-live-bootstrap-model-unavailable'; const leadSessionId = 'lead-session'; @@ -816,8 +816,8 @@ describe('TeamProvisioningService', () => { launchState: 'runtime_pending_bootstrap', error: undefined, updatedAt: acceptedAt, - runtimeAlive: false, - livenessSource: undefined, + runtimeAlive: true, + livenessSource: 'process', bootstrapConfirmed: false, hardFailure: false, agentToolAccepted: true, @@ -846,4 +846,77 @@ describe('TeamProvisioningService', () => { ); expect(run.provisioningOutputParts.join('\n')).toContain('requested model is not available'); }); + + it('marks a persisted online teammate bootstrap as failed when transcript shows model unavailability', async () => { + allowConsoleLogs(); + const teamName = 'zz-persisted-live-bootstrap-model-unavailable'; + const leadSessionId = 'lead-session'; + const memberSessionId = 'jack-session'; + const projectPath = '/Users/test/proj'; + const projectId = '-Users-test-proj'; + const acceptedAt = new Date(Date.now() - 5_000).toISOString(); + const errorAt = new Date(Date.now() - 4_000).toISOString(); + + writeLaunchConfig(teamName, projectPath, leadSessionId, ['jack']); + writeLaunchState(teamName, leadSessionId, { + jack: { + launchState: 'runtime_pending_bootstrap', + agentToolAccepted: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + hardFailureReason: undefined, + firstSpawnAcceptedAt: acceptedAt, + }, + }); + + const projectRoot = path.join(tempProjectsBase, projectId); + fs.mkdirSync(projectRoot, { recursive: true }); + fs.writeFileSync( + path.join(projectRoot, `${memberSessionId}.jsonl`), + [ + JSON.stringify({ + timestamp: acceptedAt, + teamName, + agentName: 'jack', + type: 'user', + message: { + role: 'user', + content: `You are bootstrapping into team "${teamName}" as member "jack".`, + }, + }), + JSON.stringify({ + timestamp: errorAt, + teamName, + agentName: 'jack', + type: 'assistant', + isApiErrorMessage: true, + message: { + role: 'assistant', + content: [ + { + type: 'text', + text: 'API Error: 400 {"detail":"The requested model is not available for your account."}', + }, + ], + }, + }), + ].join('\n') + '\n', + 'utf8' + ); + + const svc = new TeamProvisioningService(); + (svc as any).getLiveTeamAgentNames = vi.fn(() => new Set(['jack'])); + + const result = await svc.getMemberSpawnStatuses(teamName); + + expect(result.statuses.jack).toMatchObject({ + status: 'error', + launchState: 'failed_to_start', + runtimeAlive: true, + }); + expect(result.statuses.jack?.error).toContain('requested model is not available'); + expect(result.statuses.jack?.hardFailureReason).toContain('requested model is not available'); + expect(result.teamLaunchState).toBe('partial_failure'); + }); }); diff --git a/test/main/services/team/TeamProvisioningServicePrompts.test.ts b/test/main/services/team/TeamProvisioningServicePrompts.test.ts index 8ceb6d05..7eef52f4 100644 --- a/test/main/services/team/TeamProvisioningServicePrompts.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrompts.test.ts @@ -276,6 +276,76 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () => await svc.cancelProvisioning(runId); }); + it('createTeam materializes an explicit Codex default model for teammates before bootstrap spawn', async () => { + vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude'); + const { child } = createFakeChild(); + vi.mocked(spawnCli).mockReturnValue(child as any); + + const svc = new TeamProvisioningService(); + (svc as any).buildProvisioningEnv = vi.fn(async () => ({ + env: { PATH: '/usr/bin' }, + authSource: 'codex_runtime', + geminiRuntimeAuth: null, + })); + (svc as any).resolveProviderDefaultModel = vi.fn(async () => 'gpt-5.4'); + (svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {}); + (svc as any).startFilesystemMonitor = vi.fn(); + (svc as any).pathExists = vi.fn(async () => false); + + const { runId } = await svc.createTeam( + { + teamName: 'codex-default-team', + cwd: process.cwd(), + providerId: 'codex', + members: [{ name: 'alice', role: 'developer', providerId: 'codex' }], + }, + () => {} + ); + + const bootstrapSpec = extractBootstrapSpec(); + expect(bootstrapSpec.members).toEqual([ + expect.objectContaining({ + name: 'alice', + provider: 'codex', + model: 'gpt-5.4', + }), + ]); + + await svc.cancelProvisioning(runId); + }); + + it('createTeam fails fast when a Codex teammate default model cannot be resolved', async () => { + vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude'); + vi.mocked(spawnCli).mockReset(); + + const svc = new TeamProvisioningService(); + (svc as any).buildProvisioningEnv = vi.fn(async () => ({ + env: { PATH: '/usr/bin' }, + authSource: 'codex_runtime', + geminiRuntimeAuth: null, + })); + (svc as any).resolveProviderDefaultModel = vi.fn(async () => null); + (svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {}); + (svc as any).startFilesystemMonitor = vi.fn(); + (svc as any).pathExists = vi.fn(async () => false); + + await expect( + svc.createTeam( + { + teamName: 'codex-default-missing', + cwd: process.cwd(), + providerId: 'codex', + members: [{ name: 'alice', providerId: 'codex' }], + }, + () => {} + ) + ).rejects.toThrow( + 'Could not resolve the runtime default model for Codex teammates. Select an explicit model and retry.' + ); + + expect(spawnCli).not.toHaveBeenCalled(); + }); + it('add-member spawn prompt tells teammates to keep review on the same task', () => { const prompt = buildAddMemberSpawnMessage('my-team', 'My Team', 'team-lead', { name: 'alice', @@ -429,4 +499,67 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () => await svc.cancelProvisioning(runId); }); + + it('launchTeam materializes an explicit Codex default model for launch teammates before bootstrap spawn', async () => { + const teamName = 'codex-default-launch'; + const teamDir = path.join(tempTeamsBase, teamName); + fs.mkdirSync(teamDir, { recursive: true }); + fs.writeFileSync( + path.join(teamDir, 'config.json'), + JSON.stringify({ + name: teamName, + members: [ + { name: 'team-lead', agentType: 'team-lead', providerId: 'codex' }, + { name: 'alice', agentType: 'teammate', role: 'developer', providerId: 'codex' }, + ], + }), + 'utf8' + ); + + vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude'); + const { child } = createFakeChild(); + vi.mocked(spawnCli).mockReturnValue(child as any); + + const svc = new TeamProvisioningService(); + (svc as any).buildProvisioningEnv = vi.fn(async () => ({ + env: { PATH: '/usr/bin' }, + authSource: 'codex_runtime', + geminiRuntimeAuth: null, + })); + (svc as any).resolveProviderDefaultModel = vi.fn(async () => 'gpt-5.4'); + (svc as any).normalizeTeamConfigForLaunch = vi.fn(async () => {}); + (svc as any).updateConfigProjectPath = vi.fn(async () => {}); + (svc as any).restorePrelaunchConfig = vi.fn(async () => {}); + (svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {}); + (svc as any).persistLaunchStateSnapshot = vi.fn(async () => {}); + (svc as any).resolveLaunchExpectedMembers = vi.fn(async () => ({ + members: [{ name: 'alice', role: 'developer', providerId: 'codex' }], + source: 'config-fallback', + warning: undefined, + })); + (svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {}); + (svc as any).pathExists = vi.fn(async () => false); + (svc as any).startFilesystemMonitor = vi.fn(); + + const { runId } = await svc.launchTeam( + { + teamName, + cwd: process.cwd(), + providerId: 'codex', + clearContext: true, + } as any, + () => {} + ); + + const bootstrapSpec = extractBootstrapSpec(); + expect(bootstrapSpec.members).toEqual([ + expect.objectContaining({ + name: 'alice', + provider: 'codex', + model: 'gpt-5.4', + }), + ]); + + await svc.cancelProvisioning(runId); + }); }); diff --git a/test/renderer/utils/memberRuntimeSummary.test.ts b/test/renderer/utils/memberRuntimeSummary.test.ts new file mode 100644 index 00000000..dd8ab9b5 --- /dev/null +++ b/test/renderer/utils/memberRuntimeSummary.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveMemberRuntimeSummary } from '@renderer/utils/memberRuntimeSummary'; + +import type { MemberSpawnStatusEntry, ResolvedTeamMember } from '@shared/types'; + +function createMember(overrides: Partial = {}): ResolvedTeamMember { + return { + name: 'alice', + agentId: 'alice@test-team', + agentType: 'general-purpose', + role: 'developer', + providerId: 'codex', + effort: 'medium', + status: 'idle', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + color: 'blue', + ...overrides, + }; +} + +function createSpawnEntry(overrides: Partial = {}): MemberSpawnStatusEntry { + return { + status: 'waiting', + launchState: 'starting', + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + agentToolAccepted: true, + updatedAt: '2026-04-16T17:10:48.646Z', + ...overrides, + }; +} + +describe('resolveMemberRuntimeSummary', () => { + it('shows the live runtime model for loading members when available', () => { + const member = createMember(); + const spawnEntry = createSpawnEntry({ runtimeModel: 'claude-opus-4-6', runtimeAlive: true }); + + expect(resolveMemberRuntimeSummary(member, undefined, spawnEntry)).toBe( + 'Anthropic · Opus 4.6 · Medium' + ); + }); + + it('keeps the loading skeleton when a pending member has no live runtime model yet', () => { + const member = createMember(); + const spawnEntry = createSpawnEntry(); + + expect(resolveMemberRuntimeSummary(member, undefined, spawnEntry)).toBeUndefined(); + }); + + it('uses the live runtime model as a fallback when config has no explicit model', () => { + const member = createMember({ providerId: 'codex', model: undefined }); + const spawnEntry = createSpawnEntry({ + status: 'online', + launchState: 'confirmed_alive', + runtimeAlive: true, + runtimeModel: 'gpt-5.4-mini', + }); + + expect(resolveMemberRuntimeSummary(member, undefined, spawnEntry)).toBe('5.4 Mini · Medium'); + }); +}); From 58644b24c678f0e3d41802dcf84967e151bd6f6a Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 21:26:15 +0300 Subject: [PATCH 051/121] fix(agent-graph): harden pan and launch stepper visibility --- .../agent-graph/src/canvas/draw-agents.ts | 74 ++++++++++++++----- packages/agent-graph/src/ui/GraphControls.tsx | 19 +++-- packages/agent-graph/src/ui/GraphView.tsx | 62 +++++++++++----- .../renderer/ui/GraphProvisioningHud.tsx | 6 +- .../agent-graph/GraphProvisioningHud.test.ts | 19 +++-- 5 files changed, 127 insertions(+), 53 deletions(-) diff --git a/packages/agent-graph/src/canvas/draw-agents.ts b/packages/agent-graph/src/canvas/draw-agents.ts index 9a31800a..ed8db002 100644 --- a/packages/agent-graph/src/canvas/draw-agents.ts +++ b/packages/agent-graph/src/canvas/draw-agents.ts @@ -266,24 +266,49 @@ function drawLaunchStage( ctx.save(); switch (visualState) { case 'waiting': { - const ringR = r + 7 + Math.sin(time * 3.2) * 1.2; - const pulseAlpha = 0.2 + 0.14 * (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.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.8); - ctx.lineWidth = 2.2; + 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': { @@ -291,27 +316,37 @@ function drawLaunchStage( ctx.beginPath(); ctx.arc(x, y, ringR, 0, Math.PI * 2); ctx.strokeStyle = hexWithAlpha('#38bdf8', 0.48); - ctx.lineWidth = 1.75; + 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.62); - ctx.lineWidth = 2; + ctx.lineWidth = 2.2; ctx.lineCap = 'round'; ctx.stroke(); break; @@ -321,9 +356,14 @@ function drawLaunchStage( ctx.beginPath(); ctx.arc(x, y, ringR, Math.PI * 0.2, Math.PI * 1.15); ctx.strokeStyle = hexWithAlpha('#ef4444', 0.72); - ctx.lineWidth = 2.2; + 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; } } diff --git a/packages/agent-graph/src/ui/GraphControls.tsx b/packages/agent-graph/src/ui/GraphControls.tsx index a3bc581d..801b4a8b 100644 --- a/packages/agent-graph/src/ui/GraphControls.tsx +++ b/packages/agent-graph/src/ui/GraphControls.tsx @@ -49,6 +49,7 @@ export interface GraphControlsProps { teamColor?: string; isAlive?: boolean; topToolbarContent?: React.ReactNode; + interactionLocked?: boolean; } const TOPBAR_BUTTON_SIZE = 25; @@ -69,6 +70,7 @@ export function GraphControls({ isSidebarVisible = true, teamColor, topToolbarContent, + interactionLocked = false, }: GraphControlsProps): React.JSX.Element { const [isSettingsOpen, setIsSettingsOpen] = useState(false); const settingsRef = useRef(null); @@ -104,6 +106,9 @@ export function GraphControls({ }, [isSettingsOpen]); const nameColor = teamColor ?? '#aaeeff'; + const chromeInteractivityClass = interactionLocked + ? 'pointer-events-none select-none' + : 'pointer-events-auto'; return ( <> @@ -111,7 +116,7 @@ export function GraphControls({
{onToggleSidebar ? (
{topToolbarContent ? ( -
+
{topToolbarContent}
) : null} @@ -175,7 +180,7 @@ export function GraphControls({
-
+
(null); const [selectedEdgeId, setSelectedEdgeId] = useState(null); + const [interactionLocked, setInteractionLocked] = useState(false); const [filters, setFilters] = useState({ showTasks: config?.showTasks ?? true, showProcesses: config?.showProcesses ?? true, @@ -136,6 +137,7 @@ export function GraphView({ color?: string | null; } | null>(null); const selectionLockRef = useRef<{ userSelect: string; webkitUserSelect: string } | null>(null); + const activePrimaryInteractionRef = useRef(false); // ─── Hooks ────────────────────────────────────────────────────────────── const simulation = useGraphSimulation(); @@ -280,6 +282,15 @@ export function GraphView({ selectionLockRef.current = null; }, []); + const setInteractionGuards = useCallback( + (active: boolean) => { + activePrimaryInteractionRef.current = active; + setInteractionLocked(active); + setInteractionSelectionDisabled(active); + }, + [setInteractionSelectionDisabled] + ); + const animate = useCallback(() => { if (!runningRef.current) return; @@ -413,6 +424,7 @@ export function GraphView({ dragPreviewRef.current = null; isPanningRef.current = false; edgeMouseDownRef.current = null; + setInteractionGuards(false); }, [interaction, isSurfaceActive, simulation]); const handleWheel = useCallback( @@ -432,11 +444,11 @@ export function GraphView({ if (e.button !== 0) return; // only left click e.preventDefault(); dragPreviewRef.current = null; - setInteractionSelectionDisabled(true); + setInteractionGuards(true); const canvas = canvasHandle.current?.getCanvas(); if (!canvas) { - setInteractionSelectionDisabled(false); + setInteractionGuards(false); return; } const rect = canvas.getBoundingClientRect(); @@ -482,13 +494,14 @@ export function GraphView({ getVisibleNodes, interaction, markUserInteracted, + setInteractionGuards, simulation.stateRef, ] ); const processActivePointerMove = useCallback( - (clientX: number, clientY: number, buttons: number) => { - if ((buttons & 1) === 0) { + (clientX: number, clientY: number) => { + if (!activePrimaryInteractionRef.current) { dragPreviewRef.current = null; return false; } @@ -545,7 +558,7 @@ export function GraphView({ if (isPanningRef.current) { camera.handlePanEnd(); isPanningRef.current = false; - setInteractionSelectionDisabled(false); + setInteractionGuards(false); dragPreviewRef.current = null; setSelectedNodeId(null); setSelectedEdgeId(null); @@ -556,7 +569,7 @@ export function GraphView({ const clickedId = interaction.handleMouseUp(); if (wasDragging && draggedNodeId) { - setInteractionSelectionDisabled(false); + 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( @@ -585,7 +598,7 @@ export function GraphView({ return; } - setInteractionSelectionDisabled(false); + setInteractionGuards(false); if (clickedId) { setSelectedNodeId(clickedId); setSelectedEdgeId(null); @@ -624,12 +637,12 @@ export function GraphView({ } dragPreviewRef.current = null; }, - [camera, events, interaction, onOwnerSlotDrop, setInteractionSelectionDisabled, simulation] + [camera, events, interaction, onOwnerSlotDrop, setInteractionGuards, simulation] ); const handleMouseMove = useCallback( (e: React.MouseEvent) => { - if (processActivePointerMove(e.clientX, e.clientY, e.buttons)) { + if (processActivePointerMove(e.clientX, e.clientY)) { return; } @@ -678,11 +691,8 @@ export function GraphView({ useEffect(() => { const handleWindowMouseMove = (event: MouseEvent): void => { - if ((event.buttons & 1) === 0) { - setInteractionSelectionDisabled(false); - return; - } if ( + !activePrimaryInteractionRef.current && !isPanningRef.current && !interaction.dragNodeId.current && !interaction.isDragging.current && @@ -691,30 +701,47 @@ export function GraphView({ return; } event.preventDefault(); - processActivePointerMove(event.clientX, event.clientY, event.buttons); + processActivePointerMove(event.clientX, event.clientY); }; const handleWindowMouseUp = (event: MouseEvent): void => { if ( + !activePrimaryInteractionRef.current && !isPanningRef.current && !interaction.dragNodeId.current && !interaction.isDragging.current && !edgeMouseDownRef.current ) { - setInteractionSelectionDisabled(false); + 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); - setInteractionSelectionDisabled(false); + window.removeEventListener('blur', clearInteraction); + window.removeEventListener('dragstart', clearInteraction); + setInteractionGuards(false); }; - }, [completePointerInteraction, interaction, processActivePointerMove, setInteractionSelectionDisabled]); + }, [camera, completePointerInteraction, interaction, processActivePointerMove, setInteractionGuards]); const handleDoubleClick = useCallback( (e: React.MouseEvent) => { @@ -911,6 +938,7 @@ export function GraphView({ teamColor={data.teamColor} isAlive={data.isAlive} topToolbarContent={renderTopToolbarContent?.()} + interactionLocked={interactionLocked} /> {renderHud ? ( diff --git a/src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx b/src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx index 06dc290a..34574126 100644 --- a/src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx +++ b/src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx @@ -34,7 +34,7 @@ const HUD_STEPPER_STYLE: CSSProperties = { }; function shouldRenderLaunchHud(presentation: TeamProvisioningPresentation | null): boolean { - return presentation != null; + return presentation != null && (presentation.isActive || presentation.isFailed); } export interface GraphProvisioningHudProps { @@ -80,8 +80,10 @@ export const GraphProvisioningHud = ({ <>
- {plugin.isInstalled && ( + {installSummaryLabel && ( - Installed + {installSummaryLabel} )} diff --git a/src/renderer/components/extensions/plugins/PluginsPanel.tsx b/src/renderer/components/extensions/plugins/PluginsPanel.tsx index ab8d94e9..7584fa74 100644 --- a/src/renderer/components/extensions/plugins/PluginsPanel.tsx +++ b/src/renderer/components/extensions/plugins/PluginsPanel.tsx @@ -2,7 +2,7 @@ * PluginsPanel — search, filter, sort and browse the plugin catalog. */ -import { useMemo } from 'react'; +import { useEffect, useMemo } from 'react'; import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; @@ -142,6 +142,12 @@ export const PluginsPanel = ({ [catalog, selectedPluginId] ); + useEffect(() => { + if (selectedPluginId && !loading && !selectedPlugin) { + setSelectedPluginId(null); + } + }, [loading, selectedPlugin, selectedPluginId, setSelectedPluginId]); + const sortValue = `${pluginSort.field}:${pluginSort.order}`; const activeFilterCount = pluginFilters.categories.length + diff --git a/src/shared/utils/extensionNormalizers.ts b/src/shared/utils/extensionNormalizers.ts index 44b0dbc1..7684925a 100644 --- a/src/shared/utils/extensionNormalizers.ts +++ b/src/shared/utils/extensionNormalizers.ts @@ -3,11 +3,12 @@ */ import type { + CliInstallationStatus, InstallScope, InstalledPluginEntry, PluginCapability, PluginCatalogItem, -} from '@shared/types/extensions'; +} from '@shared/types'; /** * Normalize a repository URL for dedup comparison. @@ -109,6 +110,61 @@ export function hasInstallationInScope( return installations.some((installation) => installation.scope === scope); } +/** + * Build a concise install-status label for plugin badges. + */ +export function getInstallationSummaryLabel( + installations: Pick[] +): string | null { + const scopes = Array.from(new Set(installations.map((installation) => installation.scope))); + if (scopes.length === 0) { + return null; + } + + if (scopes.length > 1) { + return `Installed in ${scopes.length} scopes`; + } + + switch (scopes[0]) { + case 'user': + return 'Installed globally'; + case 'project': + return 'Installed in project'; + case 'local': + return 'Installed locally'; + default: + return 'Installed'; + } +} + +/** + * Install actions require Claude auth, but uninstall only requires a working CLI. + */ +export function getExtensionActionDisableReason(options: { + isInstalled: boolean; + cliStatus: Pick | null; + cliStatusLoading: boolean; +}): string | null { + const { isInstalled, cliStatus, cliStatusLoading } = options; + if (cliStatusLoading) { + return 'Checking Claude CLI status...'; + } + + if (cliStatus === null) { + return 'Checking Claude CLI availability...'; + } + + if (cliStatus.installed === false) { + return 'Claude CLI required. Install it from the Dashboard.'; + } + + if (!isInstalled && !cliStatus.authLoggedIn) { + return 'Claude CLI is installed but not signed in. Open the Dashboard to sign in.'; + } + + return null; +} + /** * Sanitize an MCP server display name into a CLI-safe server name. * Must match the regex /^[\w.-]{1,100}$/ required by McpInstallService. diff --git a/test/shared/utils/extensionNormalizers.test.ts b/test/shared/utils/extensionNormalizers.test.ts index b2cc7347..7905fd99 100644 --- a/test/shared/utils/extensionNormalizers.test.ts +++ b/test/shared/utils/extensionNormalizers.test.ts @@ -5,7 +5,9 @@ import type { PluginCatalogItem } from '@shared/types/extensions'; import { buildPluginId, formatInstallCount, + getExtensionActionDisableReason, getCapabilityLabel, + getInstallationSummaryLabel, getPrimaryCapabilityLabel, hasInstallationInScope, inferCapabilities, @@ -168,6 +170,61 @@ describe('hasInstallationInScope', () => { }); }); +describe('getInstallationSummaryLabel', () => { + it('returns null when there are no installations', () => { + expect(getInstallationSummaryLabel([])).toBeNull(); + }); + + it('describes a single global installation', () => { + expect(getInstallationSummaryLabel([{ scope: 'user' }])).toBe('Installed globally'); + }); + + it('describes a single project installation', () => { + expect(getInstallationSummaryLabel([{ scope: 'project' }])).toBe('Installed in project'); + }); + + it('summarizes multiple scopes without pretending they are global', () => { + expect( + getInstallationSummaryLabel([ + { scope: 'project' }, + { scope: 'user' }, + ]), + ).toBe('Installed in 2 scopes'); + }); +}); + +describe('getExtensionActionDisableReason', () => { + it('requires auth only for install actions', () => { + expect( + getExtensionActionDisableReason({ + isInstalled: false, + cliStatus: { installed: true, authLoggedIn: false }, + cliStatusLoading: false, + }), + ).toContain('not signed in'); + }); + + it('allows uninstall when CLI is present but auth is missing', () => { + expect( + getExtensionActionDisableReason({ + isInstalled: true, + cliStatus: { installed: true, authLoggedIn: false }, + cliStatusLoading: false, + }), + ).toBeNull(); + }); + + it('still blocks actions when the CLI is missing', () => { + expect( + getExtensionActionDisableReason({ + isInstalled: true, + cliStatus: { installed: false, authLoggedIn: false }, + cliStatusLoading: false, + }), + ).toContain('Claude CLI required'); + }); +}); + describe('sanitizeMcpServerName', () => { it('lowercases and replaces spaces with dashes', () => { expect(sanitizeMcpServerName('My Server')).toBe('my-server'); From afad52f506f86ac84f1618cb6c77cef99651fc25 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 22:01:59 +0300 Subject: [PATCH 055/121] fix(extensions): prevent stale plugin catalog races --- .../extensions/plugins/PluginDetailDialog.tsx | 6 ++ .../extensions/plugins/PluginsPanel.tsx | 6 ++ src/renderer/store/slices/extensionsSlice.ts | 47 ++++++++++++---- test/renderer/store/extensionsSlice.test.ts | 55 +++++++++++++++++++ 4 files changed, 102 insertions(+), 12 deletions(-) diff --git a/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx b/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx index 9d501e81..74d76b76 100644 --- a/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx +++ b/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx @@ -87,6 +87,12 @@ export const PluginDetailDialog = ({ } }, [plugin, open, fetchPluginReadme]); + useEffect(() => { + if (open) { + setScope('user'); + } + }, [open, plugin?.pluginId]); + useEffect(() => { if (scope === 'project' && !projectScopeAvailable) { setScope('user'); diff --git a/src/renderer/components/extensions/plugins/PluginsPanel.tsx b/src/renderer/components/extensions/plugins/PluginsPanel.tsx index 7584fa74..5480b7c8 100644 --- a/src/renderer/components/extensions/plugins/PluginsPanel.tsx +++ b/src/renderer/components/extensions/plugins/PluginsPanel.tsx @@ -148,6 +148,12 @@ export const PluginsPanel = ({ } }, [loading, selectedPlugin, selectedPluginId, setSelectedPluginId]); + useEffect(() => { + if (error && selectedPluginId) { + setSelectedPluginId(null); + } + }, [error, selectedPluginId, setSelectedPluginId]); + const sortValue = `${pluginSort.field}:${pluginSort.order}`; const activeFilterCount = pluginFilters.categories.length + diff --git a/src/renderer/store/slices/extensionsSlice.ts b/src/renderer/store/slices/extensionsSlice.ts index abea1a2f..e18437ff 100644 --- a/src/renderer/store/slices/extensionsSlice.ts +++ b/src/renderer/store/slices/extensionsSlice.ts @@ -127,7 +127,8 @@ export interface ExtensionsSlice { // Slice Creator // ============================================================================= -let pluginFetchInFlight: Promise | null = null; +let pluginFetchInFlight: { key: string; promise: Promise } | null = null; +let pluginCatalogRequestSeq = 0; let mcpDiagnosticsInFlight: Promise | null = null; let skillsCatalogRequestSeq = 0; let skillsDetailRequestSeq = 0; @@ -140,6 +141,10 @@ function hasAnyLoading(loadingMap: Record): boolean { return Object.values(loadingMap).some(Boolean); } +function getPluginCatalogKey(projectPath?: string): string { + return projectPath ?? '__user__'; +} + function getSkillsCatalogKey(projectPath?: string): string { return projectPath ?? USER_SKILLS_CATALOG_KEY; } @@ -205,34 +210,52 @@ export const createExtensionsSlice: StateCreator { if (!api.plugins) return; + const requestKey = getPluginCatalogKey(projectPath); // Dedup concurrent requests - if (pluginFetchInFlight && !forceRefresh) { - await pluginFetchInFlight; + if (pluginFetchInFlight && !forceRefresh && pluginFetchInFlight.key === requestKey) { + await pluginFetchInFlight.promise; return; } + const requestSeq = ++pluginCatalogRequestSeq; set({ pluginCatalogLoading: true, pluginCatalogError: null }); const promise = (async () => { try { const result = await api.plugins!.getAll(projectPath, forceRefresh); - set({ - pluginCatalog: result, - pluginCatalogLoading: false, - pluginCatalogProjectPath: projectPath ?? null, + set(() => { + if (requestSeq !== pluginCatalogRequestSeq) { + return {}; + } + + return { + pluginCatalog: result, + pluginCatalogLoading: false, + pluginCatalogError: null, + pluginCatalogProjectPath: projectPath ?? null, + }; }); } catch (err) { - set({ - pluginCatalogLoading: false, - pluginCatalogError: err instanceof Error ? err.message : 'Failed to load plugins', + set(() => { + if (requestSeq !== pluginCatalogRequestSeq) { + return {}; + } + + return { + pluginCatalogLoading: false, + pluginCatalogError: err instanceof Error ? err.message : 'Failed to load plugins', + pluginCatalogProjectPath: projectPath ?? null, + }; }); } finally { - pluginFetchInFlight = null; + if (pluginFetchInFlight?.promise === promise) { + pluginFetchInFlight = null; + } } })(); - pluginFetchInFlight = promise; + pluginFetchInFlight = { key: requestKey, promise }; await promise; }, diff --git a/test/renderer/store/extensionsSlice.test.ts b/test/renderer/store/extensionsSlice.test.ts index 3240c33a..278f61e1 100644 --- a/test/renderer/store/extensionsSlice.test.ts +++ b/test/renderer/store/extensionsSlice.test.ts @@ -166,6 +166,61 @@ describe('extensionsSlice', () => { expect(store.getState().pluginCatalogError).toBe('boom'); expect(store.getState().pluginCatalogLoading).toBe(false); }); + + it('dedups concurrent requests for the same project key', async () => { + let resolveFetch!: (plugins: EnrichedPlugin[]) => void; + const inFlight = new Promise((resolve) => { + resolveFetch = resolve; + }); + (api.plugins!.getAll as ReturnType).mockImplementation(() => inFlight); + + const firstFetch = store.getState().fetchPluginCatalog('/tmp/project-a'); + const secondFetch = store.getState().fetchPluginCatalog('/tmp/project-a'); + + expect(api.plugins!.getAll).toHaveBeenCalledTimes(1); + + resolveFetch([makePlugin({ pluginId: 'same@m' })]); + await Promise.all([firstFetch, secondFetch]); + + expect(store.getState().pluginCatalogProjectPath).toBe('/tmp/project-a'); + expect(store.getState().pluginCatalog.map((plugin) => plugin.pluginId)).toEqual(['same@m']); + }); + + it('keeps the newest project catalog when project changes mid-flight', async () => { + let resolveProjectA!: (plugins: EnrichedPlugin[]) => void; + let resolveProjectB!: (plugins: EnrichedPlugin[]) => void; + const projectAFetch = new Promise((resolve) => { + resolveProjectA = resolve; + }); + const projectBFetch = new Promise((resolve) => { + resolveProjectB = resolve; + }); + + (api.plugins!.getAll as ReturnType) + .mockImplementationOnce(() => projectAFetch) + .mockImplementationOnce(() => projectBFetch); + + const firstFetch = store.getState().fetchPluginCatalog('/tmp/project-a'); + const secondFetch = store.getState().fetchPluginCatalog('/tmp/project-b'); + + expect(api.plugins!.getAll).toHaveBeenCalledTimes(2); + + resolveProjectB([makePlugin({ pluginId: 'project-b@m' })]); + await secondFetch; + + expect(store.getState().pluginCatalogProjectPath).toBe('/tmp/project-b'); + expect(store.getState().pluginCatalog.map((plugin) => plugin.pluginId)).toEqual([ + 'project-b@m', + ]); + + resolveProjectA([makePlugin({ pluginId: 'project-a@m' })]); + await firstFetch; + + expect(store.getState().pluginCatalogProjectPath).toBe('/tmp/project-b'); + expect(store.getState().pluginCatalog.map((plugin) => plugin.pluginId)).toEqual([ + 'project-b@m', + ]); + }); }); describe('fetchPluginReadme', () => { From 09b5b4626f1a4497c08f2caab9d158b7221cf12c Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 22:04:02 +0300 Subject: [PATCH 056/121] fix(extensions): tighten plugin catalog fallback state --- src/renderer/store/slices/extensionsSlice.ts | 16 +++++++++++--- test/renderer/store/extensionsSlice.test.ts | 23 ++++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/renderer/store/slices/extensionsSlice.ts b/src/renderer/store/slices/extensionsSlice.ts index e18437ff..9d21f833 100644 --- a/src/renderer/store/slices/extensionsSlice.ts +++ b/src/renderer/store/slices/extensionsSlice.ts @@ -237,15 +237,19 @@ export const createExtensionsSlice: StateCreator { + set((prev) => { if (requestSeq !== pluginCatalogRequestSeq) { return {}; } + const nextProjectPath = projectPath ?? null; + const isSameProjectContext = prev.pluginCatalogProjectPath === nextProjectPath; + return { + pluginCatalog: isSameProjectContext ? prev.pluginCatalog : [], pluginCatalogLoading: false, pluginCatalogError: err instanceof Error ? err.message : 'Failed to load plugins', - pluginCatalogProjectPath: projectPath ?? null, + pluginCatalogProjectPath: nextProjectPath, }; }); } finally { @@ -263,7 +267,13 @@ export const createExtensionsSlice: StateCreator { if (!api.plugins) return; const state = get(); - if (pluginId in state.pluginReadmes || state.pluginReadmeLoading[pluginId]) return; + const cachedReadme = state.pluginReadmes[pluginId]; + if ( + (cachedReadme !== undefined && cachedReadme !== null) || + state.pluginReadmeLoading[pluginId] + ) { + return; + } set((prev) => ({ pluginReadmeLoading: { ...prev.pluginReadmeLoading, [pluginId]: true }, diff --git a/test/renderer/store/extensionsSlice.test.ts b/test/renderer/store/extensionsSlice.test.ts index 278f61e1..906c89b9 100644 --- a/test/renderer/store/extensionsSlice.test.ts +++ b/test/renderer/store/extensionsSlice.test.ts @@ -167,6 +167,20 @@ describe('extensionsSlice', () => { expect(store.getState().pluginCatalogLoading).toBe(false); }); + it('clears stale catalog when a different project fetch fails', async () => { + store.setState({ + pluginCatalog: [makePlugin({ pluginId: 'project-a@m' })], + pluginCatalogProjectPath: '/tmp/project-a', + }); + (api.plugins!.getAll as ReturnType).mockRejectedValue(new Error('boom')); + + await store.getState().fetchPluginCatalog('/tmp/project-b'); + + expect(store.getState().pluginCatalog).toEqual([]); + expect(store.getState().pluginCatalogProjectPath).toBe('/tmp/project-b'); + expect(store.getState().pluginCatalogError).toBe('boom'); + }); + it('dedups concurrent requests for the same project key', async () => { let resolveFetch!: (plugins: EnrichedPlugin[]) => void; const inFlight = new Promise((resolve) => { @@ -243,6 +257,15 @@ describe('extensionsSlice', () => { expect(api.plugins!.getReadme).not.toHaveBeenCalled(); }); + + it('retries README fetch when the cached value is null', () => { + store.setState({ pluginReadmes: { 'test@m': null } }); + (api.plugins!.getReadme as ReturnType).mockResolvedValue(null); + + store.getState().fetchPluginReadme('test@m'); + + expect(api.plugins!.getReadme).toHaveBeenCalledWith('test@m'); + }); }); describe('mcpBrowse', () => { From 560174d98c1356ea3c48074f847e18a88e120710 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 22:05:28 +0300 Subject: [PATCH 057/121] fix(extensions): reset stale plugin action state on project switch --- src/renderer/store/slices/extensionsSlice.ts | 54 +++++++++++++++++++- test/renderer/store/extensionsSlice.test.ts | 46 +++++++++++++++++ 2 files changed, 98 insertions(+), 2 deletions(-) diff --git a/src/renderer/store/slices/extensionsSlice.ts b/src/renderer/store/slices/extensionsSlice.ts index 9d21f833..cf6a8687 100644 --- a/src/renderer/store/slices/extensionsSlice.ts +++ b/src/renderer/store/slices/extensionsSlice.ts @@ -145,6 +145,36 @@ function getPluginCatalogKey(projectPath?: string): string { return projectPath ?? '__user__'; } +function buildPluginIdSet(catalog: EnrichedPlugin[]): Set { + return new Set(catalog.map((plugin) => plugin.pluginId)); +} + +function clearPluginOperationState( + pluginIds: Set, + pluginInstallProgress: Record, + installErrors: Record +): { + pluginInstallProgress: Record; + installErrors: Record; +} { + if (pluginIds.size === 0) { + return { pluginInstallProgress, installErrors }; + } + + const nextPluginInstallProgress = { ...pluginInstallProgress }; + const nextInstallErrors = { ...installErrors }; + + for (const pluginId of pluginIds) { + delete nextPluginInstallProgress[pluginId]; + delete nextInstallErrors[pluginId]; + } + + return { + pluginInstallProgress: nextPluginInstallProgress, + installErrors: nextInstallErrors, + }; +} + function getSkillsCatalogKey(projectPath?: string): string { return projectPath ?? USER_SKILLS_CATALOG_KEY; } @@ -224,16 +254,29 @@ export const createExtensionsSlice: StateCreator { try { const result = await api.plugins!.getAll(projectPath, forceRefresh); - set(() => { + set((prev) => { if (requestSeq !== pluginCatalogRequestSeq) { return {}; } + const nextProjectPath = projectPath ?? null; + const isSameProjectContext = prev.pluginCatalogProjectPath === nextProjectPath; + const pluginIdsToClear = isSameProjectContext + ? new Set() + : new Set([...buildPluginIdSet(prev.pluginCatalog), ...buildPluginIdSet(result)]); + const nextOperationState = clearPluginOperationState( + pluginIdsToClear, + prev.pluginInstallProgress, + prev.installErrors + ); + return { pluginCatalog: result, pluginCatalogLoading: false, pluginCatalogError: null, - pluginCatalogProjectPath: projectPath ?? null, + pluginCatalogProjectPath: nextProjectPath, + pluginInstallProgress: nextOperationState.pluginInstallProgress, + installErrors: nextOperationState.installErrors, }; }); } catch (err) { @@ -244,12 +287,19 @@ export const createExtensionsSlice: StateCreator() : buildPluginIdSet(prev.pluginCatalog), + prev.pluginInstallProgress, + prev.installErrors + ); return { pluginCatalog: isSameProjectContext ? prev.pluginCatalog : [], pluginCatalogLoading: false, pluginCatalogError: err instanceof Error ? err.message : 'Failed to load plugins', pluginCatalogProjectPath: nextProjectPath, + pluginInstallProgress: nextOperationState.pluginInstallProgress, + installErrors: nextOperationState.installErrors, }; }); } finally { diff --git a/test/renderer/store/extensionsSlice.test.ts b/test/renderer/store/extensionsSlice.test.ts index 906c89b9..aa5eac58 100644 --- a/test/renderer/store/extensionsSlice.test.ts +++ b/test/renderer/store/extensionsSlice.test.ts @@ -181,6 +181,30 @@ describe('extensionsSlice', () => { expect(store.getState().pluginCatalogError).toBe('boom'); }); + it('clears plugin operation state when switching project context', async () => { + store.setState({ + pluginCatalog: [makePlugin({ pluginId: 'project-a@m' })], + pluginCatalogProjectPath: '/tmp/project-a', + pluginInstallProgress: { + 'project-a@m': 'error', + }, + installErrors: { + 'project-a@m': 'Install failed', + 'mcp-server': 'Keep me', + }, + }); + (api.plugins!.getAll as ReturnType).mockResolvedValue([ + makePlugin({ pluginId: 'project-b@m' }), + ]); + + await store.getState().fetchPluginCatalog('/tmp/project-b'); + + expect(store.getState().pluginCatalogProjectPath).toBe('/tmp/project-b'); + expect(store.getState().pluginInstallProgress['project-a@m']).toBeUndefined(); + expect(store.getState().installErrors['project-a@m']).toBeUndefined(); + expect(store.getState().installErrors['mcp-server']).toBe('Keep me'); + }); + it('dedups concurrent requests for the same project key', async () => { let resolveFetch!: (plugins: EnrichedPlugin[]) => void; const inFlight = new Promise((resolve) => { @@ -235,6 +259,28 @@ describe('extensionsSlice', () => { 'project-b@m', ]); }); + + it('clears plugin operation state when a different project fetch fails', async () => { + store.setState({ + pluginCatalog: [makePlugin({ pluginId: 'project-a@m' })], + pluginCatalogProjectPath: '/tmp/project-a', + pluginInstallProgress: { + 'project-a@m': 'error', + }, + installErrors: { + 'project-a@m': 'Install failed', + 'mcp-server': 'Keep me', + }, + }); + (api.plugins!.getAll as ReturnType).mockRejectedValue(new Error('boom')); + + await store.getState().fetchPluginCatalog('/tmp/project-b'); + + expect(store.getState().pluginCatalog).toEqual([]); + expect(store.getState().pluginInstallProgress['project-a@m']).toBeUndefined(); + expect(store.getState().installErrors['project-a@m']).toBeUndefined(); + expect(store.getState().installErrors['mcp-server']).toBe('Keep me'); + }); }); describe('fetchPluginReadme', () => { From 2b8062dfa3f3f5b35d76b67d417c1c3af596a264 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 22:07:22 +0300 Subject: [PATCH 058/121] fix(extensions): cancel stale plugin success timers --- src/renderer/store/slices/extensionsSlice.ts | 60 ++++++++++++++++---- test/renderer/store/extensionsSlice.test.ts | 42 ++++++++++++++ 2 files changed, 91 insertions(+), 11 deletions(-) diff --git a/src/renderer/store/slices/extensionsSlice.ts b/src/renderer/store/slices/extensionsSlice.ts index cf6a8687..6c207740 100644 --- a/src/renderer/store/slices/extensionsSlice.ts +++ b/src/renderer/store/slices/extensionsSlice.ts @@ -129,6 +129,7 @@ export interface ExtensionsSlice { let pluginFetchInFlight: { key: string; promise: Promise } | null = null; let pluginCatalogRequestSeq = 0; +const pluginSuccessResetTimers = new Map>(); let mcpDiagnosticsInFlight: Promise | null = null; let skillsCatalogRequestSeq = 0; let skillsDetailRequestSeq = 0; @@ -175,6 +176,42 @@ function clearPluginOperationState( }; } +function clearPluginSuccessResetTimer(pluginId: string): void { + const timer = pluginSuccessResetTimers.get(pluginId); + if (!timer) { + return; + } + + clearTimeout(timer); + pluginSuccessResetTimers.delete(pluginId); +} + +function clearPluginSuccessResetTimers(pluginIds: Set): void { + for (const pluginId of pluginIds) { + clearPluginSuccessResetTimer(pluginId); + } +} + +function schedulePluginSuccessReset( + pluginId: string, + set: Parameters>[0] +): void { + clearPluginSuccessResetTimer(pluginId); + const timer = setTimeout(() => { + pluginSuccessResetTimers.delete(pluginId); + set((prev) => { + if (prev.pluginInstallProgress[pluginId] !== 'success') { + return {}; + } + + return { + pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'idle' }, + }; + }); + }, SUCCESS_DISPLAY_MS); + pluginSuccessResetTimers.set(pluginId, timer); +} + function getSkillsCatalogKey(projectPath?: string): string { return projectPath ?? USER_SKILLS_CATALOG_KEY; } @@ -269,6 +306,7 @@ export const createExtensionsSlice: StateCreator() : buildPluginIdSet(prev.pluginCatalog) + ); return { pluginCatalog: isSameProjectContext ? prev.pluginCatalog : [], @@ -679,6 +720,7 @@ export const createExtensionsSlice: StateCreator ({ pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' }, installErrors: { ...prev.installErrors, [request.pluginId]: preflightError }, @@ -686,6 +728,7 @@ export const createExtensionsSlice: StateCreator ({ pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'pending' }, installErrors: { ...prev.installErrors, [request.pluginId]: '' }, @@ -711,13 +754,9 @@ export const createExtensionsSlice: StateCreator { - set((prev) => ({ - pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'idle' }, - })); - }, SUCCESS_DISPLAY_MS); + schedulePluginSuccessReset(request.pluginId, set); } catch (err) { + clearPluginSuccessResetTimer(request.pluginId); const message = err instanceof Error ? err.message : 'Install failed'; set((prev) => ({ pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' }, @@ -735,6 +774,7 @@ export const createExtensionsSlice: StateCreator ({ pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' }, installErrors: { ...prev.installErrors, [pluginId]: PROJECT_SCOPE_REQUIRED_MESSAGE }, @@ -742,6 +782,7 @@ export const createExtensionsSlice: StateCreator ({ pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'pending' }, })); @@ -763,12 +804,9 @@ export const createExtensionsSlice: StateCreator { - set((prev) => ({ - pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'idle' }, - })); - }, SUCCESS_DISPLAY_MS); + schedulePluginSuccessReset(pluginId, set); } catch (err) { + clearPluginSuccessResetTimer(pluginId); const message = err instanceof Error ? err.message : 'Uninstall failed'; set((prev) => ({ pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' }, diff --git a/test/renderer/store/extensionsSlice.test.ts b/test/renderer/store/extensionsSlice.test.ts index aa5eac58..5d9479eb 100644 --- a/test/renderer/store/extensionsSlice.test.ts +++ b/test/renderer/store/extensionsSlice.test.ts @@ -142,6 +142,7 @@ describe('extensionsSlice', () => { }); afterEach(() => { + vi.useRealTimers(); vi.restoreAllMocks(); }); @@ -490,6 +491,25 @@ describe('extensionsSlice', () => { expect(store.getState().pluginInstallProgress['project@m']).toBe('error'); expect(store.getState().installErrors['project@m']).toContain('active project'); }); + + it('clears older success reset timers before a new operation on the same plugin', async () => { + vi.useFakeTimers(); + store.setState({ cliStatus: makeReadyCliStatus() }); + (api.plugins!.getAll as ReturnType).mockResolvedValue([]); + (api.plugins!.install as ReturnType) + .mockResolvedValueOnce({ state: 'success' }) + .mockResolvedValueOnce({ state: 'error', error: 'second failure' }); + + await store.getState().installPlugin({ pluginId: 'test@m', scope: 'user' }); + expect(store.getState().pluginInstallProgress['test@m']).toBe('success'); + + await store.getState().installPlugin({ pluginId: 'test@m', scope: 'user' }); + expect(store.getState().pluginInstallProgress['test@m']).toBe('error'); + + await vi.advanceTimersByTimeAsync(2_000); + + expect(store.getState().pluginInstallProgress['test@m']).toBe('error'); + }); }); describe('uninstallPlugin', () => { @@ -524,6 +544,28 @@ describe('extensionsSlice', () => { expect(store.getState().pluginInstallProgress['project@m']).toBe('error'); expect(store.getState().installErrors['project@m']).toContain('active project'); }); + + it('does not restore idle state after project switch clears a pending success timer', async () => { + vi.useFakeTimers(); + store.setState({ + pluginCatalogProjectPath: '/tmp/project-a', + pluginCatalog: [makePlugin({ pluginId: 'test@m' })], + }); + (api.plugins!.getAll as ReturnType) + .mockResolvedValueOnce([makePlugin({ pluginId: 'test@m' })]) + .mockResolvedValueOnce([makePlugin({ pluginId: 'other@m' })]); + (api.plugins!.uninstall as ReturnType).mockResolvedValue({ state: 'success' }); + + await store.getState().uninstallPlugin('test@m', 'user'); + expect(store.getState().pluginInstallProgress['test@m']).toBe('success'); + + await store.getState().fetchPluginCatalog('/tmp/project-b'); + expect(store.getState().pluginInstallProgress['test@m']).toBeUndefined(); + + await vi.advanceTimersByTimeAsync(2_000); + + expect(store.getState().pluginInstallProgress['test@m']).toBeUndefined(); + }); }); describe('installMcpServer', () => { From 45021524270611d4b7b4448827117ce0b19f5a42 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 22:09:52 +0300 Subject: [PATCH 059/121] fix(extensions): support local plugin scope actions --- .../install/PluginInstallService.ts | 12 +++-- .../extensions/plugins/PluginDetailDialog.tsx | 11 ++--- src/renderer/store/slices/extensionsSlice.ts | 10 ++--- src/shared/types/extensions/plugin.ts | 2 +- .../extensions/PluginInstallService.test.ts | 44 ++++++++++++++++++ test/renderer/store/extensionsSlice.test.ts | 45 +++++++++++++++++++ 6 files changed, 109 insertions(+), 15 deletions(-) diff --git a/src/main/services/extensions/install/PluginInstallService.ts b/src/main/services/extensions/install/PluginInstallService.ts index 4a3a7711..0b994f9f 100644 --- a/src/main/services/extensions/install/PluginInstallService.ts +++ b/src/main/services/extensions/install/PluginInstallService.ts @@ -26,6 +26,10 @@ 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) {} @@ -48,10 +52,10 @@ export class PluginInstallService { }; } - if (scope === 'project' && !projectPath) { + if (scopeRequiresProjectPath(scope) && !projectPath) { return { state: 'error', - error: 'projectPath is required for project-scoped plugin installs', + error: `projectPath is required for ${scope}-scoped plugin installs`, }; } @@ -130,10 +134,10 @@ export class PluginInstallService { }; } - if (scope === 'project' && !projectPath) { + if (scopeRequiresProjectPath(scope) && !projectPath) { return { state: 'error', - error: 'projectPath is required for project-scoped plugin uninstalls', + error: `projectPath is required for ${scope}-scoped plugin uninstalls`, }; } diff --git a/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx b/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx index 74d76b76..8d6e05ac 100644 --- a/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx +++ b/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx @@ -48,7 +48,8 @@ interface PluginDetailDialogProps { const SCOPE_OPTIONS: { value: InstallScope; label: string }[] = [ { value: 'user', label: 'User (global)' }, - { value: 'project', label: 'Project' }, + { value: 'project', label: 'Project (shared)' }, + { value: 'local', label: 'Local (gitignored)' }, ]; export const PluginDetailDialog = ({ @@ -94,7 +95,7 @@ export const PluginDetailDialog = ({ }, [open, plugin?.pluginId]); useEffect(() => { - if (scope === 'project' && !projectScopeAvailable) { + if (scope !== 'user' && !projectScopeAvailable) { setScope('user'); } }, [projectScopeAvailable, scope]); @@ -186,7 +187,7 @@ export const PluginDetailDialog = ({ {opt.label} @@ -201,7 +202,7 @@ export const PluginDetailDialog = ({ installPlugin({ pluginId: plugin.pluginId, scope, - ...(scope === 'project' && pluginCatalogProjectPath + ...(scope !== 'user' && pluginCatalogProjectPath ? { projectPath: pluginCatalogProjectPath } : {}), }) @@ -210,7 +211,7 @@ export const PluginDetailDialog = ({ uninstallPlugin( plugin.pluginId, scope, - scope === 'project' ? (pluginCatalogProjectPath ?? undefined) : undefined + scope !== 'user' ? (pluginCatalogProjectPath ?? undefined) : undefined ) } size="default" diff --git a/src/renderer/store/slices/extensionsSlice.ts b/src/renderer/store/slices/extensionsSlice.ts index 6c207740..7932eeeb 100644 --- a/src/renderer/store/slices/extensionsSlice.ts +++ b/src/renderer/store/slices/extensionsSlice.ts @@ -225,7 +225,7 @@ const CLI_HEALTHCHECK_FAILED_MESSAGE = const CLI_STATUS_UNKNOWN_MESSAGE = 'Unable to verify Claude CLI status. Open the Dashboard and check the CLI before retrying.'; const PROJECT_SCOPE_REQUIRED_MESSAGE = - 'Project-scoped plugins require an active project in the Extensions tab.'; + 'Project- and local-scoped plugins require an active project in the Extensions tab.'; export const createExtensionsSlice: StateCreator = ( set, @@ -688,7 +688,7 @@ export const createExtensionsSlice: StateCreator ({ pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' }, diff --git a/src/shared/types/extensions/plugin.ts b/src/shared/types/extensions/plugin.ts index 698f800b..5ddb4efc 100644 --- a/src/shared/types/extensions/plugin.ts +++ b/src/shared/types/extensions/plugin.ts @@ -70,7 +70,7 @@ export function inferCapabilities(item: PluginCatalogItem): PluginCapability[] { export interface PluginInstallRequest { pluginId: string; // canonical key — main resolves qualifiedName from catalog scope: InstallScope; - projectPath?: string; // required for 'project' scope + projectPath?: string; // required for repo-scoped installs ('project' or 'local') } // ── Filters (renderer-only concern) ──────────────────────────────────────── diff --git a/test/main/services/extensions/PluginInstallService.test.ts b/test/main/services/extensions/PluginInstallService.test.ts index b88c1b1e..8223d06d 100644 --- a/test/main/services/extensions/PluginInstallService.test.ts +++ b/test/main/services/extensions/PluginInstallService.test.ts @@ -85,6 +85,22 @@ describe('PluginInstallService', () => { ); }); + it('adds local scope flag and cwd for local installs', async () => { + mockExecCli.mockResolvedValue({ stdout: '', stderr: '' }); + + await service.install({ + pluginId: 'context7', + scope: 'local', + projectPath: '/tmp/test-project', + }); + + expect(mockExecCli).toHaveBeenCalledWith( + '/usr/local/bin/claude', + ['plugin', 'install', '-s', 'local', 'context7@claude-plugins-official'], + expect.objectContaining({ cwd: '/tmp/test-project' }), + ); + }); + it('returns error if plugin not found in catalog', async () => { catalog = createMockCatalog({ resolvePlugin: vi.fn().mockResolvedValue(null) as PluginCatalogService['resolvePlugin'], @@ -129,6 +145,14 @@ describe('PluginInstallService', () => { expect(result.error).toContain('projectPath is required'); expect(mockExecCli).not.toHaveBeenCalled(); }); + + it('rejects local scope when projectPath is missing', async () => { + const result = await service.install({ pluginId: 'context7', scope: 'local' }); + + expect(result.state).toBe('error'); + expect(result.error).toContain('local-scoped'); + expect(mockExecCli).not.toHaveBeenCalled(); + }); }); // ── uninstall ─────────────────────────────────────────────────────────────── @@ -159,6 +183,18 @@ describe('PluginInstallService', () => { ); }); + it('adds scope flag for local scope', async () => { + mockExecCli.mockResolvedValue({ stdout: '', stderr: '' }); + + await service.uninstall('context7', 'local', '/tmp/test-project'); + + expect(mockExecCli).toHaveBeenCalledWith( + '/usr/local/bin/claude', + ['plugin', 'uninstall', '-s', 'local', 'context7@claude-plugins-official'], + expect.objectContaining({ cwd: '/tmp/test-project' }), + ); + }); + it('returns error if plugin not in catalog', async () => { catalog = createMockCatalog({ resolvePlugin: vi.fn().mockResolvedValue(null) as PluginCatalogService['resolvePlugin'], @@ -187,5 +223,13 @@ describe('PluginInstallService', () => { expect(result.error).toContain('projectPath is required'); expect(mockExecCli).not.toHaveBeenCalled(); }); + + it('rejects local scope when projectPath is missing', async () => { + const result = await service.uninstall('context7', 'local'); + + expect(result.state).toBe('error'); + expect(result.error).toContain('local-scoped'); + expect(mockExecCli).not.toHaveBeenCalled(); + }); }); }); diff --git a/test/renderer/store/extensionsSlice.test.ts b/test/renderer/store/extensionsSlice.test.ts index 5d9479eb..058b32d6 100644 --- a/test/renderer/store/extensionsSlice.test.ts +++ b/test/renderer/store/extensionsSlice.test.ts @@ -492,6 +492,32 @@ describe('extensionsSlice', () => { expect(store.getState().installErrors['project@m']).toContain('active project'); }); + it('fills missing projectPath for local scope from the active Extensions project context', async () => { + store.setState({ + cliStatus: makeReadyCliStatus(), + pluginCatalogProjectPath: '/tmp/project-a', + }); + (api.plugins!.install as ReturnType).mockResolvedValue({ state: 'success' }); + + await store.getState().installPlugin({ pluginId: 'local@m', scope: 'local' }); + + expect(api.plugins!.install).toHaveBeenCalledWith({ + pluginId: 'local@m', + scope: 'local', + projectPath: '/tmp/project-a', + }); + }); + + it('fails fast for local scope when there is no active project path', async () => { + store.setState({ cliStatus: makeReadyCliStatus(), pluginCatalogProjectPath: null }); + + await store.getState().installPlugin({ pluginId: 'local@m', scope: 'local' }); + + expect(api.plugins!.install).not.toHaveBeenCalled(); + expect(store.getState().pluginInstallProgress['local@m']).toBe('error'); + expect(store.getState().installErrors['local@m']).toContain('active project'); + }); + it('clears older success reset timers before a new operation on the same plugin', async () => { vi.useFakeTimers(); store.setState({ cliStatus: makeReadyCliStatus() }); @@ -545,6 +571,25 @@ describe('extensionsSlice', () => { expect(store.getState().installErrors['project@m']).toContain('active project'); }); + it('fills missing projectPath for local uninstall from the active Extensions project context', async () => { + store.setState({ pluginCatalogProjectPath: '/tmp/project-a' }); + (api.plugins!.uninstall as ReturnType).mockResolvedValue({ state: 'success' }); + + await store.getState().uninstallPlugin('local@m', 'local'); + + expect(api.plugins!.uninstall).toHaveBeenCalledWith('local@m', 'local', '/tmp/project-a'); + }); + + it('fails fast for local uninstall when there is no active project path', async () => { + store.setState({ pluginCatalogProjectPath: null }); + + await store.getState().uninstallPlugin('local@m', 'local'); + + expect(api.plugins!.uninstall).not.toHaveBeenCalled(); + expect(store.getState().pluginInstallProgress['local@m']).toBe('error'); + expect(store.getState().installErrors['local@m']).toContain('active project'); + }); + it('does not restore idle state after project switch clears a pending success timer', async () => { vi.useFakeTimers(); store.setState({ From 57f546bab0ddf3a6126cbe1591a3264b161b0560 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 22:16:38 +0300 Subject: [PATCH 060/121] fix(extensions): scope plugin operation state by install scope --- .../extensions/plugins/PluginCard.tsx | 6 +- .../extensions/plugins/PluginDetailDialog.tsx | 11 ++- src/renderer/store/slices/extensionsSlice.ts | 94 +++++++++++-------- src/shared/utils/extensionNormalizers.ts | 7 ++ test/renderer/store/extensionsSlice.test.ts | 90 ++++++++++++------ .../shared/utils/extensionNormalizers.test.ts | 9 ++ 6 files changed, 145 insertions(+), 72 deletions(-) diff --git a/src/renderer/components/extensions/plugins/PluginCard.tsx b/src/renderer/components/extensions/plugins/PluginCard.tsx index 24ca2d87..deef088f 100644 --- a/src/renderer/components/extensions/plugins/PluginCard.tsx +++ b/src/renderer/components/extensions/plugins/PluginCard.tsx @@ -7,6 +7,7 @@ import { useStore } from '@renderer/store'; import { getInstallationSummaryLabel, getCapabilityLabel, + getPluginOperationKey, hasInstallationInScope, inferCapabilities, normalizeCategory, @@ -27,10 +28,11 @@ interface PluginCardProps { export const PluginCard = ({ plugin, index, onClick }: PluginCardProps): React.JSX.Element => { const capabilities = inferCapabilities(plugin); const category = normalizeCategory(plugin.category); - const installProgress = useStore((s) => s.pluginInstallProgress[plugin.pluginId] ?? 'idle'); + const operationKey = getPluginOperationKey(plugin.pluginId, 'user'); + const installProgress = useStore((s) => s.pluginInstallProgress[operationKey] ?? 'idle'); const installPlugin = useStore((s) => s.installPlugin); const uninstallPlugin = useStore((s) => s.uninstallPlugin); - const installError = useStore((s) => s.installErrors[plugin.pluginId]); + const installError = useStore((s) => s.installErrors[operationKey]); const isUserInstalled = hasInstallationInScope(plugin.installations, 'user'); const installSummaryLabel = getInstallationSummaryLabel(plugin.installations); const baseStriped = index % 2 === 0; diff --git a/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx b/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx index 8d6e05ac..732e9ca6 100644 --- a/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx +++ b/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx @@ -27,6 +27,7 @@ import { useStore } from '@renderer/store'; import { getInstallationSummaryLabel, getCapabilityLabel, + getPluginOperationKey, hasInstallationInScope, inferCapabilities, normalizeCategory, @@ -74,10 +75,6 @@ export const PluginDetailDialog = ({ pluginCatalogProjectPath: s.pluginCatalogProjectPath, })) ); - const installProgress = useStore( - (s) => (plugin ? s.pluginInstallProgress[plugin.pluginId] : undefined) ?? 'idle' - ); - const installError = useStore((s) => (plugin ? s.installErrors[plugin.pluginId] : undefined)); const [scope, setScope] = useState('user'); const projectScopeAvailable = Boolean(pluginCatalogProjectPath); @@ -100,6 +97,12 @@ export const PluginDetailDialog = ({ } }, [projectScopeAvailable, scope]); + const operationKey = plugin ? getPluginOperationKey(plugin.pluginId, scope) : null; + const installProgress = useStore( + (s) => (operationKey ? s.pluginInstallProgress[operationKey] : undefined) ?? 'idle' + ); + const installError = useStore((s) => (operationKey ? s.installErrors[operationKey] : undefined)); + if (!plugin) return <>; const capabilities = inferCapabilities(plugin); diff --git a/src/renderer/store/slices/extensionsSlice.ts b/src/renderer/store/slices/extensionsSlice.ts index 7932eeeb..fdea0a79 100644 --- a/src/renderer/store/slices/extensionsSlice.ts +++ b/src/renderer/store/slices/extensionsSlice.ts @@ -5,6 +5,7 @@ import { api } from '@renderer/api'; import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli'; +import { getPluginOperationKey } from '@shared/utils/extensionNormalizers'; import { findPaneByTabId, updatePane } from '../utils/paneHelpers'; @@ -150,6 +151,10 @@ function buildPluginIdSet(catalog: EnrichedPlugin[]): Set { return new Set(catalog.map((plugin) => plugin.pluginId)); } +function buildPluginOperationKeys(pluginId: string): string[] { + return PLUGIN_OPERATION_SCOPES.map((scope) => getPluginOperationKey(pluginId, scope)); +} + function clearPluginOperationState( pluginIds: Set, pluginInstallProgress: Record, @@ -166,8 +171,10 @@ function clearPluginOperationState( const nextInstallErrors = { ...installErrors }; for (const pluginId of pluginIds) { - delete nextPluginInstallProgress[pluginId]; - delete nextInstallErrors[pluginId]; + for (const operationKey of buildPluginOperationKeys(pluginId)) { + delete nextPluginInstallProgress[operationKey]; + delete nextInstallErrors[operationKey]; + } } return { @@ -176,40 +183,42 @@ function clearPluginOperationState( }; } -function clearPluginSuccessResetTimer(pluginId: string): void { - const timer = pluginSuccessResetTimers.get(pluginId); +function clearPluginSuccessResetTimer(operationKey: string): void { + const timer = pluginSuccessResetTimers.get(operationKey); if (!timer) { return; } clearTimeout(timer); - pluginSuccessResetTimers.delete(pluginId); + pluginSuccessResetTimers.delete(operationKey); } function clearPluginSuccessResetTimers(pluginIds: Set): void { for (const pluginId of pluginIds) { - clearPluginSuccessResetTimer(pluginId); + for (const operationKey of buildPluginOperationKeys(pluginId)) { + clearPluginSuccessResetTimer(operationKey); + } } } function schedulePluginSuccessReset( - pluginId: string, + operationKey: string, set: Parameters>[0] ): void { - clearPluginSuccessResetTimer(pluginId); + clearPluginSuccessResetTimer(operationKey); const timer = setTimeout(() => { - pluginSuccessResetTimers.delete(pluginId); + pluginSuccessResetTimers.delete(operationKey); set((prev) => { - if (prev.pluginInstallProgress[pluginId] !== 'success') { + if (prev.pluginInstallProgress[operationKey] !== 'success') { return {}; } return { - pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'idle' }, + pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'idle' }, }; }); }, SUCCESS_DISPLAY_MS); - pluginSuccessResetTimers.set(pluginId, timer); + pluginSuccessResetTimers.set(operationKey, timer); } function getSkillsCatalogKey(projectPath?: string): string { @@ -226,6 +235,7 @@ const CLI_STATUS_UNKNOWN_MESSAGE = 'Unable to verify Claude CLI status. Open the Dashboard and check the CLI before retrying.'; const PROJECT_SCOPE_REQUIRED_MESSAGE = 'Project- and local-scoped plugins require an active project in the Extensions tab.'; +const PLUGIN_OPERATION_SCOPES: InstallScope[] = ['user', 'project', 'local']; export const createExtensionsSlice: StateCreator = ( set, @@ -691,6 +701,7 @@ export const createExtensionsSlice: StateCreator ({ - pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' }, - installErrors: { ...prev.installErrors, [request.pluginId]: preflightError }, + pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'error' }, + installErrors: { ...prev.installErrors, [operationKey]: preflightError }, })); return; } - clearPluginSuccessResetTimer(request.pluginId); + clearPluginSuccessResetTimer(operationKey); set((prev) => ({ - pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'pending' }, - installErrors: { ...prev.installErrors, [request.pluginId]: '' }, + pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'pending' }, + installErrors: { ...prev.installErrors, [operationKey]: '' }, })); try { const result = await api.plugins.install(effectiveRequest); if (result.state === 'error') { set((prev) => ({ - pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' }, + pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'error' }, installErrors: { ...prev.installErrors, - [request.pluginId]: result.error ?? 'Install failed', + [operationKey]: result.error ?? 'Install failed', }, })); return; } set((prev) => ({ - pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'success' }, + pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'success' }, })); // Refresh catalog to pick up new installed state void get().fetchPluginCatalog(get().pluginCatalogProjectPath ?? undefined, true); - schedulePluginSuccessReset(request.pluginId, set); + schedulePluginSuccessReset(operationKey, set); } catch (err) { - clearPluginSuccessResetTimer(request.pluginId); + clearPluginSuccessResetTimer(operationKey); const message = err instanceof Error ? err.message : 'Install failed'; set((prev) => ({ - pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' }, - installErrors: { ...prev.installErrors, [request.pluginId]: message }, + pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'error' }, + installErrors: { ...prev.installErrors, [operationKey]: message }, })); } }, @@ -769,48 +780,53 @@ export const createExtensionsSlice: StateCreator { if (!api.plugins) return; + const effectiveScope = scope ?? 'user'; + const operationKey = getPluginOperationKey(pluginId, effectiveScope); const effectiveProjectPath = - scope && scope !== 'user' + effectiveScope !== 'user' ? (projectPath ?? get().pluginCatalogProjectPath ?? undefined) : projectPath; - if (scope && scope !== 'user' && !effectiveProjectPath) { - clearPluginSuccessResetTimer(pluginId); + if (effectiveScope !== 'user' && !effectiveProjectPath) { + clearPluginSuccessResetTimer(operationKey); set((prev) => ({ - pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' }, - installErrors: { ...prev.installErrors, [pluginId]: PROJECT_SCOPE_REQUIRED_MESSAGE }, + pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'error' }, + installErrors: { ...prev.installErrors, [operationKey]: PROJECT_SCOPE_REQUIRED_MESSAGE }, })); return; } - clearPluginSuccessResetTimer(pluginId); + clearPluginSuccessResetTimer(operationKey); set((prev) => ({ - pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'pending' }, + pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'pending' }, })); try { const result = await api.plugins.uninstall(pluginId, scope, effectiveProjectPath); if (result.state === 'error') { set((prev) => ({ - pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' }, - installErrors: { ...prev.installErrors, [pluginId]: result.error ?? 'Uninstall failed' }, + pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'error' }, + installErrors: { + ...prev.installErrors, + [operationKey]: result.error ?? 'Uninstall failed', + }, })); return; } set((prev) => ({ - pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'success' }, + pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'success' }, })); // Refresh catalog void get().fetchPluginCatalog(get().pluginCatalogProjectPath ?? undefined, true); - schedulePluginSuccessReset(pluginId, set); + schedulePluginSuccessReset(operationKey, set); } catch (err) { - clearPluginSuccessResetTimer(pluginId); + clearPluginSuccessResetTimer(operationKey); const message = err instanceof Error ? err.message : 'Uninstall failed'; set((prev) => ({ - pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' }, - installErrors: { ...prev.installErrors, [pluginId]: message }, + pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'error' }, + installErrors: { ...prev.installErrors, [operationKey]: message }, })); } }, diff --git a/src/shared/utils/extensionNormalizers.ts b/src/shared/utils/extensionNormalizers.ts index 7684925a..a5c6420e 100644 --- a/src/shared/utils/extensionNormalizers.ts +++ b/src/shared/utils/extensionNormalizers.ts @@ -100,6 +100,13 @@ export function buildPluginId(pluginName: string, marketplaceName: string): stri return `${pluginName}@${marketplaceName}`; } +/** + * Namespaced operation-state key for plugin install/uninstall UI state. + */ +export function getPluginOperationKey(pluginId: string, scope: InstallScope): string { + return `plugin:${pluginId}:${scope}`; +} + /** * Check whether a plugin has an installation for the selected scope. */ diff --git a/test/renderer/store/extensionsSlice.test.ts b/test/renderer/store/extensionsSlice.test.ts index 058b32d6..8e2a0c8b 100644 --- a/test/renderer/store/extensionsSlice.test.ts +++ b/test/renderer/store/extensionsSlice.test.ts @@ -40,6 +40,7 @@ vi.mock('../../../src/renderer/api', () => ({ })); import { api } from '../../../src/renderer/api'; +import { getPluginOperationKey } from '../../../src/shared/utils/extensionNormalizers'; import type { EnrichedPlugin, @@ -133,6 +134,9 @@ const makeReadyCliStatus = () => ({ providers: [], }); +const pluginOperationKey = (pluginId: string, scope: 'user' | 'project' | 'local' = 'user') => + getPluginOperationKey(pluginId, scope); + describe('extensionsSlice', () => { let store: TestStore; @@ -187,10 +191,10 @@ describe('extensionsSlice', () => { pluginCatalog: [makePlugin({ pluginId: 'project-a@m' })], pluginCatalogProjectPath: '/tmp/project-a', pluginInstallProgress: { - 'project-a@m': 'error', + [pluginOperationKey('project-a@m', 'project')]: 'error', }, installErrors: { - 'project-a@m': 'Install failed', + [pluginOperationKey('project-a@m', 'project')]: 'Install failed', 'mcp-server': 'Keep me', }, }); @@ -201,8 +205,10 @@ describe('extensionsSlice', () => { await store.getState().fetchPluginCatalog('/tmp/project-b'); expect(store.getState().pluginCatalogProjectPath).toBe('/tmp/project-b'); - expect(store.getState().pluginInstallProgress['project-a@m']).toBeUndefined(); - expect(store.getState().installErrors['project-a@m']).toBeUndefined(); + expect( + store.getState().pluginInstallProgress[pluginOperationKey('project-a@m', 'project')], + ).toBeUndefined(); + expect(store.getState().installErrors[pluginOperationKey('project-a@m', 'project')]).toBeUndefined(); expect(store.getState().installErrors['mcp-server']).toBe('Keep me'); }); @@ -266,10 +272,10 @@ describe('extensionsSlice', () => { pluginCatalog: [makePlugin({ pluginId: 'project-a@m' })], pluginCatalogProjectPath: '/tmp/project-a', pluginInstallProgress: { - 'project-a@m': 'error', + [pluginOperationKey('project-a@m', 'project')]: 'error', }, installErrors: { - 'project-a@m': 'Install failed', + [pluginOperationKey('project-a@m', 'project')]: 'Install failed', 'mcp-server': 'Keep me', }, }); @@ -278,8 +284,10 @@ describe('extensionsSlice', () => { await store.getState().fetchPluginCatalog('/tmp/project-b'); expect(store.getState().pluginCatalog).toEqual([]); - expect(store.getState().pluginInstallProgress['project-a@m']).toBeUndefined(); - expect(store.getState().installErrors['project-a@m']).toBeUndefined(); + expect( + store.getState().pluginInstallProgress[pluginOperationKey('project-a@m', 'project')], + ).toBeUndefined(); + expect(store.getState().installErrors[pluginOperationKey('project-a@m', 'project')]).toBeUndefined(); expect(store.getState().installErrors['mcp-server']).toBe('Keep me'); }); }); @@ -448,10 +456,10 @@ describe('extensionsSlice', () => { const promise = store.getState().installPlugin({ pluginId: 'test@m', scope: 'user' }); // During execution, should be pending - expect(store.getState().pluginInstallProgress['test@m']).toBe('pending'); + expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('pending'); await promise; - expect(store.getState().pluginInstallProgress['test@m']).toBe('success'); + expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('success'); }); it('sets progress to error on failure', async () => { @@ -463,7 +471,7 @@ describe('extensionsSlice', () => { await store.getState().installPlugin({ pluginId: 'fail@m', scope: 'user' }); - expect(store.getState().pluginInstallProgress['fail@m']).toBe('error'); + expect(store.getState().pluginInstallProgress[pluginOperationKey('fail@m')]).toBe('error'); }); it('fills missing projectPath from the active Extensions project context', async () => { @@ -488,8 +496,12 @@ describe('extensionsSlice', () => { await store.getState().installPlugin({ pluginId: 'project@m', scope: 'project' }); expect(api.plugins!.install).not.toHaveBeenCalled(); - expect(store.getState().pluginInstallProgress['project@m']).toBe('error'); - expect(store.getState().installErrors['project@m']).toContain('active project'); + expect(store.getState().pluginInstallProgress[pluginOperationKey('project@m', 'project')]).toBe( + 'error', + ); + expect(store.getState().installErrors[pluginOperationKey('project@m', 'project')]).toContain( + 'active project', + ); }); it('fills missing projectPath for local scope from the active Extensions project context', async () => { @@ -514,8 +526,24 @@ describe('extensionsSlice', () => { await store.getState().installPlugin({ pluginId: 'local@m', scope: 'local' }); expect(api.plugins!.install).not.toHaveBeenCalled(); - expect(store.getState().pluginInstallProgress['local@m']).toBe('error'); - expect(store.getState().installErrors['local@m']).toContain('active project'); + expect(store.getState().pluginInstallProgress[pluginOperationKey('local@m', 'local')]).toBe( + 'error', + ); + expect(store.getState().installErrors[pluginOperationKey('local@m', 'local')]).toContain( + 'active project', + ); + }); + + it('keeps user-scope state isolated from local-scope failures', async () => { + store.setState({ cliStatus: makeReadyCliStatus(), pluginCatalogProjectPath: null }); + + await store.getState().installPlugin({ pluginId: 'shared@m', scope: 'local' }); + + expect(store.getState().pluginInstallProgress[pluginOperationKey('shared@m', 'local')]).toBe( + 'error', + ); + expect(store.getState().pluginInstallProgress[pluginOperationKey('shared@m', 'user')]).toBeUndefined(); + expect(store.getState().installErrors[pluginOperationKey('shared@m', 'user')]).toBeUndefined(); }); it('clears older success reset timers before a new operation on the same plugin', async () => { @@ -527,14 +555,14 @@ describe('extensionsSlice', () => { .mockResolvedValueOnce({ state: 'error', error: 'second failure' }); await store.getState().installPlugin({ pluginId: 'test@m', scope: 'user' }); - expect(store.getState().pluginInstallProgress['test@m']).toBe('success'); + expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('success'); await store.getState().installPlugin({ pluginId: 'test@m', scope: 'user' }); - expect(store.getState().pluginInstallProgress['test@m']).toBe('error'); + expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('error'); await vi.advanceTimersByTimeAsync(2_000); - expect(store.getState().pluginInstallProgress['test@m']).toBe('error'); + expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('error'); }); }); @@ -546,10 +574,10 @@ describe('extensionsSlice', () => { const promise = store.getState().uninstallPlugin('test@m', 'user'); - expect(store.getState().pluginInstallProgress['test@m']).toBe('pending'); + expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('pending'); await promise; - expect(store.getState().pluginInstallProgress['test@m']).toBe('success'); + expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('success'); }); it('fills missing projectPath from the active Extensions project context', async () => { @@ -567,8 +595,12 @@ describe('extensionsSlice', () => { await store.getState().uninstallPlugin('project@m', 'project'); expect(api.plugins!.uninstall).not.toHaveBeenCalled(); - expect(store.getState().pluginInstallProgress['project@m']).toBe('error'); - expect(store.getState().installErrors['project@m']).toContain('active project'); + expect(store.getState().pluginInstallProgress[pluginOperationKey('project@m', 'project')]).toBe( + 'error', + ); + expect(store.getState().installErrors[pluginOperationKey('project@m', 'project')]).toContain( + 'active project', + ); }); it('fills missing projectPath for local uninstall from the active Extensions project context', async () => { @@ -586,8 +618,12 @@ describe('extensionsSlice', () => { await store.getState().uninstallPlugin('local@m', 'local'); expect(api.plugins!.uninstall).not.toHaveBeenCalled(); - expect(store.getState().pluginInstallProgress['local@m']).toBe('error'); - expect(store.getState().installErrors['local@m']).toContain('active project'); + expect(store.getState().pluginInstallProgress[pluginOperationKey('local@m', 'local')]).toBe( + 'error', + ); + expect(store.getState().installErrors[pluginOperationKey('local@m', 'local')]).toContain( + 'active project', + ); }); it('does not restore idle state after project switch clears a pending success timer', async () => { @@ -602,14 +638,14 @@ describe('extensionsSlice', () => { (api.plugins!.uninstall as ReturnType).mockResolvedValue({ state: 'success' }); await store.getState().uninstallPlugin('test@m', 'user'); - expect(store.getState().pluginInstallProgress['test@m']).toBe('success'); + expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('success'); await store.getState().fetchPluginCatalog('/tmp/project-b'); - expect(store.getState().pluginInstallProgress['test@m']).toBeUndefined(); + expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBeUndefined(); await vi.advanceTimersByTimeAsync(2_000); - expect(store.getState().pluginInstallProgress['test@m']).toBeUndefined(); + expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBeUndefined(); }); }); diff --git a/test/shared/utils/extensionNormalizers.test.ts b/test/shared/utils/extensionNormalizers.test.ts index 7905fd99..427a22bb 100644 --- a/test/shared/utils/extensionNormalizers.test.ts +++ b/test/shared/utils/extensionNormalizers.test.ts @@ -8,6 +8,7 @@ import { getExtensionActionDisableReason, getCapabilityLabel, getInstallationSummaryLabel, + getPluginOperationKey, getPrimaryCapabilityLabel, hasInstallationInScope, inferCapabilities, @@ -152,6 +153,14 @@ describe('buildPluginId', () => { }); }); +describe('getPluginOperationKey', () => { + it('namespaces plugin operation keys by scope', () => { + expect(getPluginOperationKey('context7@claude-plugins-official', 'local')).toBe( + 'plugin:context7@claude-plugins-official:local', + ); + }); +}); + describe('hasInstallationInScope', () => { it('returns true when the selected scope exists', () => { expect( From 743dbec36d2ebaa2de785410e41ef8d843bffe51 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 22:17:41 +0300 Subject: [PATCH 061/121] fix(extensions): surface cli healthcheck failures in action tooltips --- src/shared/utils/extensionNormalizers.ts | 8 ++++++- .../shared/utils/extensionNormalizers.test.ts | 21 ++++++++++++++++--- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/shared/utils/extensionNormalizers.ts b/src/shared/utils/extensionNormalizers.ts index a5c6420e..ecff2758 100644 --- a/src/shared/utils/extensionNormalizers.ts +++ b/src/shared/utils/extensionNormalizers.ts @@ -149,7 +149,10 @@ export function getInstallationSummaryLabel( */ export function getExtensionActionDisableReason(options: { isInstalled: boolean; - cliStatus: Pick | null; + cliStatus: Pick< + CliInstallationStatus, + 'installed' | 'authLoggedIn' | 'binaryPath' | 'launchError' + > | null; cliStatusLoading: boolean; }): string | null { const { isInstalled, cliStatus, cliStatusLoading } = options; @@ -162,6 +165,9 @@ export function getExtensionActionDisableReason(options: { } if (cliStatus.installed === false) { + if (cliStatus.binaryPath && cliStatus.launchError) { + return 'Claude CLI was found but failed to start. Open the Dashboard to repair or reinstall it.'; + } return 'Claude CLI required. Install it from the Dashboard.'; } diff --git a/test/shared/utils/extensionNormalizers.test.ts b/test/shared/utils/extensionNormalizers.test.ts index 427a22bb..b78f1bdc 100644 --- a/test/shared/utils/extensionNormalizers.test.ts +++ b/test/shared/utils/extensionNormalizers.test.ts @@ -207,7 +207,7 @@ describe('getExtensionActionDisableReason', () => { expect( getExtensionActionDisableReason({ isInstalled: false, - cliStatus: { installed: true, authLoggedIn: false }, + cliStatus: { installed: true, authLoggedIn: false, binaryPath: null, launchError: null }, cliStatusLoading: false, }), ).toContain('not signed in'); @@ -217,7 +217,7 @@ describe('getExtensionActionDisableReason', () => { expect( getExtensionActionDisableReason({ isInstalled: true, - cliStatus: { installed: true, authLoggedIn: false }, + cliStatus: { installed: true, authLoggedIn: false, binaryPath: null, launchError: null }, cliStatusLoading: false, }), ).toBeNull(); @@ -227,11 +227,26 @@ describe('getExtensionActionDisableReason', () => { expect( getExtensionActionDisableReason({ isInstalled: true, - cliStatus: { installed: false, authLoggedIn: false }, + cliStatus: { installed: false, authLoggedIn: false, binaryPath: null, launchError: null }, cliStatusLoading: false, }), ).toContain('Claude CLI required'); }); + + it('surfaces startup health-check failures separately from missing CLI', () => { + expect( + getExtensionActionDisableReason({ + isInstalled: false, + cliStatus: { + installed: false, + authLoggedIn: false, + binaryPath: '/usr/local/bin/claude', + launchError: 'spawn EACCES', + }, + cliStatusLoading: false, + }), + ).toContain('failed to start'); + }); }); describe('sanitizeMcpServerName', () => { From 0d19e5174fb0f7f124a5c8896a96d89829678784 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 22:22:05 +0300 Subject: [PATCH 062/121] fix(extensions): use tab project context for plugin actions --- .../extensions/ExtensionStoreView.tsx | 1 + .../extensions/plugins/PluginDetailDialog.tsx | 20 +- .../extensions/plugins/PluginsPanel.tsx | 3 + .../plugins/PluginDetailDialog.test.ts | 269 ++++++++++++++++++ 4 files changed, 279 insertions(+), 14 deletions(-) create mode 100644 test/renderer/components/extensions/plugins/PluginDetailDialog.test.ts diff --git a/src/renderer/components/extensions/ExtensionStoreView.tsx b/src/renderer/components/extensions/ExtensionStoreView.tsx index 7b736687..84648b44 100644 --- a/src/renderer/components/extensions/ExtensionStoreView.tsx +++ b/src/renderer/components/extensions/ExtensionStoreView.tsx @@ -323,6 +323,7 @@ export const ExtensionStoreView = (): React.JSX.Element => { void; + projectPath: string | null; } const SCOPE_OPTIONS: { value: InstallScope; label: string }[] = [ @@ -57,27 +58,20 @@ export const PluginDetailDialog = ({ plugin, open, onClose, + projectPath, }: PluginDetailDialogProps): React.JSX.Element => { - const { - fetchPluginReadme, - readmes, - readmeLoading, - installPlugin, - uninstallPlugin, - pluginCatalogProjectPath, - } = useStore( + const { fetchPluginReadme, readmes, readmeLoading, installPlugin, uninstallPlugin } = useStore( useShallow((s) => ({ fetchPluginReadme: s.fetchPluginReadme, readmes: s.pluginReadmes, readmeLoading: s.pluginReadmeLoading, installPlugin: s.installPlugin, uninstallPlugin: s.uninstallPlugin, - pluginCatalogProjectPath: s.pluginCatalogProjectPath, })) ); const [scope, setScope] = useState('user'); - const projectScopeAvailable = Boolean(pluginCatalogProjectPath); + const projectScopeAvailable = Boolean(projectPath); useEffect(() => { if (plugin && open) { @@ -205,16 +199,14 @@ export const PluginDetailDialog = ({ installPlugin({ pluginId: plugin.pluginId, scope, - ...(scope !== 'user' && pluginCatalogProjectPath - ? { projectPath: pluginCatalogProjectPath } - : {}), + ...(scope !== 'user' && projectPath ? { projectPath } : {}), }) } onUninstall={() => uninstallPlugin( plugin.pluginId, scope, - scope !== 'user' ? (pluginCatalogProjectPath ?? undefined) : undefined + scope !== 'user' ? (projectPath ?? undefined) : undefined ) } size="default" diff --git a/src/renderer/components/extensions/plugins/PluginsPanel.tsx b/src/renderer/components/extensions/plugins/PluginsPanel.tsx index 5480b7c8..4fe38a2e 100644 --- a/src/renderer/components/extensions/plugins/PluginsPanel.tsx +++ b/src/renderer/components/extensions/plugins/PluginsPanel.tsx @@ -35,6 +35,7 @@ import type { } from '@shared/types/extensions'; interface PluginsPanelProps { + projectPath: string | null; pluginFilters: PluginFilters; pluginSort: { field: PluginSortField; order: 'asc' | 'desc' }; selectedPluginId: string | null; @@ -111,6 +112,7 @@ function selectFilteredPlugins( } export const PluginsPanel = ({ + projectPath, pluginFilters, pluginSort, selectedPluginId, @@ -395,6 +397,7 @@ export const PluginsPanel = ({ plugin={selectedPlugin} open={selectedPluginId !== null} onClose={() => setSelectedPluginId(null)} + projectPath={projectPath} />
); diff --git a/test/renderer/components/extensions/plugins/PluginDetailDialog.test.ts b/test/renderer/components/extensions/plugins/PluginDetailDialog.test.ts new file mode 100644 index 00000000..753761f8 --- /dev/null +++ b/test/renderer/components/extensions/plugins/PluginDetailDialog.test.ts @@ -0,0 +1,269 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { EnrichedPlugin } from '@shared/types/extensions'; + +interface StoreState { + fetchPluginReadme: ReturnType; + pluginReadmes: Record; + pluginReadmeLoading: Record; + installPlugin: ReturnType; + uninstallPlugin: ReturnType; + pluginCatalogProjectPath: string | null; + pluginInstallProgress: Record; + installErrors: Record; +} + +const storeState = {} as StoreState; + +vi.mock('@renderer/store', () => ({ + useStore: (selector: (state: StoreState) => unknown) => selector(storeState), +})); + +vi.mock('zustand/react/shallow', () => ({ + useShallow: (selector: unknown) => selector, +})); + +vi.mock('@renderer/api', () => ({ + api: { + openExternal: vi.fn(), + }, +})); + +vi.mock('@renderer/components/chat/viewers/MarkdownViewer', () => ({ + MarkdownViewer: ({ content }: { content: string }) => + React.createElement('div', { 'data-testid': 'markdown' }, content), +})); + +vi.mock('@renderer/components/ui/badge', () => ({ + Badge: ({ children }: React.PropsWithChildren) => React.createElement('span', null, children), +})); + +vi.mock('@renderer/components/ui/button', () => ({ + Button: ({ + children, + onClick, + type = 'button', + }: React.PropsWithChildren<{ + onClick?: () => void; + type?: 'button' | 'submit' | 'reset'; + }>) => + React.createElement( + 'button', + { + type, + onClick, + }, + children + ), +})); + +vi.mock('@renderer/components/ui/dialog', () => ({ + Dialog: ({ open, children }: React.PropsWithChildren<{ open: boolean }>) => + open ? React.createElement('div', { 'data-testid': 'dialog' }, children) : null, + DialogContent: ({ children }: React.PropsWithChildren) => + React.createElement('div', { 'data-testid': 'dialog-content' }, children), + DialogHeader: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children), + DialogTitle: ({ children }: React.PropsWithChildren) => React.createElement('h2', null, children), + DialogDescription: ({ children }: React.PropsWithChildren) => + React.createElement('p', null, children), +})); + +vi.mock('@renderer/components/ui/label', () => ({ + Label: ({ children }: React.PropsWithChildren) => React.createElement('label', null, children), +})); + +vi.mock('@renderer/components/ui/select', () => ({ + Select: ({ + children, + value, + onValueChange, + }: React.PropsWithChildren<{ value: string; onValueChange: (value: string) => void }>) => + React.createElement( + 'select', + { + 'data-testid': 'scope-select', + value, + onChange: (event: React.ChangeEvent) => onValueChange(event.target.value), + }, + children + ), + SelectTrigger: ({ children }: React.PropsWithChildren) => + React.createElement(React.Fragment, null, children), + SelectValue: () => null, + SelectContent: ({ children }: React.PropsWithChildren) => + React.createElement(React.Fragment, null, children), + SelectItem: ({ + children, + value, + disabled, + }: React.PropsWithChildren<{ value: string; disabled?: boolean }>) => + React.createElement( + 'option', + { + value, + disabled, + }, + children + ), +})); + +vi.mock('@renderer/components/extensions/common/InstallButton', () => ({ + InstallButton: ({ + isInstalled, + onInstall, + onUninstall, + }: { + isInstalled: boolean; + onInstall: () => void; + onUninstall: () => void; + }) => + React.createElement( + 'button', + { + type: 'button', + 'data-testid': 'install-button', + onClick: () => (isInstalled ? onUninstall() : onInstall()), + }, + isInstalled ? 'Uninstall' : 'Install' + ), +})); + +vi.mock('@renderer/components/extensions/common/InstallCountBadge', () => ({ + InstallCountBadge: ({ count }: { count: number }) => + React.createElement('span', { 'data-testid': 'install-count' }, String(count)), +})); + +vi.mock('@renderer/components/extensions/common/SourceBadge', () => ({ + SourceBadge: ({ source }: { source: string }) => + React.createElement('span', { 'data-testid': 'source-badge' }, source), +})); + +vi.mock('lucide-react', () => { + const Icon = (props: React.SVGProps) => React.createElement('svg', props); + return { + ExternalLink: Icon, + Loader2: Icon, + Mail: Icon, + }; +}); + +import { PluginDetailDialog } from '@renderer/components/extensions/plugins/PluginDetailDialog'; + +const makePlugin = (): EnrichedPlugin => ({ + pluginId: 'context7@claude-plugins-official', + marketplaceId: 'context7@claude-plugins-official', + qualifiedName: 'context7@claude-plugins-official', + name: 'Context7', + source: 'official', + description: 'Fresh docs in Claude', + category: 'docs', + author: { name: 'Anthropic', email: 'help@example.com' }, + version: '1.0.0', + homepage: 'https://example.com/context7', + tags: [], + hasLspServers: false, + hasMcpServers: true, + hasAgents: false, + hasCommands: false, + hasHooks: false, + isExternal: true, + installCount: 42, + isInstalled: false, + installations: [], +}); + +describe('PluginDetailDialog project context', () => { + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.fetchPluginReadme = vi.fn(); + storeState.pluginReadmes = {}; + storeState.pluginReadmeLoading = {}; + storeState.installPlugin = vi.fn(); + storeState.uninstallPlugin = vi.fn(); + storeState.pluginCatalogProjectPath = '/tmp/global-project'; + storeState.pluginInstallProgress = {}; + storeState.installErrors = {}; + }); + + afterEach(() => { + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + }); + + it('uses the current tab project path for project-scope installs', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const plugin = makePlugin(); + + await act(async () => { + root.render( + React.createElement(PluginDetailDialog, { + plugin, + open: true, + onClose: vi.fn(), + projectPath: '/tmp/tab-project', + }) + ); + await Promise.resolve(); + }); + + const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; + expect(scopeSelect).not.toBeNull(); + + await act(async () => { + scopeSelect.value = 'project'; + scopeSelect.dispatchEvent(new Event('change', { bubbles: true })); + await Promise.resolve(); + }); + + const installButton = host.querySelector('[data-testid="install-button"]') as HTMLButtonElement; + expect(installButton).not.toBeNull(); + + await act(async () => { + installButton.click(); + await Promise.resolve(); + }); + + expect(storeState.installPlugin).toHaveBeenCalledWith({ + pluginId: plugin.pluginId, + scope: 'project', + projectPath: '/tmp/tab-project', + }); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('disables project and local scopes when the current tab has no project', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(PluginDetailDialog, { + plugin: makePlugin(), + open: true, + onClose: vi.fn(), + projectPath: null, + }) + ); + await Promise.resolve(); + }); + + const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; + expect(scopeSelect).not.toBeNull(); + expect(scopeSelect.querySelector('option[value="project"]')?.disabled).toBe(true); + expect(scopeSelect.querySelector('option[value="local"]')?.disabled).toBe(true); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +}); From 495d8514c1c11e2be55dce4483dcc64e39f1b5c3 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 22:23:46 +0300 Subject: [PATCH 063/121] fix(extensions): clear detail selections on sub-tab switch --- src/renderer/hooks/useExtensionsTabState.ts | 12 ++ .../hooks/useExtensionsTabState.test.ts | 122 ++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 test/renderer/hooks/useExtensionsTabState.test.ts diff --git a/src/renderer/hooks/useExtensionsTabState.ts b/src/renderer/hooks/useExtensionsTabState.ts index cffc4e9a..ce71077d 100644 --- a/src/renderer/hooks/useExtensionsTabState.ts +++ b/src/renderer/hooks/useExtensionsTabState.ts @@ -68,6 +68,18 @@ export function useExtensionsTabState() { }; }, []); + useEffect(() => { + if (activeSubTab !== 'plugins' && selectedPluginId !== null) { + setSelectedPluginId(null); + } + if (activeSubTab !== 'mcp-servers' && selectedMcpServerId !== null) { + setSelectedMcpServerId(null); + } + if (activeSubTab !== 'skills' && selectedSkillId !== null) { + setSelectedSkillId(null); + } + }, [activeSubTab, selectedMcpServerId, selectedPluginId, selectedSkillId]); + const mcpSearch = useCallback((query: string) => { setMcpSearchQuery(query); diff --git a/test/renderer/hooks/useExtensionsTabState.test.ts b/test/renderer/hooks/useExtensionsTabState.test.ts new file mode 100644 index 00000000..52ae6fbb --- /dev/null +++ b/test/renderer/hooks/useExtensionsTabState.test.ts @@ -0,0 +1,122 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { useExtensionsTabState } from '../../../src/renderer/hooks/useExtensionsTabState'; + +type ExtensionsTabState = ReturnType; + +let capturedState: ExtensionsTabState | null = null; + +vi.mock('@renderer/api', () => ({ + api: { + mcpRegistry: null, + }, +})); + +function Harness(): null { + capturedState = useExtensionsTabState(); + return null; +} + +describe('useExtensionsTabState', () => { + afterEach(() => { + capturedState = null; + document.body.innerHTML = ''; + }); + + it('clears selected plugin when leaving the plugins sub-tab', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + 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 act(async () => { + capturedState?.setSelectedPluginId('context7@claude-plugins-official'); + await Promise.resolve(); + }); + expect(capturedState?.selectedPluginId).toBe('context7@claude-plugins-official'); + + await act(async () => { + capturedState?.setActiveSubTab('mcp-servers'); + await Promise.resolve(); + }); + expect(capturedState?.selectedPluginId).toBeNull(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('clears selected MCP server when leaving the MCP sub-tab', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + 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 act(async () => { + capturedState?.setActiveSubTab('mcp-servers'); + await Promise.resolve(); + }); + await act(async () => { + capturedState?.setSelectedMcpServerId('server-1'); + await Promise.resolve(); + }); + expect(capturedState?.selectedMcpServerId).toBe('server-1'); + + await act(async () => { + capturedState?.setActiveSubTab('skills'); + await Promise.resolve(); + }); + expect(capturedState?.selectedMcpServerId).toBeNull(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('clears selected skill when leaving the skills sub-tab', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + 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 act(async () => { + capturedState?.setActiveSubTab('skills'); + await Promise.resolve(); + }); + await act(async () => { + capturedState?.setSelectedSkillId('skill-1'); + await Promise.resolve(); + }); + expect(capturedState?.selectedSkillId).toBe('skill-1'); + + await act(async () => { + capturedState?.setActiveSubTab('api-keys'); + await Promise.resolve(); + }); + expect(capturedState?.selectedSkillId).toBeNull(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +}); From 113f9105fbb73170881f060529907560ef05df71 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 22:26:03 +0300 Subject: [PATCH 064/121] fix(extensions): ignore stale MCP search responses --- src/renderer/hooks/useExtensionsTabState.ts | 13 +- .../hooks/useExtensionsTabState.test.ts | 131 +++++++++++++++++- 2 files changed, 141 insertions(+), 3 deletions(-) diff --git a/src/renderer/hooks/useExtensionsTabState.ts b/src/renderer/hooks/useExtensionsTabState.ts index ce71077d..6cb9375e 100644 --- a/src/renderer/hooks/useExtensionsTabState.ts +++ b/src/renderer/hooks/useExtensionsTabState.ts @@ -58,6 +58,7 @@ export function useExtensionsTabState() { // ── Debounced MCP search ── const searchTimerRef = useRef | null>(null); + const mcpSearchRequestSeqRef = useRef(0); // Cleanup timer on unmount useEffect(() => { @@ -65,6 +66,7 @@ export function useExtensionsTabState() { if (searchTimerRef.current) { clearTimeout(searchTimerRef.current); } + mcpSearchRequestSeqRef.current += 1; }; }, []); @@ -82,6 +84,7 @@ export function useExtensionsTabState() { const mcpSearch = useCallback((query: string) => { setMcpSearchQuery(query); + const requestId = ++mcpSearchRequestSeqRef.current; if (searchTimerRef.current) { clearTimeout(searchTimerRef.current); @@ -98,17 +101,25 @@ export function useExtensionsTabState() { searchTimerRef.current = setTimeout(() => { if (!api.mcpRegistry) { - setMcpSearchLoading(false); + if (mcpSearchRequestSeqRef.current === requestId) { + setMcpSearchLoading(false); + } return; } void api.mcpRegistry.search(query).then( (result: McpSearchResult) => { + if (mcpSearchRequestSeqRef.current !== requestId) { + return; + } setMcpSearchResults(result.servers); setMcpSearchWarnings(result.warnings); setMcpSearchLoading(false); }, () => { + if (mcpSearchRequestSeqRef.current !== requestId) { + return; + } setMcpSearchLoading(false); setMcpSearchWarnings(['Search failed']); } diff --git a/test/renderer/hooks/useExtensionsTabState.test.ts b/test/renderer/hooks/useExtensionsTabState.test.ts index 52ae6fbb..340d2849 100644 --- a/test/renderer/hooks/useExtensionsTabState.test.ts +++ b/test/renderer/hooks/useExtensionsTabState.test.ts @@ -1,16 +1,21 @@ import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useExtensionsTabState } from '../../../src/renderer/hooks/useExtensionsTabState'; +import type { McpCatalogItem } from '@shared/types/extensions'; + type ExtensionsTabState = ReturnType; let capturedState: ExtensionsTabState | null = null; +const mcpSearchMock = vi.fn(); vi.mock('@renderer/api', () => ({ api: { - mcpRegistry: null, + mcpRegistry: { + search: (...args: unknown[]) => mcpSearchMock(...args), + }, }, })); @@ -19,10 +24,39 @@ function Harness(): null { return null; } +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function makeMcpServer(id: string): McpCatalogItem { + return { + id, + name: id, + description: `${id} description`, + source: 'official', + installSpec: null, + envVars: [], + tools: [], + requiresAuth: false, + }; +} + describe('useExtensionsTabState', () => { + beforeEach(() => { + mcpSearchMock.mockReset(); + mcpSearchMock.mockResolvedValue({ servers: [], warnings: [] }); + }); + afterEach(() => { capturedState = null; document.body.innerHTML = ''; + vi.useRealTimers(); }); it('clears selected plugin when leaving the plugins sub-tab', async () => { @@ -119,4 +153,97 @@ describe('useExtensionsTabState', () => { await Promise.resolve(); }); }); + + it('ignores stale MCP search responses that resolve out of order', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.useFakeTimers(); + const first = createDeferred<{ servers: McpCatalogItem[]; warnings: string[] }>(); + const second = createDeferred<{ servers: McpCatalogItem[]; warnings: string[] }>(); + + mcpSearchMock + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise); + + 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 act(async () => { + capturedState?.mcpSearch('first'); + await vi.advanceTimersByTimeAsync(300); + }); + + await act(async () => { + capturedState?.mcpSearch('second'); + await vi.advanceTimersByTimeAsync(300); + }); + + await act(async () => { + second.resolve({ servers: [makeMcpServer('second-result')], warnings: ['new warning'] }); + await Promise.resolve(); + }); + expect(capturedState?.mcpSearchResults.map((server) => server.id)).toEqual(['second-result']); + expect(capturedState?.mcpSearchWarnings).toEqual(['new warning']); + + await act(async () => { + first.resolve({ servers: [makeMcpServer('first-result')], warnings: ['old warning'] }); + await Promise.resolve(); + }); + expect(capturedState?.mcpSearchResults.map((server) => server.id)).toEqual(['second-result']); + expect(capturedState?.mcpSearchWarnings).toEqual(['new warning']); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('drops in-flight MCP search results after clearing the query', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.useFakeTimers(); + const pending = createDeferred<{ servers: McpCatalogItem[]; warnings: string[] }>(); + mcpSearchMock.mockReturnValueOnce(pending.promise); + + 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 act(async () => { + capturedState?.mcpSearch('context7'); + await vi.advanceTimersByTimeAsync(300); + }); + expect(capturedState?.mcpSearchLoading).toBe(true); + + await act(async () => { + capturedState?.mcpSearch(''); + await Promise.resolve(); + }); + expect(capturedState?.mcpSearchQuery).toBe(''); + expect(capturedState?.mcpSearchResults).toEqual([]); + expect(capturedState?.mcpSearchWarnings).toEqual([]); + expect(capturedState?.mcpSearchLoading).toBe(false); + + await act(async () => { + pending.resolve({ servers: [makeMcpServer('stale-result')], warnings: ['stale warning'] }); + await Promise.resolve(); + }); + expect(capturedState?.mcpSearchResults).toEqual([]); + expect(capturedState?.mcpSearchWarnings).toEqual([]); + expect(capturedState?.mcpSearchLoading).toBe(false); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); From acabe52ae79090ab987f4d2b359491abd93a8df4 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 22:28:08 +0300 Subject: [PATCH 065/121] fix(extensions): stop MCP browse auto-retries after errors --- .../extensions/mcp/McpServersPanel.tsx | 4 +- .../extensions/mcp/McpServersPanel.test.ts | 211 ++++++++++++++++++ 2 files changed, 213 insertions(+), 2 deletions(-) create mode 100644 test/renderer/components/extensions/mcp/McpServersPanel.test.ts diff --git a/src/renderer/components/extensions/mcp/McpServersPanel.tsx b/src/renderer/components/extensions/mcp/McpServersPanel.tsx index 60ae4157..1a831f0f 100644 --- a/src/renderer/components/extensions/mcp/McpServersPanel.tsx +++ b/src/renderer/components/extensions/mcp/McpServersPanel.tsx @@ -107,10 +107,10 @@ export const McpServersPanel = ({ // 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]); useEffect(() => { void runMcpDiagnostics(); diff --git a/test/renderer/components/extensions/mcp/McpServersPanel.test.ts b/test/renderer/components/extensions/mcp/McpServersPanel.test.ts new file mode 100644 index 00000000..7c5956f7 --- /dev/null +++ b/test/renderer/components/extensions/mcp/McpServersPanel.test.ts @@ -0,0 +1,211 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +interface StoreState { + mcpBrowseCatalog: Array<{ + id: string; + name: string; + description: string; + source: 'official' | 'glama'; + installSpec: null; + envVars: []; + tools: []; + requiresAuth: boolean; + }>; + mcpBrowseNextCursor?: string; + mcpBrowseLoading: boolean; + mcpBrowseError: string | null; + mcpBrowse: ReturnType; + mcpInstalledServers: Array<{ name: string; scope: 'local' | 'user' | 'project' }>; + fetchMcpGitHubStars: ReturnType; + mcpDiagnostics: Record; + mcpDiagnosticsLoading: boolean; + mcpDiagnosticsError: string | null; + mcpDiagnosticsLastCheckedAt: number | null; + runMcpDiagnostics: ReturnType; +} + +const storeState = {} as StoreState; + +vi.mock('@renderer/store', () => ({ + useStore: (selector: (state: StoreState) => unknown) => selector(storeState), +})); + +vi.mock('zustand/react/shallow', () => ({ + useShallow: (selector: unknown) => selector, +})); + +vi.mock('@renderer/components/ui/badge', () => ({ + Badge: ({ children }: React.PropsWithChildren) => React.createElement('span', null, children), +})); + +vi.mock('@renderer/components/ui/button', () => ({ + Button: ({ + children, + onClick, + type = 'button', + disabled, + }: React.PropsWithChildren<{ + onClick?: () => void; + type?: 'button' | 'submit' | 'reset'; + disabled?: boolean; + }>) => + React.createElement( + 'button', + { + type, + disabled, + onClick, + }, + children + ), +})); + +vi.mock('@renderer/components/ui/select', () => ({ + Select: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children), + SelectTrigger: ({ children }: React.PropsWithChildren) => + React.createElement('button', { type: 'button' }, children), + SelectValue: () => React.createElement('span', null, 'select-value'), + SelectContent: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children), + SelectItem: ({ children }: React.PropsWithChildren<{ value: string }>) => + React.createElement('button', { type: 'button' }, children), +})); + +vi.mock('@renderer/components/extensions/common/SearchInput', () => ({ + SearchInput: ({ + value, + onChange, + }: { + value: string; + onChange: (value: string) => void; + }) => + React.createElement('input', { + value, + onChange: (event: React.ChangeEvent) => onChange(event.target.value), + }), +})); + +vi.mock('@renderer/components/extensions/mcp/McpServerCard', () => ({ + McpServerCard: ({ server }: { server: { id: string; name: string } }) => + React.createElement('div', { 'data-testid': 'mcp-card', 'data-server-id': server.id }, server.name), +})); + +vi.mock('@renderer/components/extensions/mcp/McpServerDetailDialog', () => ({ + McpServerDetailDialog: ({ open }: { open: boolean }) => + open ? React.createElement('div', { 'data-testid': 'mcp-detail' }) : null, +})); + +vi.mock('@renderer/utils/formatters', () => ({ + formatRelativeTime: () => 'just now', +})); + +vi.mock('lucide-react', () => { + const Icon = (props: React.SVGProps) => React.createElement('svg', props); + return { + AlertTriangle: Icon, + RefreshCw: Icon, + Search: Icon, + Server: Icon, + }; +}); + +import { McpServersPanel } from '@renderer/components/extensions/mcp/McpServersPanel'; + +describe('McpServersPanel initial browse loading', () => { + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.mcpBrowseCatalog = []; + storeState.mcpBrowseNextCursor = undefined; + storeState.mcpBrowseLoading = false; + storeState.mcpBrowseError = null; + storeState.mcpBrowse = vi.fn(); + storeState.mcpInstalledServers = []; + storeState.fetchMcpGitHubStars = vi.fn(); + storeState.mcpDiagnostics = {}; + storeState.mcpDiagnosticsLoading = false; + storeState.mcpDiagnosticsError = null; + storeState.mcpDiagnosticsLastCheckedAt = null; + storeState.runMcpDiagnostics = vi.fn(); + }); + + afterEach(() => { + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + }); + + it('loads the catalog once on first mount when browse state is empty', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(McpServersPanel, { + mcpSearchQuery: '', + mcpSearch: vi.fn(), + mcpSearchResults: [], + mcpSearchLoading: false, + mcpSearchWarnings: [], + selectedMcpServerId: null, + setSelectedMcpServerId: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + expect(storeState.mcpBrowse).toHaveBeenCalledTimes(1); + expect(storeState.runMcpDiagnostics).toHaveBeenCalledTimes(1); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('does not auto-retry browse after an error with an empty catalog', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(McpServersPanel, { + mcpSearchQuery: '', + mcpSearch: vi.fn(), + mcpSearchResults: [], + mcpSearchLoading: false, + mcpSearchWarnings: [], + selectedMcpServerId: null, + setSelectedMcpServerId: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + expect(storeState.mcpBrowse).toHaveBeenCalledTimes(1); + + storeState.mcpBrowseError = 'Registry unavailable'; + await act(async () => { + root.render( + React.createElement(McpServersPanel, { + mcpSearchQuery: '', + mcpSearch: vi.fn(), + mcpSearchResults: [], + mcpSearchLoading: false, + mcpSearchWarnings: [], + selectedMcpServerId: null, + setSelectedMcpServerId: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + expect(storeState.mcpBrowse).toHaveBeenCalledTimes(1); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +}); From 0d6276ea0ba5d41abda1d81c4aefc88dfa7e7284 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 22:31:56 +0300 Subject: [PATCH 066/121] fix(extensions): honor installed MCP targets in manage actions --- .../extensions/mcp/McpServerCard.tsx | 26 +- .../extensions/mcp/McpServerDetailDialog.tsx | 20 +- .../extensions/mcp/McpServersPanel.tsx | 2 + .../extensions/mcp/McpServerCard.test.ts | 193 +++++++++++++++ .../mcp/McpServerDetailDialog.test.ts | 222 ++++++++++++++++++ 5 files changed, 453 insertions(+), 10 deletions(-) create mode 100644 test/renderer/components/extensions/mcp/McpServerCard.test.ts create mode 100644 test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts diff --git a/src/renderer/components/extensions/mcp/McpServerCard.tsx b/src/renderer/components/extensions/mcp/McpServerCard.tsx index e2f04f87..d6586ff3 100644 --- a/src/renderer/components/extensions/mcp/McpServerCard.tsx +++ b/src/renderer/components/extensions/mcp/McpServerCard.tsx @@ -18,11 +18,16 @@ 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; diagnostic?: McpServerDiagnostic | null; diagnosticsLoading?: boolean; onClick: (serverId: string) => void; @@ -31,6 +36,7 @@ interface McpServerCardProps { export const McpServerCard = ({ server, isInstalled, + installedEntry, diagnostic, diagnosticsLoading, onClick, @@ -48,6 +54,14 @@ export const McpServerCard = ({ server.envVars.length > 0 || server.requiresAuth || (server.authHeaders?.length ?? 0) > 0; + const defaultServerName = sanitizeMcpServerName(server.name); + const supportsDirectInstalledAction = + isInstalled && + installedEntry?.scope === 'user' && + installedEntry.name === defaultServerName && + !requiresConfiguration; + const shouldShowDirectInstallButton = + canAutoInstall && (!isInstalled ? !requiresConfiguration : supportsDirectInstalledAction); const [imgError, setImgError] = useState(false); const hasIcon = !!server.iconUrl && !imgError; const diagnosticBadgeClass = @@ -224,7 +238,7 @@ export const McpServerCard = ({ )}
- {canAutoInstall && !requiresConfiguration && ( + {shouldShowDirectInstallButton && (
installMcpServer({ registryId: server.id, - serverName: sanitizeMcpServerName(server.name), + serverName: defaultServerName, scope: 'user', envValues: {}, headers: [], }) } - onUninstall={() => uninstallMcpServer(server.id, sanitizeMcpServerName(server.name))} + onUninstall={() => + uninstallMcpServer(server.id, installedEntry?.name ?? defaultServerName, 'user') + } size="sm" errorMessage={installError} />
)} - {canAutoInstall && requiresConfiguration && ( + {canAutoInstall && (!shouldShowDirectInstallButton || requiresConfiguration) && (
diff --git a/src/renderer/components/extensions/mcp/McpServersPanel.tsx b/src/renderer/components/extensions/mcp/McpServersPanel.tsx index 1a831f0f..c21b181c 100644 --- a/src/renderer/components/extensions/mcp/McpServersPanel.tsx +++ b/src/renderer/components/extensions/mcp/McpServersPanel.tsx @@ -374,6 +374,7 @@ export const McpServersPanel = ({ key={server.id} server={server} isInstalled={isServerInstalled(server)} + installedEntry={getInstalledEntry(server)} diagnostic={getDiagnostic(server)} diagnosticsLoading={mcpDiagnosticsLoading} onClick={setSelectedMcpServerId} @@ -400,6 +401,7 @@ export const McpServersPanel = ({ ; + installMcpServer: ReturnType; + uninstallMcpServer: ReturnType; + installErrors: Record; + mcpGitHubStars: Record; +} + +const storeState = {} as StoreState; + +vi.mock('@renderer/store', () => ({ + useStore: (selector: (state: StoreState) => unknown) => selector(storeState), +})); + +vi.mock('@renderer/api', () => ({ + api: { + openExternal: vi.fn(), + }, +})); + +vi.mock('@renderer/components/ui/badge', () => ({ + Badge: ({ children }: React.PropsWithChildren) => React.createElement('span', null, children), +})); + +vi.mock('@renderer/components/ui/button', () => ({ + Button: ({ + children, + onClick, + type = 'button', + }: React.PropsWithChildren<{ + onClick?: (event: React.MouseEvent) => void; + type?: 'button' | 'submit' | 'reset'; + }>) => + React.createElement( + 'button', + { + type, + onClick, + }, + children + ), +})); + +vi.mock('@renderer/components/ui/tooltip', () => ({ + Tooltip: ({ children }: React.PropsWithChildren) => React.createElement(React.Fragment, null, children), + TooltipTrigger: ({ children }: React.PropsWithChildren) => + React.createElement(React.Fragment, null, children), + TooltipContent: ({ children }: React.PropsWithChildren) => React.createElement('span', null, children), +})); + +vi.mock('@renderer/components/extensions/common/InstallButton', () => ({ + InstallButton: () => React.createElement('button', { type: 'button', 'data-testid': 'install-button' }, 'Install'), +})); + +vi.mock('@renderer/components/extensions/common/SourceBadge', () => ({ + SourceBadge: ({ source }: { source: string }) => React.createElement('span', null, source), +})); + +vi.mock('@renderer/utils/formatters', () => ({ + formatCompactNumber: (value: number) => String(value), + formatRelativeTime: () => 'recently', +})); + +vi.mock('lucide-react', () => { + const Icon = (props: React.SVGProps) => React.createElement('svg', props); + return { + Clock: Icon, + Cloud: Icon, + Globe: Icon, + KeyRound: Icon, + Lock: Icon, + Monitor: Icon, + Star: Icon, + Tag: Icon, + Wrench: Icon, + Github: Icon, + }; +}); + +import { McpServerCard } from '@renderer/components/extensions/mcp/McpServerCard'; + +function makeServer(): McpCatalogItem { + return { + id: 'io.github.upstash/context7', + name: 'Context7', + description: 'Docs server', + source: 'official', + installSpec: { + type: 'stdio', + npmPackage: '@upstash/context7-mcp', + }, + envVars: [], + tools: [], + requiresAuth: false, + authHeaders: [], + }; +} + +describe('McpServerCard direct action safety', () => { + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.mcpInstallProgress = {}; + storeState.installMcpServer = vi.fn(); + storeState.uninstallMcpServer = vi.fn(); + storeState.installErrors = {}; + storeState.mcpGitHubStars = {}; + }); + + afterEach(() => { + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + }); + + it('falls back to Manage for installed entries that cannot be safely uninstalled directly', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const onClick = vi.fn(); + const installedEntry: InstalledMcpEntry = { + name: 'context7-local', + scope: 'local', + }; + + await act(async () => { + root.render( + React.createElement(McpServerCard, { + server: makeServer(), + isInstalled: true, + installedEntry, + diagnostic: null, + diagnosticsLoading: false, + onClick, + }) + ); + await Promise.resolve(); + }); + + expect(host.querySelector('[data-testid="install-button"]')).toBeNull(); + const manageButton = Array.from(host.querySelectorAll('button')).find( + (button) => button.textContent === 'Manage' + ) as HTMLButtonElement | undefined; + expect(manageButton).toBeDefined(); + + await act(async () => { + manageButton?.click(); + await Promise.resolve(); + }); + + expect(onClick).toHaveBeenCalledWith('io.github.upstash/context7'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('keeps direct actions for standard user-scope installs', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const installedEntry: InstalledMcpEntry = { + name: 'context7', + scope: 'user', + }; + + await act(async () => { + root.render( + React.createElement(McpServerCard, { + server: makeServer(), + isInstalled: true, + installedEntry, + diagnostic: null, + diagnosticsLoading: false, + onClick: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + expect(host.querySelector('[data-testid="install-button"]')).not.toBeNull(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +}); diff --git a/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts b/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts new file mode 100644 index 00000000..5329963a --- /dev/null +++ b/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts @@ -0,0 +1,222 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { InstalledMcpEntry, McpCatalogItem } from '@shared/types/extensions'; + +interface StoreState { + mcpInstallProgress: Record; + installMcpServer: ReturnType; + uninstallMcpServer: ReturnType; + installErrors: Record; + mcpGitHubStars: Record; +} + +const storeState = {} as StoreState; +const lookupMock = vi.fn(); + +vi.mock('@renderer/store', () => ({ + useStore: (selector: (state: StoreState) => unknown) => selector(storeState), +})); + +vi.mock('@renderer/api', () => ({ + api: { + openExternal: vi.fn(), + apiKeys: { + lookup: (...args: unknown[]) => lookupMock(...args), + }, + }, +})); + +vi.mock('@renderer/components/ui/badge', () => ({ + Badge: ({ children }: React.PropsWithChildren) => React.createElement('span', null, children), +})); + +vi.mock('@renderer/components/ui/button', () => ({ + Button: ({ + children, + onClick, + type = 'button', + disabled, + }: React.PropsWithChildren<{ + onClick?: () => void; + type?: 'button' | 'submit' | 'reset'; + disabled?: boolean; + }>) => + React.createElement( + 'button', + { + type, + disabled, + onClick, + }, + children + ), +})); + +vi.mock('@renderer/components/ui/dialog', () => ({ + Dialog: ({ open, children }: React.PropsWithChildren<{ open: boolean }>) => + open ? React.createElement('div', null, children) : null, + DialogContent: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children), + DialogHeader: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children), + DialogTitle: ({ children }: React.PropsWithChildren) => React.createElement('h2', null, children), + DialogDescription: ({ children }: React.PropsWithChildren) => + React.createElement('p', null, children), +})); + +vi.mock('@renderer/components/ui/input', () => ({ + Input: (props: React.InputHTMLAttributes) => + React.createElement('input', props), +})); + +vi.mock('@renderer/components/ui/label', () => ({ + Label: ({ children }: React.PropsWithChildren) => React.createElement('label', null, children), +})); + +vi.mock('@renderer/components/ui/select', () => ({ + Select: ({ + children, + value, + onValueChange, + }: React.PropsWithChildren<{ value: string; onValueChange: (value: string) => void }>) => + React.createElement( + 'select', + { + 'data-testid': 'scope-select', + value, + onChange: (event: React.ChangeEvent) => onValueChange(event.target.value), + }, + children + ), + SelectTrigger: ({ children }: React.PropsWithChildren) => + React.createElement(React.Fragment, null, children), + SelectValue: () => null, + SelectContent: ({ children }: React.PropsWithChildren) => + React.createElement(React.Fragment, null, children), + SelectItem: ({ children, value }: React.PropsWithChildren<{ value: string }>) => + React.createElement('option', { value }, children), +})); + +vi.mock('@renderer/components/extensions/common/InstallButton', () => ({ + InstallButton: ({ + isInstalled, + onInstall, + onUninstall, + }: { + isInstalled: boolean; + onInstall: () => void; + onUninstall: () => void; + }) => + React.createElement( + 'button', + { + type: 'button', + 'data-testid': 'install-button', + onClick: () => (isInstalled ? onUninstall() : onInstall()), + }, + isInstalled ? 'Uninstall' : 'Install' + ), +})); + +vi.mock('@renderer/components/extensions/common/SourceBadge', () => ({ + SourceBadge: ({ source }: { source: string }) => React.createElement('span', null, source), +})); + +vi.mock('lucide-react', () => { + const Icon = (props: React.SVGProps) => React.createElement('svg', props); + return { + ExternalLink: Icon, + Lock: Icon, + Plus: Icon, + Star: Icon, + Trash2: Icon, + Wrench: Icon, + }; +}); + +import { McpServerDetailDialog } from '@renderer/components/extensions/mcp/McpServerDetailDialog'; + +function makeServer(): McpCatalogItem { + return { + id: 'io.github.upstash/context7', + name: 'Context7', + description: 'Docs server', + source: 'official', + installSpec: { + type: 'stdio', + npmPackage: '@upstash/context7-mcp', + }, + envVars: [], + tools: [], + requiresAuth: false, + authHeaders: [], + }; +} + +describe('McpServerDetailDialog installed entry handling', () => { + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.mcpInstallProgress = {}; + storeState.installMcpServer = vi.fn(); + storeState.uninstallMcpServer = vi.fn(); + storeState.installErrors = {}; + storeState.mcpGitHubStars = {}; + lookupMock.mockReset(); + lookupMock.mockResolvedValue([]); + }); + + afterEach(() => { + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + }); + + it('uninstalls using the real installed server name and scope', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const installedEntry: InstalledMcpEntry = { + name: 'context7-local', + scope: 'local', + }; + + await act(async () => { + root.render( + React.createElement(McpServerDetailDialog, { + server: makeServer(), + isInstalled: true, + installedEntry, + diagnostic: null, + diagnosticsLoading: false, + open: true, + onClose: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + const serverNameInput = host.querySelector('#server-name') as HTMLInputElement; + expect(serverNameInput).not.toBeNull(); + expect(serverNameInput.value).toBe('context7-local'); + expect(serverNameInput.disabled).toBe(true); + + const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; + expect(scopeSelect.value).toBe('local'); + + const uninstallButton = host.querySelector('[data-testid="install-button"]') as HTMLButtonElement; + await act(async () => { + uninstallButton.click(); + await Promise.resolve(); + }); + + expect(storeState.uninstallMcpServer).toHaveBeenCalledWith( + 'io.github.upstash/context7', + 'context7-local', + 'local' + ); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +}); From 3f03bc720a4998f367d9eb540e8ca7e1abeea405 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 22:32:47 +0300 Subject: [PATCH 067/121] fix(extensions): dedupe MCP detail API key lookups --- .../extensions/mcp/McpServerDetailDialog.tsx | 32 ---------------- .../mcp/McpServerDetailDialog.test.ts | 38 +++++++++++++++++++ 2 files changed, 38 insertions(+), 32 deletions(-) diff --git a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx index 7ddeb82c..256b4cc2 100644 --- a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx +++ b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx @@ -128,38 +128,6 @@ export const McpServerDetailDialog = ({ ); }, [server?.id, open]); // eslint-disable-line react-hooks/exhaustive-deps - // Auto-fill env vars from saved API keys - useEffect(() => { - if (!server || server.envVars.length === 0 || !api.apiKeys) return; - - const envVarNames = server.envVars.map((env) => env.name); - void api.apiKeys.lookup(envVarNames).then( - (results) => { - if (results.length === 0) return; - const filled = new Set(); - const updates: Record = {}; - for (const r of results) { - updates[r.envVarName] = r.value; - filled.add(r.envVarName); - } - setEnvValues((prev) => { - const next = { ...prev }; - for (const [k, v] of Object.entries(updates)) { - // Only auto-fill if the field is empty - if (!next[k]) { - next[k] = v; - } - } - return next; - }); - setAutoFilledFields(filled); - }, - () => { - // Silently ignore lookup failures - } - ); - }, [server?.id]); // eslint-disable-line react-hooks/exhaustive-deps - if (!server) return <>; const canAutoInstall = !!server.installSpec; diff --git a/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts b/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts index 5329963a..8835a9dd 100644 --- a/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts +++ b/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts @@ -219,4 +219,42 @@ describe('McpServerDetailDialog installed entry handling', () => { await Promise.resolve(); }); }); + + it('looks up saved API keys only once per dialog open', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const server = makeServer(); + server.envVars = [{ name: 'CONTEXT7_API_KEY', isSecret: true }]; + lookupMock.mockResolvedValue([ + { + envVarName: 'CONTEXT7_API_KEY', + value: 'secret', + }, + ]); + + await act(async () => { + root.render( + React.createElement(McpServerDetailDialog, { + server, + isInstalled: false, + installedEntry: null, + diagnostic: null, + diagnosticsLoading: false, + open: true, + onClose: vi.fn(), + }) + ); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(lookupMock).toHaveBeenCalledTimes(1); + expect(lookupMock).toHaveBeenCalledWith(['CONTEXT7_API_KEY']); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); From 0420428281f4771ecb332551655c74bf27bca51c Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 22:39:03 +0300 Subject: [PATCH 068/121] fix(extensions): support project-scoped mcp installs --- .../extensions/install/McpInstallService.ts | 18 ++ .../extensions/ExtensionStoreView.tsx | 2 + .../extensions/mcp/CustomMcpServerDialog.tsx | 19 +- .../extensions/mcp/McpServerDetailDialog.tsx | 35 ++- .../extensions/mcp/McpServersPanel.tsx | 3 + .../extensions/McpInstallService.test.ts | 41 ++++ .../mcp/CustomMcpServerDialog.test.ts | 206 ++++++++++++++++++ .../mcp/McpServerDetailDialog.test.ts | 98 ++++++++- .../extensions/mcp/McpServersPanel.test.ts | 3 + 9 files changed, 415 insertions(+), 10 deletions(-) create mode 100644 test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts diff --git a/src/main/services/extensions/install/McpInstallService.ts b/src/main/services/extensions/install/McpInstallService.ts index 3c83c0fe..779e6178 100644 --- a/src/main/services/extensions/install/McpInstallService.ts +++ b/src/main/services/extensions/install/McpInstallService.ts @@ -59,6 +59,13 @@ export class McpInstallService { }; } + if (scope === 'project' && !projectPath) { + return { + state: 'error', + error: 'projectPath is required for project scope', + }; + } + // 3. Validate env var keys (prevent command injection) for (const key of Object.keys(envValues)) { if (!ENV_KEY_RE.test(key)) { @@ -212,6 +219,10 @@ export class McpInstallService { return { state: 'error', error: `Invalid scope: "${scope}".` }; } + if (scope === 'project' && !projectPath) { + return { state: 'error', error: 'projectPath is required for project scope' }; + } + for (const key of Object.keys(envValues)) { if (!ENV_KEY_RE.test(key)) { return { state: 'error', error: `Invalid env var name: "${key}".` }; @@ -319,6 +330,13 @@ export class McpInstallService { }; } + if (scope === 'project' && !projectPath) { + return { + state: 'error', + error: 'projectPath is required for project scope', + }; + } + if (projectPath && !path.isAbsolute(projectPath)) { return { state: 'error', diff --git a/src/renderer/components/extensions/ExtensionStoreView.tsx b/src/renderer/components/extensions/ExtensionStoreView.tsx index 84648b44..7f1331c3 100644 --- a/src/renderer/components/extensions/ExtensionStoreView.tsx +++ b/src/renderer/components/extensions/ExtensionStoreView.tsx @@ -340,6 +340,7 @@ export const ExtensionStoreView = (): React.JSX.Element => { { setCustomMcpDialogOpen(false)} + projectPath={projectPath} />
diff --git a/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx b/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx index 35624aad..b096287e 100644 --- a/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx +++ b/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx @@ -37,14 +37,16 @@ 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'; +type Scope = 'local' | 'user' | 'project'; const SCOPE_OPTIONS: { value: Scope; label: string }[] = [ { value: 'user', label: 'User (global)' }, + { value: 'project', label: 'Project' }, { value: 'local', label: 'Local' }, ]; @@ -62,6 +64,7 @@ interface EnvEntry { export const CustomMcpServerDialog = ({ open, onClose, + projectPath, }: CustomMcpServerDialogProps): React.JSX.Element => { const installCustomMcpServer = useStore((s) => s.installCustomMcpServer); @@ -101,6 +104,12 @@ export const CustomMcpServerDialog = ({ } }, [open]); + useEffect(() => { + if (open && scope === 'project' && !projectPath) { + setScope('user'); + } + }, [open, projectPath, scope]); + // Auto-fill env vars from saved API keys useEffect(() => { if (!open || envVars.length === 0 || !api.apiKeys) return; @@ -168,6 +177,7 @@ export const CustomMcpServerDialog = ({ const request: McpCustomInstallRequest = { serverName, scope, + projectPath: scope === 'project' ? (projectPath ?? undefined) : undefined, installSpec, envValues, headers: headers.filter((h) => h.key.trim() && h.value.trim()), @@ -197,6 +207,7 @@ export const CustomMcpServerDialog = ({ const canSubmit = serverName.trim() && (transportMode === 'stdio' ? npmPackage.trim() : httpUrl.trim()) && + !(scope === 'project' && !projectPath) && !installing; return ( @@ -372,7 +383,11 @@ export const CustomMcpServerDialog = ({ {SCOPE_OPTIONS.map((opt) => ( - + {opt.label} ))} diff --git a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx index 256b4cc2..391e8239 100644 --- a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx +++ b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx @@ -44,14 +44,16 @@ interface McpServerDetailDialogProps { installedEntry?: InstalledMcpEntry | null; diagnostic?: McpServerDiagnostic | null; diagnosticsLoading?: boolean; + projectPath: string | null; open: boolean; onClose: () => void; } -type Scope = 'local' | 'user'; +type Scope = 'local' | 'user' | 'project'; const SCOPE_OPTIONS: { value: Scope; label: string }[] = [ { value: 'user', label: 'User (global)' }, + { value: 'project', label: 'Project' }, { value: 'local', label: 'Local' }, ]; @@ -61,6 +63,7 @@ export const McpServerDetailDialog = ({ installedEntry, diagnostic, diagnosticsLoading, + projectPath, open, onClose, }: McpServerDetailDialogProps): React.JSX.Element => { @@ -100,11 +103,17 @@ export const McpServerDetailDialog = ({ })) ); setServerName(installedEntry?.name ?? sanitizeMcpServerName(server.name)); - setScope(installedEntry?.scope === 'local' ? 'local' : 'user'); + setScope(installedEntry?.scope ?? 'user'); setImgError(false); setAutoFilledFields(new Set()); }, [installedEntry?.name, installedEntry?.scope, open, server?.id]); + useEffect(() => { + if (open && scope === 'project' && !projectPath) { + setScope('user'); + } + }, [open, projectPath, scope]); + // Auto-fill env values from saved API keys useEffect(() => { if (!server || !open || server.envVars.length === 0 || !api.apiKeys) return; @@ -144,9 +153,15 @@ export const McpServerDetailDialog = ({ const missingRequiredHeaders = headers.some( (header) => header.isRequired && !header.value.trim() ); - const installDisabled = !serverName.trim() || missingRequiredEnvVars || missingRequiredHeaders; const uninstallServerName = installedEntry?.name ?? serverName; const uninstallScope = installedEntry?.scope ?? scope; + const scopeRequiresProjectPath = + (scope === 'project' || uninstallScope === 'project') && !projectPath; + const installDisabled = + !serverName.trim() || + missingRequiredEnvVars || + missingRequiredHeaders || + scopeRequiresProjectPath; const diagnosticBadgeClass = diagnostic?.status === 'connected' ? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400' @@ -161,13 +176,19 @@ export const McpServerDetailDialog = ({ registryId: server.id, serverName, scope, + projectPath: scope === 'project' ? (projectPath ?? undefined) : undefined, envValues, headers, }); }; const handleUninstall = () => { - uninstallMcpServer(server.id, uninstallServerName, uninstallScope); + uninstallMcpServer( + server.id, + uninstallServerName, + uninstallScope, + uninstallScope === 'project' ? (projectPath ?? undefined) : undefined + ); }; const addHeader = () => { @@ -370,7 +391,11 @@ export const McpServerDetailDialog = ({ {SCOPE_OPTIONS.map((opt) => ( - + {opt.label} ))} diff --git a/src/renderer/components/extensions/mcp/McpServersPanel.tsx b/src/renderer/components/extensions/mcp/McpServersPanel.tsx index c21b181c..fcc3d6f1 100644 --- a/src/renderer/components/extensions/mcp/McpServersPanel.tsx +++ b/src/renderer/components/extensions/mcp/McpServersPanel.tsx @@ -55,6 +55,7 @@ function sortMcpServers(servers: McpCatalogItem[], sort: McpSortValue): McpCatal } interface McpServersPanelProps { + projectPath: string | null; mcpSearchQuery: string; mcpSearch: (query: string) => void; mcpSearchResults: McpCatalogItem[]; @@ -65,6 +66,7 @@ interface McpServersPanelProps { } export const McpServersPanel = ({ + projectPath, mcpSearchQuery, mcpSearch, mcpSearchResults, @@ -404,6 +406,7 @@ export const McpServersPanel = ({ installedEntry={selectedServer ? getInstalledEntry(selectedServer) : null} diagnostic={selectedServer ? getDiagnostic(selectedServer) : null} diagnosticsLoading={mcpDiagnosticsLoading} + projectPath={projectPath} open={selectedMcpServerId !== null} onClose={() => setSelectedMcpServerId(null)} /> diff --git a/test/main/services/extensions/McpInstallService.test.ts b/test/main/services/extensions/McpInstallService.test.ts index 40f1dc5a..1320d359 100644 --- a/test/main/services/extensions/McpInstallService.test.ts +++ b/test/main/services/extensions/McpInstallService.test.ts @@ -216,6 +216,20 @@ describe('McpInstallService', () => { expect(result.state).toBe('error'); expect(result.error).toContain('Manual setup required'); }); + + it('rejects project scope install without project path', async () => { + const result = await service.install({ + registryId: 'upstash/context7-mcp', + serverName: 'context7', + scope: 'project', + envValues: {}, + headers: [], + }); + + expect(result.state).toBe('error'); + expect(result.error).toContain('projectPath is required'); + expect(mockExecCli).not.toHaveBeenCalled(); + }); }); // ── install: error masking ────────────────────────────────────────────────── @@ -259,6 +273,25 @@ describe('McpInstallService', () => { }); }); + describe('installCustom (validation)', () => { + it('rejects project scope custom install without project path', async () => { + const result = await service.installCustom({ + serverName: 'custom-context7', + scope: 'project', + installSpec: { + type: 'stdio', + npmPackage: '@upstash/context7-mcp', + }, + envValues: {}, + headers: [], + }); + + expect(result.state).toBe('error'); + expect(result.error).toContain('projectPath is required'); + expect(mockExecCli).not.toHaveBeenCalled(); + }); + }); + // ── uninstall ─────────────────────────────────────────────────────────────── describe('uninstall', () => { @@ -293,6 +326,14 @@ describe('McpInstallService', () => { expect(mockExecCli).not.toHaveBeenCalled(); }); + it('rejects project scope uninstall without project path', async () => { + const result = await service.uninstall('context7', 'project'); + + expect(result.state).toBe('error'); + expect(result.error).toContain('projectPath is required'); + expect(mockExecCli).not.toHaveBeenCalled(); + }); + it('returns error on CLI failure', async () => { mockExecCli.mockRejectedValue(new Error('Not found')); diff --git a/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts b/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts new file mode 100644 index 00000000..9dd675ef --- /dev/null +++ b/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts @@ -0,0 +1,206 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +interface StoreState { + installCustomMcpServer: ReturnType; +} + +const storeState = {} as StoreState; +const lookupMock = vi.fn(); + +vi.mock('@renderer/store', () => ({ + useStore: (selector: (state: StoreState) => unknown) => selector(storeState), +})); + +vi.mock('@renderer/api', () => ({ + api: { + apiKeys: { + lookup: (...args: unknown[]) => lookupMock(...args), + }, + }, +})); + +vi.mock('@renderer/components/ui/button', () => ({ + Button: ({ + children, + onClick, + type = 'button', + disabled, + }: React.PropsWithChildren<{ + onClick?: () => void; + type?: 'button' | 'submit' | 'reset'; + disabled?: boolean; + }>) => + React.createElement( + 'button', + { + type, + disabled, + onClick, + }, + children + ), +})); + +vi.mock('@renderer/components/ui/dialog', () => ({ + Dialog: ({ open, children }: React.PropsWithChildren<{ open: boolean }>) => + open ? React.createElement('div', null, children) : null, + DialogContent: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children), + DialogHeader: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children), + DialogTitle: ({ children }: React.PropsWithChildren) => React.createElement('h2', null, children), + DialogDescription: ({ children }: React.PropsWithChildren) => + React.createElement('p', null, children), +})); + +vi.mock('@renderer/components/ui/input', () => ({ + Input: (props: React.InputHTMLAttributes) => + React.createElement('input', props), +})); + +vi.mock('@renderer/components/ui/label', () => ({ + Label: ({ children }: React.PropsWithChildren) => React.createElement('label', null, children), +})); + +vi.mock('@renderer/components/ui/select', () => ({ + Select: ({ + children, + value, + onValueChange, + }: React.PropsWithChildren<{ value: string; onValueChange: (value: string) => void }>) => + React.createElement( + 'select', + { + 'data-testid': 'scope-select', + value, + onChange: (event: React.ChangeEvent) => onValueChange(event.target.value), + }, + children + ), + SelectTrigger: ({ children }: React.PropsWithChildren) => + React.createElement(React.Fragment, null, children), + SelectValue: () => null, + SelectContent: ({ children }: React.PropsWithChildren) => + React.createElement(React.Fragment, null, children), + SelectItem: ({ + children, + value, + disabled, + }: React.PropsWithChildren<{ value: string; disabled?: boolean }>) => + React.createElement('option', { value, disabled }, children), +})); + +vi.mock('lucide-react', () => { + const Icon = (props: React.SVGProps) => React.createElement('svg', props); + return { + Plus: Icon, + Server: Icon, + Trash2: Icon, + }; +}); + +import { CustomMcpServerDialog } from '@renderer/components/extensions/mcp/CustomMcpServerDialog'; + +function setNativeValue( + element: HTMLInputElement | HTMLSelectElement, + value: string, + eventName: 'input' | 'change' +): void { + const prototype = element instanceof HTMLSelectElement ? HTMLSelectElement.prototype : HTMLInputElement.prototype; + const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value'); + descriptor?.set?.call(element, value); + element.dispatchEvent(new Event(eventName, { bubbles: true })); +} + +describe('CustomMcpServerDialog project scope', () => { + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.installCustomMcpServer = vi.fn().mockResolvedValue(undefined); + lookupMock.mockReset(); + lookupMock.mockResolvedValue([]); + }); + + afterEach(() => { + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + }); + + it('disables project scope without an active project', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(CustomMcpServerDialog, { + open: true, + onClose: vi.fn(), + projectPath: null, + }) + ); + await Promise.resolve(); + }); + + const projectOption = host.querySelector('option[value="project"]') as HTMLOptionElement; + expect(projectOption.disabled).toBe(true); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('passes projectPath for project-scoped custom installs', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const onClose = vi.fn(); + const projectPath = '/tmp/custom-mcp-project'; + + await act(async () => { + root.render( + React.createElement(CustomMcpServerDialog, { + open: true, + onClose, + projectPath, + }) + ); + await Promise.resolve(); + }); + + const nameInput = host.querySelector('#custom-name') as HTMLInputElement; + const packageInput = host.querySelector('#custom-npm') as HTMLInputElement; + const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; + + await act(async () => { + setNativeValue(nameInput, 'custom-context7', 'input'); + setNativeValue(packageInput, '@upstash/context7-mcp', 'input'); + setNativeValue(scopeSelect, 'project', 'change'); + await Promise.resolve(); + }); + + const installButton = Array.from(host.querySelectorAll('button')).find( + (button) => button.textContent === 'Install' + ) as HTMLButtonElement; + expect(installButton.disabled).toBe(false); + + await act(async () => { + installButton.click(); + await Promise.resolve(); + }); + + expect(storeState.installCustomMcpServer).toHaveBeenCalledWith( + expect.objectContaining({ + serverName: 'custom-context7', + scope: 'project', + projectPath, + }) + ); + expect(onClose).toHaveBeenCalledTimes(1); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +}); diff --git a/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts b/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts index 8835a9dd..42d178b4 100644 --- a/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts +++ b/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts @@ -93,8 +93,12 @@ vi.mock('@renderer/components/ui/select', () => ({ SelectValue: () => null, SelectContent: ({ children }: React.PropsWithChildren) => React.createElement(React.Fragment, null, children), - SelectItem: ({ children, value }: React.PropsWithChildren<{ value: string }>) => - React.createElement('option', { value }, children), + SelectItem: ({ + children, + value, + disabled, + }: React.PropsWithChildren<{ value: string; disabled?: boolean }>) => + React.createElement('option', { value, disabled }, children), })); vi.mock('@renderer/components/extensions/common/InstallButton', () => ({ @@ -187,6 +191,7 @@ describe('McpServerDetailDialog installed entry handling', () => { installedEntry, diagnostic: null, diagnosticsLoading: false, + projectPath: '/tmp/project', open: true, onClose: vi.fn(), }) @@ -211,7 +216,8 @@ describe('McpServerDetailDialog installed entry handling', () => { expect(storeState.uninstallMcpServer).toHaveBeenCalledWith( 'io.github.upstash/context7', 'context7-local', - 'local' + 'local', + undefined ); await act(async () => { @@ -241,6 +247,7 @@ describe('McpServerDetailDialog installed entry handling', () => { installedEntry: null, diagnostic: null, diagnosticsLoading: false, + projectPath: null, open: true, onClose: vi.fn(), }) @@ -257,4 +264,89 @@ describe('McpServerDetailDialog installed entry handling', () => { await Promise.resolve(); }); }); + + it('passes project path for project-scoped installs and uninstalls', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const projectPath = '/tmp/project-context7'; + const installedEntry: InstalledMcpEntry = { + name: 'context7-project', + scope: 'project', + }; + + await act(async () => { + root.render( + React.createElement(McpServerDetailDialog, { + server: makeServer(), + isInstalled: true, + installedEntry, + diagnostic: null, + diagnosticsLoading: false, + projectPath, + open: true, + onClose: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; + expect(scopeSelect.value).toBe('project'); + + const uninstallButton = host.querySelector('[data-testid="install-button"]') as HTMLButtonElement; + await act(async () => { + uninstallButton.click(); + await Promise.resolve(); + }); + + expect(storeState.uninstallMcpServer).toHaveBeenCalledWith( + 'io.github.upstash/context7', + 'context7-project', + 'project', + projectPath + ); + + await act(async () => { + root.render( + React.createElement(McpServerDetailDialog, { + server: makeServer(), + isInstalled: false, + installedEntry: null, + diagnostic: null, + diagnosticsLoading: false, + projectPath, + open: true, + onClose: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + const installScopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; + await act(async () => { + installScopeSelect.value = 'project'; + installScopeSelect.dispatchEvent(new Event('change', { bubbles: true })); + await Promise.resolve(); + }); + + const installButton = host.querySelector('[data-testid="install-button"]') as HTMLButtonElement; + await act(async () => { + installButton.click(); + await Promise.resolve(); + }); + + expect(storeState.installMcpServer).toHaveBeenCalledWith( + expect.objectContaining({ + registryId: 'io.github.upstash/context7', + scope: 'project', + projectPath, + }) + ); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/components/extensions/mcp/McpServersPanel.test.ts b/test/renderer/components/extensions/mcp/McpServersPanel.test.ts index 7c5956f7..0d9451df 100644 --- a/test/renderer/components/extensions/mcp/McpServersPanel.test.ts +++ b/test/renderer/components/extensions/mcp/McpServersPanel.test.ts @@ -142,6 +142,7 @@ describe('McpServersPanel initial browse loading', () => { await act(async () => { root.render( React.createElement(McpServersPanel, { + projectPath: null, mcpSearchQuery: '', mcpSearch: vi.fn(), mcpSearchResults: [], @@ -171,6 +172,7 @@ describe('McpServersPanel initial browse loading', () => { await act(async () => { root.render( React.createElement(McpServersPanel, { + projectPath: null, mcpSearchQuery: '', mcpSearch: vi.fn(), mcpSearchResults: [], @@ -189,6 +191,7 @@ describe('McpServersPanel initial browse loading', () => { await act(async () => { root.render( React.createElement(McpServersPanel, { + projectPath: null, mcpSearchQuery: '', mcpSearch: vi.fn(), mcpSearchResults: [], From be8f4f45d219ef59c83eb607033f7581b560cbd4 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 22:41:10 +0300 Subject: [PATCH 069/121] fix(extensions): scope mcp installed cache by project --- .../state/McpInstallationStateService.ts | 13 +-- .../McpInstallationStateService.test.ts | 98 +++++++++++++++++++ 2 files changed, 105 insertions(+), 6 deletions(-) create mode 100644 test/main/services/extensions/McpInstallationStateService.test.ts diff --git a/src/main/services/extensions/state/McpInstallationStateService.ts b/src/main/services/extensions/state/McpInstallationStateService.ts index f947f190..6e5950ec 100644 --- a/src/main/services/extensions/state/McpInstallationStateService.ts +++ b/src/main/services/extensions/state/McpInstallationStateService.ts @@ -27,15 +27,16 @@ interface TimedCache { } export class McpInstallationStateService { - private cache: TimedCache | null = null; + private cache = new Map>(); /** * 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 = projectPath ?? '__user__'; + const cached = this.cache.get(cacheKey); + if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) { + return cached.data; } const entries: InstalledMcpEntry[] = []; @@ -50,7 +51,7 @@ export class McpInstallationStateService { entries.push(...projectEntries); } - this.cache = { data: entries, fetchedAt: Date.now() }; + this.cache.set(cacheKey, { data: entries, fetchedAt: Date.now() }); return entries; } @@ -58,7 +59,7 @@ export class McpInstallationStateService { * Invalidate cache. Call after install/uninstall operations. */ invalidateCache(): void { - this.cache = null; + this.cache.clear(); } // ── Private ──────────────────────────────────────────────────────────── diff --git a/test/main/services/extensions/McpInstallationStateService.test.ts b/test/main/services/extensions/McpInstallationStateService.test.ts new file mode 100644 index 00000000..9e65a2be --- /dev/null +++ b/test/main/services/extensions/McpInstallationStateService.test.ts @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as fs from 'node:fs/promises'; + +import { McpInstallationStateService } from '@main/services/extensions/state/McpInstallationStateService'; + +vi.mock('@main/utils/pathDecoder', () => ({ + getHomeDir: () => '/tmp/mock-home', +})); + +vi.mock('node:fs/promises'); + +describe('McpInstallationStateService', () => { + let service: McpInstallationStateService; + const mockedFs = vi.mocked(fs); + + beforeEach(() => { + service = new McpInstallationStateService(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('getInstalled', () => { + it('caches results within TTL for the same project path', async () => { + mockedFs.readFile.mockImplementation(async (filePath) => { + const normalizedPath = String(filePath); + if (normalizedPath === '/tmp/mock-home/.claude.json') { + return JSON.stringify({ + mcpServers: { + context7: { command: 'npx -y @upstash/context7-mcp' }, + }, + }); + } + + if (normalizedPath === '/tmp/project-a/.mcp.json') { + return JSON.stringify({ + mcpServers: { + 'repo-a-server': { url: 'https://repo-a.example.com/mcp' }, + }, + }); + } + + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + + await service.getInstalled('/tmp/project-a'); + await service.getInstalled('/tmp/project-a'); + + expect(mockedFs.readFile).toHaveBeenCalledTimes(2); + }); + + it('caches results independently per project path', async () => { + mockedFs.readFile.mockImplementation(async (filePath) => { + const normalizedPath = String(filePath); + if (normalizedPath === '/tmp/mock-home/.claude.json') { + return JSON.stringify({ + mcpServers: { + context7: { command: 'npx -y @upstash/context7-mcp' }, + }, + }); + } + + if (normalizedPath === '/tmp/project-a/.mcp.json') { + return JSON.stringify({ + mcpServers: { + 'repo-a-server': { url: 'https://repo-a.example.com/mcp' }, + }, + }); + } + + if (normalizedPath === '/tmp/project-b/.mcp.json') { + return JSON.stringify({ + mcpServers: { + 'repo-b-server': { command: 'uvx repo-b-mcp' }, + }, + }); + } + + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + + const projectAEntries = await service.getInstalled('/tmp/project-a'); + const projectBEntries = await service.getInstalled('/tmp/project-b'); + + expect(projectAEntries).toEqual([ + { name: 'context7', scope: 'user', transport: 'stdio' }, + { name: 'repo-a-server', scope: 'project', transport: 'http' }, + ]); + expect(projectBEntries).toEqual([ + { name: 'context7', scope: 'user', transport: 'stdio' }, + { name: 'repo-b-server', scope: 'project', transport: 'stdio' }, + ]); + expect(mockedFs.readFile).toHaveBeenCalledTimes(4); + }); + }); +}); From 7418643dc91c82d07d29e365df85ff2ef519a1ee Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 22:45:34 +0300 Subject: [PATCH 070/121] fix(extensions): make mcp scope actions scope-aware --- .../extensions/mcp/McpServerCard.tsx | 24 +++++--- .../extensions/mcp/McpServerDetailDialog.tsx | 49 +++++++++++----- .../extensions/mcp/McpServersPanel.tsx | 24 ++++++-- src/shared/utils/extensionNormalizers.ts | 50 ++++++++++++++++ .../extensions/mcp/McpServerCard.test.ts | 34 +++++++++++ .../mcp/McpServerDetailDialog.test.ts | 58 +++++++++++++++++++ .../shared/utils/extensionNormalizers.test.ts | 42 +++++++++++++- 7 files changed, 254 insertions(+), 27 deletions(-) diff --git a/src/renderer/components/extensions/mcp/McpServerCard.tsx b/src/renderer/components/extensions/mcp/McpServerCard.tsx index d6586ff3..aec1c0c3 100644 --- a/src/renderer/components/extensions/mcp/McpServerCard.tsx +++ b/src/renderer/components/extensions/mcp/McpServerCard.tsx @@ -11,7 +11,10 @@ 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, + sanitizeMcpServerName, +} from '@shared/utils/extensionNormalizers'; import { Clock, Cloud, Globe, KeyRound, Lock, Monitor, Star, Tag, Wrench } from 'lucide-react'; import { Github as GithubIcon } from 'lucide-react'; @@ -28,6 +31,7 @@ interface McpServerCardProps { server: McpCatalogItem; isInstalled: boolean; installedEntry?: InstalledMcpEntry | null; + installedEntries?: InstalledMcpEntry[]; diagnostic?: McpServerDiagnostic | null; diagnosticsLoading?: boolean; onClick: (serverId: string) => void; @@ -37,6 +41,7 @@ export const McpServerCard = ({ server, isInstalled, installedEntry, + installedEntries = [], diagnostic, diagnosticsLoading, onClick, @@ -49,17 +54,22 @@ export const McpServerCard = ({ 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 userInstallEntry = + normalizedInstalledEntries.find((entry) => entry.scope === 'user') ?? null; + const installSummaryLabel = getMcpInstallationSummaryLabel(normalizedInstalledEntries); const supportsDirectInstalledAction = - isInstalled && - installedEntry?.scope === 'user' && - installedEntry.name === defaultServerName && - !requiresConfiguration; + isInstalled && userInstallEntry?.name === defaultServerName && !requiresConfiguration; const shouldShowDirectInstallButton = canAutoInstall && (!isInstalled ? !requiresConfiguration : supportsDirectInstalledAction); const [imgError, setImgError] = useState(false); @@ -117,7 +127,7 @@ export const McpServerCard = ({ className="border-emerald-500/30 bg-emerald-500/10 text-emerald-400" variant="outline" > - Installed + {installSummaryLabel ?? 'Installed'} )} {isInstalled && diagnosticsLoading && !diagnostic && ( @@ -253,7 +263,7 @@ export const McpServerCard = ({ }) } onUninstall={() => - uninstallMcpServer(server.id, installedEntry?.name ?? defaultServerName, 'user') + uninstallMcpServer(server.id, userInstallEntry?.name ?? defaultServerName, 'user') } size="sm" errorMessage={installError} diff --git a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx index 391e8239..91f3b2f8 100644 --- a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx +++ b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx @@ -25,7 +25,11 @@ import { SelectValue, } from '@renderer/components/ui/select'; import { useStore } from '@renderer/store'; -import { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers'; +import { + getMcpInstallationSummaryLabel, + getPreferredMcpInstallationEntry, + sanitizeMcpServerName, +} from '@shared/utils/extensionNormalizers'; import { ExternalLink, Lock, Plus, Star, Trash2, Wrench } from 'lucide-react'; import { InstallButton } from '../common/InstallButton'; @@ -42,6 +46,7 @@ interface McpServerDetailDialogProps { server: McpCatalogItem | null; isInstalled: boolean; installedEntry?: InstalledMcpEntry | null; + installedEntries?: InstalledMcpEntry[]; diagnostic?: McpServerDiagnostic | null; diagnosticsLoading?: boolean; projectPath: string | null; @@ -61,6 +66,7 @@ export const McpServerDetailDialog = ({ server, isInstalled, installedEntry, + installedEntries = [], diagnostic, diagnosticsLoading, projectPath, @@ -83,6 +89,15 @@ export const McpServerDetailDialog = ({ const [headers, setHeaders] = useState([]); const [imgError, setImgError] = useState(false); const [autoFilledFields, setAutoFilledFields] = useState>(new Set()); + const normalizedInstalledEntries = installedEntries.length + ? installedEntries + : installedEntry + ? [installedEntry] + : []; + const preferredInstalledEntry = getPreferredMcpInstallationEntry(normalizedInstalledEntries); + const selectedInstalledEntry = + normalizedInstalledEntries.find((entry) => entry.scope === scope) ?? null; + const installSummaryLabel = getMcpInstallationSummaryLabel(normalizedInstalledEntries); // Initialize form when dialog opens or server changes useEffect(() => { @@ -102,11 +117,19 @@ export const McpServerDetailDialog = ({ locked: true, })) ); - setServerName(installedEntry?.name ?? sanitizeMcpServerName(server.name)); - setScope(installedEntry?.scope ?? 'user'); + setServerName(preferredInstalledEntry?.name ?? sanitizeMcpServerName(server.name)); + setScope(preferredInstalledEntry?.scope ?? 'user'); setImgError(false); setAutoFilledFields(new Set()); - }, [installedEntry?.name, installedEntry?.scope, open, server?.id]); + }, [open, preferredInstalledEntry?.name, preferredInstalledEntry?.scope, server?.id]); + + useEffect(() => { + if (!server || !open) { + return; + } + + setServerName(selectedInstalledEntry?.name ?? sanitizeMcpServerName(server.name)); + }, [open, scope, selectedInstalledEntry?.name, server]); useEffect(() => { if (open && scope === 'project' && !projectPath) { @@ -153,10 +176,10 @@ export const McpServerDetailDialog = ({ const missingRequiredHeaders = headers.some( (header) => header.isRequired && !header.value.trim() ); - const uninstallServerName = installedEntry?.name ?? serverName; - const uninstallScope = installedEntry?.scope ?? scope; - const scopeRequiresProjectPath = - (scope === 'project' || uninstallScope === 'project') && !projectPath; + const isInstalledForScope = selectedInstalledEntry !== null; + const uninstallServerName = selectedInstalledEntry?.name ?? serverName; + const uninstallScope = selectedInstalledEntry?.scope ?? scope; + const scopeRequiresProjectPath = scope === 'project' && !projectPath; const installDisabled = !serverName.trim() || missingRequiredEnvVars || @@ -231,7 +254,7 @@ export const McpServerDetailDialog = ({ className="border-emerald-500/30 bg-emerald-500/10 text-emerald-400" variant="outline" > - Installed + {installSummaryLabel ?? 'Installed'} )} {server.source !== 'official' && } @@ -325,7 +348,7 @@ export const McpServerDetailDialog = ({ does not describe them. If connection fails after install, check the provider docs.
)} - {(isInstalled || diagnosticsLoading) && ( + {isInstalledForScope && (
Claude Status @@ -364,7 +387,7 @@ export const McpServerDetailDialog = ({ {canAutoInstall && (

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

{/* Server name */} @@ -378,7 +401,7 @@ export const McpServerDetailDialog = ({ onChange={(e) => setServerName(e.target.value)} placeholder="my-server" className="h-8 text-sm" - disabled={isInstalled} + disabled={isInstalledForScope} />
@@ -502,7 +525,7 @@ export const McpServerDetailDialog = ({
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); @@ -377,6 +387,7 @@ export const McpServersPanel = ({ server={server} isInstalled={isServerInstalled(server)} installedEntry={getInstalledEntry(server)} + installedEntries={getInstalledEntries(server)} diagnostic={getDiagnostic(server)} diagnosticsLoading={mcpDiagnosticsLoading} onClick={setSelectedMcpServerId} @@ -404,6 +415,7 @@ export const McpServersPanel = ({ server={selectedServer} isInstalled={selectedServer ? isServerInstalled(selectedServer) : false} installedEntry={selectedServer ? getInstalledEntry(selectedServer) : null} + installedEntries={selectedServer ? getInstalledEntries(selectedServer) : []} diagnostic={selectedServer ? getDiagnostic(selectedServer) : null} diagnosticsLoading={mcpDiagnosticsLoading} projectPath={projectPath} diff --git a/src/shared/utils/extensionNormalizers.ts b/src/shared/utils/extensionNormalizers.ts index ecff2758..11c4e931 100644 --- a/src/shared/utils/extensionNormalizers.ts +++ b/src/shared/utils/extensionNormalizers.ts @@ -5,6 +5,7 @@ import type { CliInstallationStatus, InstallScope, + InstalledMcpEntry, InstalledPluginEntry, PluginCapability, PluginCatalogItem, @@ -144,6 +145,55 @@ export function getInstallationSummaryLabel( } } +const MCP_SCOPE_PRIORITY: Record = { + user: 0, + project: 1, + local: 2, +}; + +/** + * Pick a stable MCP installation entry when multiple scopes are installed. + * Prefer user scope because it is the only safe direct-card action target. + */ +export function getPreferredMcpInstallationEntry( + installations: InstalledMcpEntry[] +): InstalledMcpEntry | null { + if (installations.length === 0) { + return null; + } + + return [...installations].sort( + (left, right) => MCP_SCOPE_PRIORITY[left.scope] - MCP_SCOPE_PRIORITY[right.scope] + )[0]!; +} + +/** + * Build a concise install-status label for MCP badges. + */ +export function getMcpInstallationSummaryLabel( + installations: Pick[] +): string | null { + const scopes = Array.from(new Set(installations.map((installation) => installation.scope))); + if (scopes.length === 0) { + return null; + } + + if (scopes.length > 1) { + return `Installed in ${scopes.length} scopes`; + } + + switch (scopes[0]) { + case 'user': + return 'Installed globally'; + case 'project': + return 'Installed in project'; + case 'local': + return 'Installed locally'; + default: + return 'Installed'; + } +} + /** * Install actions require Claude auth, but uninstall only requires a working CLI. */ diff --git a/test/renderer/components/extensions/mcp/McpServerCard.test.ts b/test/renderer/components/extensions/mcp/McpServerCard.test.ts index 7561f663..2e2a2be2 100644 --- a/test/renderer/components/extensions/mcp/McpServerCard.test.ts +++ b/test/renderer/components/extensions/mcp/McpServerCard.test.ts @@ -175,6 +175,7 @@ describe('McpServerCard direct action safety', () => { server: makeServer(), isInstalled: true, installedEntry, + installedEntries: [installedEntry], diagnostic: null, diagnosticsLoading: false, onClick: vi.fn(), @@ -190,4 +191,37 @@ describe('McpServerCard direct action safety', () => { await Promise.resolve(); }); }); + + it('keeps direct user action when the same server is installed in multiple scopes', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const installedEntries: InstalledMcpEntry[] = [ + { name: 'context7', scope: 'user' }, + { name: 'context7', scope: 'project' }, + ]; + + await act(async () => { + root.render( + React.createElement(McpServerCard, { + server: makeServer(), + isInstalled: true, + installedEntry: installedEntries[1], + installedEntries, + diagnostic: null, + diagnosticsLoading: false, + onClick: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Installed in 2 scopes'); + expect(host.querySelector('[data-testid="install-button"]')).not.toBeNull(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts b/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts index 42d178b4..a19aa6b7 100644 --- a/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts +++ b/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts @@ -281,6 +281,7 @@ describe('McpServerDetailDialog installed entry handling', () => { server: makeServer(), isInstalled: true, installedEntry, + installedEntries: [installedEntry], diagnostic: null, diagnosticsLoading: false, projectPath, @@ -313,6 +314,7 @@ describe('McpServerDetailDialog installed entry handling', () => { server: makeServer(), isInstalled: false, installedEntry: null, + installedEntries: [], diagnostic: null, diagnosticsLoading: false, projectPath, @@ -349,4 +351,60 @@ describe('McpServerDetailDialog installed entry handling', () => { await Promise.resolve(); }); }); + + it('uses selected scope instead of aggregated installed state', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const installedEntry: InstalledMcpEntry = { + name: 'context7', + scope: 'user', + }; + + await act(async () => { + root.render( + React.createElement(McpServerDetailDialog, { + server: makeServer(), + isInstalled: true, + installedEntry, + installedEntries: [installedEntry], + diagnostic: null, + diagnosticsLoading: false, + projectPath: '/tmp/project', + open: true, + onClose: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; + await act(async () => { + scopeSelect.value = 'project'; + scopeSelect.dispatchEvent(new Event('change', { bubbles: true })); + await Promise.resolve(); + }); + + const actionButton = host.querySelector('[data-testid="install-button"]') as HTMLButtonElement; + expect(actionButton.textContent).toBe('Install'); + + await act(async () => { + actionButton.click(); + await Promise.resolve(); + }); + + expect(storeState.installMcpServer).toHaveBeenCalledWith( + expect.objectContaining({ + registryId: 'io.github.upstash/context7', + scope: 'project', + projectPath: '/tmp/project', + }) + ); + expect(storeState.uninstallMcpServer).not.toHaveBeenCalled(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/shared/utils/extensionNormalizers.test.ts b/test/shared/utils/extensionNormalizers.test.ts index b78f1bdc..a558f227 100644 --- a/test/shared/utils/extensionNormalizers.test.ts +++ b/test/shared/utils/extensionNormalizers.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import type { PluginCatalogItem } from '@shared/types/extensions'; +import type { InstalledMcpEntry, PluginCatalogItem } from '@shared/types/extensions'; import { buildPluginId, @@ -8,6 +8,8 @@ import { getExtensionActionDisableReason, getCapabilityLabel, getInstallationSummaryLabel, + getMcpInstallationSummaryLabel, + getPreferredMcpInstallationEntry, getPluginOperationKey, getPrimaryCapabilityLabel, hasInstallationInScope, @@ -202,6 +204,44 @@ describe('getInstallationSummaryLabel', () => { }); }); +describe('getPreferredMcpInstallationEntry', () => { + it('returns null when there are no MCP installs', () => { + expect(getPreferredMcpInstallationEntry([])).toBeNull(); + }); + + it('prefers user scope over project and local', () => { + const installations: InstalledMcpEntry[] = [ + { name: 'context7', scope: 'project' }, + { name: 'context7', scope: 'user' }, + { name: 'context7', scope: 'local' }, + ]; + + expect(getPreferredMcpInstallationEntry(installations)).toEqual({ + name: 'context7', + scope: 'user', + }); + }); +}); + +describe('getMcpInstallationSummaryLabel', () => { + it('returns null when there are no MCP installations', () => { + expect(getMcpInstallationSummaryLabel([])).toBeNull(); + }); + + it('describes a single local MCP installation', () => { + expect(getMcpInstallationSummaryLabel([{ scope: 'local' }])).toBe('Installed locally'); + }); + + it('summarizes multiple MCP scopes', () => { + expect( + getMcpInstallationSummaryLabel([ + { scope: 'user' }, + { scope: 'project' }, + ]) + ).toBe('Installed in 2 scopes'); + }); +}); + describe('getExtensionActionDisableReason', () => { it('requires auth only for install actions', () => { expect( From a3c5b7dca9ecd6a32605896a83f8b723ac7000cf Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 22:48:43 +0300 Subject: [PATCH 071/121] fix(extensions): honor local mcp scope precedence --- .../state/McpInstallationStateService.ts | 82 +++++++++++++------ .../extensions/mcp/McpServerCard.tsx | 5 +- src/shared/utils/extensionNormalizers.ts | 8 +- .../McpInstallationStateService.test.ts | 52 ++++++++++++ .../extensions/mcp/McpServerCard.test.ts | 5 +- .../mcp/McpServerDetailDialog.test.ts | 38 +++++++++ .../shared/utils/extensionNormalizers.test.ts | 6 +- 7 files changed, 162 insertions(+), 34 deletions(-) diff --git a/src/main/services/extensions/state/McpInstallationStateService.ts b/src/main/services/extensions/state/McpInstallationStateService.ts index 6e5950ec..a550237b 100644 --- a/src/main/services/extensions/state/McpInstallationStateService.ts +++ b/src/main/services/extensions/state/McpInstallationStateService.ts @@ -3,8 +3,8 @@ * * Sources: * - User scope: ~/.claude.json → mcpServers + * - Local scope: ~/.claude.json → projects[projectPath].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. */ @@ -30,7 +30,7 @@ export class McpInstallationStateService { private cache = new Map>(); /** - * Get all installed MCP servers across user and project scopes. + * Get all installed MCP servers across user, local, and project scopes. */ async getInstalled(projectPath?: string): Promise { const cacheKey = projectPath ?? '__user__'; @@ -40,15 +40,14 @@ export class McpInstallationStateService { } const entries: InstalledMcpEntry[] = []; + const claudeConfig = await this.readClaudeConfig(); // User scope: ~/.claude.json - const userEntries = await this.readUserMcpServers(); - entries.push(...userEntries); + entries.push(...this.readUserMcpServers(claudeConfig)); - // Project scope: .mcp.json if (projectPath) { - const projectEntries = await this.readProjectMcpServers(projectPath); - entries.push(...projectEntries); + entries.push(...this.readLocalMcpServers(claudeConfig, projectPath)); + entries.push(...(await this.readProjectMcpServers(projectPath))); } this.cache.set(cacheKey, { data: entries, fetchedAt: Date.now() }); @@ -64,9 +63,37 @@ export class McpInstallationStateService { // ── Private ──────────────────────────────────────────────────────────── - private async readUserMcpServers(): Promise { + private async readClaudeConfig(): Promise | null> { const configPath = path.join(getHomeDir(), '.claude.json'); - return this.readMcpServersFromFile(configPath, 'user'); + 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 { @@ -74,6 +101,27 @@ export class McpInstallationStateService { 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' @@ -81,21 +129,7 @@ export class McpInstallationStateService { 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 }; - }); + return this.readMcpServersFromConfig(json.mcpServers, scope); } catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') { return []; diff --git a/src/renderer/components/extensions/mcp/McpServerCard.tsx b/src/renderer/components/extensions/mcp/McpServerCard.tsx index aec1c0c3..c1e0d260 100644 --- a/src/renderer/components/extensions/mcp/McpServerCard.tsx +++ b/src/renderer/components/extensions/mcp/McpServerCard.tsx @@ -69,7 +69,10 @@ export const McpServerCard = ({ normalizedInstalledEntries.find((entry) => entry.scope === 'user') ?? null; const installSummaryLabel = getMcpInstallationSummaryLabel(normalizedInstalledEntries); const supportsDirectInstalledAction = - isInstalled && userInstallEntry?.name === defaultServerName && !requiresConfiguration; + isInstalled && + normalizedInstalledEntries.length === 1 && + userInstallEntry?.name === defaultServerName && + !requiresConfiguration; const shouldShowDirectInstallButton = canAutoInstall && (!isInstalled ? !requiresConfiguration : supportsDirectInstalledAction); const [imgError, setImgError] = useState(false); diff --git a/src/shared/utils/extensionNormalizers.ts b/src/shared/utils/extensionNormalizers.ts index 11c4e931..9eb456c9 100644 --- a/src/shared/utils/extensionNormalizers.ts +++ b/src/shared/utils/extensionNormalizers.ts @@ -146,14 +146,14 @@ export function getInstallationSummaryLabel( } const MCP_SCOPE_PRIORITY: Record = { - user: 0, + local: 0, project: 1, - local: 2, + user: 2, }; /** - * Pick a stable MCP installation entry when multiple scopes are installed. - * Prefer user scope because it is the only safe direct-card action target. + * Pick the MCP installation entry that Claude will actually use. + * Scope precedence matches Claude Code: local > project > user. */ export function getPreferredMcpInstallationEntry( installations: InstalledMcpEntry[] diff --git a/test/main/services/extensions/McpInstallationStateService.test.ts b/test/main/services/extensions/McpInstallationStateService.test.ts index 9e65a2be..61bfb566 100644 --- a/test/main/services/extensions/McpInstallationStateService.test.ts +++ b/test/main/services/extensions/McpInstallationStateService.test.ts @@ -23,6 +23,44 @@ describe('McpInstallationStateService', () => { }); describe('getInstalled', () => { + it('includes local scope from the current project entry in ~/.claude.json', async () => { + mockedFs.readFile.mockImplementation(async (filePath) => { + const normalizedPath = String(filePath); + if (normalizedPath === '/tmp/mock-home/.claude.json') { + return JSON.stringify({ + mcpServers: { + context7: { command: 'npx -y @upstash/context7-mcp' }, + }, + projects: { + '/tmp/project-a': { + mcpServers: { + stripe: { url: 'https://mcp.stripe.com' }, + }, + }, + }, + }); + } + + if (normalizedPath === '/tmp/project-a/.mcp.json') { + return JSON.stringify({ + mcpServers: { + paypal: { url: 'https://mcp.paypal.com/mcp' }, + }, + }); + } + + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + + const entries = await service.getInstalled('/tmp/project-a'); + + expect(entries).toEqual([ + { name: 'context7', scope: 'user', transport: 'stdio' }, + { name: 'stripe', scope: 'local', transport: 'http' }, + { name: 'paypal', scope: 'project', transport: 'http' }, + ]); + }); + it('caches results within TTL for the same project path', async () => { mockedFs.readFile.mockImplementation(async (filePath) => { const normalizedPath = String(filePath); @@ -59,6 +97,18 @@ describe('McpInstallationStateService', () => { mcpServers: { context7: { command: 'npx -y @upstash/context7-mcp' }, }, + projects: { + '/tmp/project-a': { + mcpServers: { + stripe: { url: 'https://mcp.stripe.com' }, + }, + }, + '/tmp/project-b': { + mcpServers: { + github: { command: 'uvx github-mcp' }, + }, + }, + }, }); } @@ -86,10 +136,12 @@ describe('McpInstallationStateService', () => { expect(projectAEntries).toEqual([ { name: 'context7', scope: 'user', transport: 'stdio' }, + { name: 'stripe', scope: 'local', transport: 'http' }, { name: 'repo-a-server', scope: 'project', transport: 'http' }, ]); expect(projectBEntries).toEqual([ { name: 'context7', scope: 'user', transport: 'stdio' }, + { name: 'github', scope: 'local', transport: 'stdio' }, { name: 'repo-b-server', scope: 'project', transport: 'stdio' }, ]); expect(mockedFs.readFile).toHaveBeenCalledTimes(4); diff --git a/test/renderer/components/extensions/mcp/McpServerCard.test.ts b/test/renderer/components/extensions/mcp/McpServerCard.test.ts index 2e2a2be2..a122cd66 100644 --- a/test/renderer/components/extensions/mcp/McpServerCard.test.ts +++ b/test/renderer/components/extensions/mcp/McpServerCard.test.ts @@ -192,7 +192,7 @@ describe('McpServerCard direct action safety', () => { }); }); - it('keeps direct user action when the same server is installed in multiple scopes', async () => { + it('falls back to Manage when the same server is installed in multiple scopes', async () => { const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); @@ -217,7 +217,8 @@ describe('McpServerCard direct action safety', () => { }); expect(host.textContent).toContain('Installed in 2 scopes'); - expect(host.querySelector('[data-testid="install-button"]')).not.toBeNull(); + expect(host.querySelector('[data-testid="install-button"]')).toBeNull(); + expect(host.textContent).toContain('Manage'); await act(async () => { root.unmount(); diff --git a/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts b/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts index a19aa6b7..356415e9 100644 --- a/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts +++ b/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts @@ -407,4 +407,42 @@ describe('McpServerDetailDialog installed entry handling', () => { await Promise.resolve(); }); }); + + it('defaults to the highest-precedence installed scope', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const installedEntries: InstalledMcpEntry[] = [ + { name: 'context7', scope: 'user' }, + { name: 'context7-shared', scope: 'project' }, + ]; + + await act(async () => { + root.render( + React.createElement(McpServerDetailDialog, { + server: makeServer(), + isInstalled: true, + installedEntry: installedEntries[0], + installedEntries, + diagnostic: null, + diagnosticsLoading: false, + projectPath: '/tmp/project', + open: true, + onClose: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; + const serverNameInput = host.querySelector('#server-name') as HTMLInputElement; + + expect(scopeSelect.value).toBe('project'); + expect(serverNameInput.value).toBe('context7-shared'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/shared/utils/extensionNormalizers.test.ts b/test/shared/utils/extensionNormalizers.test.ts index a558f227..6b4708fc 100644 --- a/test/shared/utils/extensionNormalizers.test.ts +++ b/test/shared/utils/extensionNormalizers.test.ts @@ -209,16 +209,16 @@ describe('getPreferredMcpInstallationEntry', () => { expect(getPreferredMcpInstallationEntry([])).toBeNull(); }); - it('prefers user scope over project and local', () => { + it('prefers local scope over project and user', () => { const installations: InstalledMcpEntry[] = [ - { name: 'context7', scope: 'project' }, { name: 'context7', scope: 'user' }, + { name: 'context7', scope: 'project' }, { name: 'context7', scope: 'local' }, ]; expect(getPreferredMcpInstallationEntry(installations)).toEqual({ name: 'context7', - scope: 'user', + scope: 'local', }); }); }); From 94291f50f0381b575783007bad56f2cb15ee3f8e Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 22:51:05 +0300 Subject: [PATCH 072/121] fix(extensions): require project context for local mcp scope --- .../extensions/install/McpInstallService.ts | 16 ++-- .../extensions/mcp/CustomMcpServerDialog.tsx | 8 +- .../extensions/mcp/McpServerDetailDialog.tsx | 10 +-- .../extensions/McpInstallService.test.ts | 40 +++++++++ .../mcp/CustomMcpServerDialog.test.ts | 58 +++++++++++- .../mcp/McpServerDetailDialog.test.ts | 90 ++++++++++++++++++- 6 files changed, 205 insertions(+), 17 deletions(-) diff --git a/src/main/services/extensions/install/McpInstallService.ts b/src/main/services/extensions/install/McpInstallService.ts index 779e6178..687c904a 100644 --- a/src/main/services/extensions/install/McpInstallService.ts +++ b/src/main/services/extensions/install/McpInstallService.ts @@ -37,6 +37,10 @@ const HEADER_KEY_RE = /^[A-Za-z][\w-]{0,100}$/; const TIMEOUT_MS = 30_000; +function scopeRequiresProjectPath(scope?: string): boolean { + return scope === 'local' || scope === 'project'; +} + export class McpInstallService { constructor(private readonly aggregator: McpCatalogAggregator) {} @@ -59,10 +63,10 @@ export class McpInstallService { }; } - if (scope === 'project' && !projectPath) { + if (scopeRequiresProjectPath(scope) && !projectPath) { return { state: 'error', - error: 'projectPath is required for project scope', + error: `projectPath is required for ${scope} scope`, }; } @@ -219,8 +223,8 @@ export class McpInstallService { return { state: 'error', error: `Invalid scope: "${scope}".` }; } - if (scope === 'project' && !projectPath) { - return { state: 'error', error: 'projectPath is required for project scope' }; + if (scopeRequiresProjectPath(scope) && !projectPath) { + return { state: 'error', error: `projectPath is required for ${scope} scope` }; } for (const key of Object.keys(envValues)) { @@ -330,10 +334,10 @@ export class McpInstallService { }; } - if (scope === 'project' && !projectPath) { + if (scopeRequiresProjectPath(scope) && !projectPath) { return { state: 'error', - error: 'projectPath is required for project scope', + error: `projectPath is required for ${scope} scope`, }; } diff --git a/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx b/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx index b096287e..7cb8e740 100644 --- a/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx +++ b/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx @@ -105,7 +105,7 @@ export const CustomMcpServerDialog = ({ }, [open]); useEffect(() => { - if (open && scope === 'project' && !projectPath) { + if (open && scope !== 'user' && !projectPath) { setScope('user'); } }, [open, projectPath, scope]); @@ -177,7 +177,7 @@ export const CustomMcpServerDialog = ({ const request: McpCustomInstallRequest = { serverName, scope, - projectPath: scope === 'project' ? (projectPath ?? undefined) : undefined, + projectPath: scope !== 'user' ? (projectPath ?? undefined) : undefined, installSpec, envValues, headers: headers.filter((h) => h.key.trim() && h.value.trim()), @@ -207,7 +207,7 @@ export const CustomMcpServerDialog = ({ const canSubmit = serverName.trim() && (transportMode === 'stdio' ? npmPackage.trim() : httpUrl.trim()) && - !(scope === 'project' && !projectPath) && + !(scope !== 'user' && !projectPath) && !installing; return ( @@ -386,7 +386,7 @@ export const CustomMcpServerDialog = ({ {opt.label} diff --git a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx index 91f3b2f8..43b49c27 100644 --- a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx +++ b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx @@ -132,7 +132,7 @@ export const McpServerDetailDialog = ({ }, [open, scope, selectedInstalledEntry?.name, server]); useEffect(() => { - if (open && scope === 'project' && !projectPath) { + if (open && scope !== 'user' && !projectPath) { setScope('user'); } }, [open, projectPath, scope]); @@ -179,7 +179,7 @@ export const McpServerDetailDialog = ({ const isInstalledForScope = selectedInstalledEntry !== null; const uninstallServerName = selectedInstalledEntry?.name ?? serverName; const uninstallScope = selectedInstalledEntry?.scope ?? scope; - const scopeRequiresProjectPath = scope === 'project' && !projectPath; + const scopeRequiresProjectPath = scope !== 'user' && !projectPath; const installDisabled = !serverName.trim() || missingRequiredEnvVars || @@ -199,7 +199,7 @@ export const McpServerDetailDialog = ({ registryId: server.id, serverName, scope, - projectPath: scope === 'project' ? (projectPath ?? undefined) : undefined, + projectPath: scope !== 'user' ? (projectPath ?? undefined) : undefined, envValues, headers, }); @@ -210,7 +210,7 @@ export const McpServerDetailDialog = ({ server.id, uninstallServerName, uninstallScope, - uninstallScope === 'project' ? (projectPath ?? undefined) : undefined + uninstallScope !== 'user' ? (projectPath ?? undefined) : undefined ); }; @@ -417,7 +417,7 @@ export const McpServerDetailDialog = ({ {opt.label} diff --git a/test/main/services/extensions/McpInstallService.test.ts b/test/main/services/extensions/McpInstallService.test.ts index 1320d359..f67716ad 100644 --- a/test/main/services/extensions/McpInstallService.test.ts +++ b/test/main/services/extensions/McpInstallService.test.ts @@ -130,6 +130,7 @@ describe('McpInstallService', () => { registryId: 'upstash/context7-mcp', serverName: 'context7', scope: 'local', + projectPath: '/tmp/test', envValues: {}, headers: [], }); @@ -230,6 +231,20 @@ describe('McpInstallService', () => { expect(result.error).toContain('projectPath is required'); expect(mockExecCli).not.toHaveBeenCalled(); }); + + it('rejects local scope install without project path', async () => { + const result = await service.install({ + registryId: 'upstash/context7-mcp', + serverName: 'context7', + scope: 'local', + envValues: {}, + headers: [], + }); + + expect(result.state).toBe('error'); + expect(result.error).toContain('projectPath is required'); + expect(mockExecCli).not.toHaveBeenCalled(); + }); }); // ── install: error masking ────────────────────────────────────────────────── @@ -290,6 +305,23 @@ describe('McpInstallService', () => { expect(result.error).toContain('projectPath is required'); expect(mockExecCli).not.toHaveBeenCalled(); }); + + it('rejects local scope custom install without project path', async () => { + const result = await service.installCustom({ + serverName: 'custom-context7', + scope: 'local', + installSpec: { + type: 'stdio', + npmPackage: '@upstash/context7-mcp', + }, + envValues: {}, + headers: [], + }); + + expect(result.state).toBe('error'); + expect(result.error).toContain('projectPath is required'); + expect(mockExecCli).not.toHaveBeenCalled(); + }); }); // ── uninstall ─────────────────────────────────────────────────────────────── @@ -334,6 +366,14 @@ describe('McpInstallService', () => { expect(mockExecCli).not.toHaveBeenCalled(); }); + it('rejects local scope uninstall without project path', async () => { + const result = await service.uninstall('context7', 'local'); + + expect(result.state).toBe('error'); + expect(result.error).toContain('projectPath is required'); + expect(mockExecCli).not.toHaveBeenCalled(); + }); + it('returns error on CLI failure', async () => { mockExecCli.mockRejectedValue(new Error('Not found')); diff --git a/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts b/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts index 9dd675ef..31ef3d9f 100644 --- a/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts +++ b/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts @@ -125,7 +125,7 @@ describe('CustomMcpServerDialog project scope', () => { vi.unstubAllGlobals(); }); - it('disables project scope without an active project', async () => { + it('disables non-user scopes without an active project', async () => { const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); @@ -142,7 +142,9 @@ describe('CustomMcpServerDialog project scope', () => { }); const projectOption = host.querySelector('option[value="project"]') as HTMLOptionElement; + const localOption = host.querySelector('option[value="local"]') as HTMLOptionElement; expect(projectOption.disabled).toBe(true); + expect(localOption.disabled).toBe(true); await act(async () => { root.unmount(); @@ -203,4 +205,58 @@ describe('CustomMcpServerDialog project scope', () => { await Promise.resolve(); }); }); + + it('passes projectPath for local-scoped custom installs', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const onClose = vi.fn(); + const projectPath = '/tmp/custom-mcp-project'; + + await act(async () => { + root.render( + React.createElement(CustomMcpServerDialog, { + open: true, + onClose, + projectPath, + }) + ); + await Promise.resolve(); + }); + + const nameInput = host.querySelector('#custom-name') as HTMLInputElement; + const packageInput = host.querySelector('#custom-npm') as HTMLInputElement; + const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; + + await act(async () => { + setNativeValue(nameInput, 'local-context7', 'input'); + setNativeValue(packageInput, '@upstash/context7-mcp', 'input'); + setNativeValue(scopeSelect, 'local', 'change'); + await Promise.resolve(); + }); + + const installButton = Array.from(host.querySelectorAll('button')).find( + (button) => button.textContent === 'Install' + ) as HTMLButtonElement; + expect(installButton.disabled).toBe(false); + + await act(async () => { + installButton.click(); + await Promise.resolve(); + }); + + expect(storeState.installCustomMcpServer).toHaveBeenCalledWith( + expect.objectContaining({ + serverName: 'local-context7', + scope: 'local', + projectPath, + }) + ); + expect(onClose).toHaveBeenCalledTimes(1); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts b/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts index 356415e9..262161bd 100644 --- a/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts +++ b/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts @@ -217,7 +217,7 @@ describe('McpServerDetailDialog installed entry handling', () => { 'io.github.upstash/context7', 'context7-local', 'local', - undefined + '/tmp/project' ); await act(async () => { @@ -258,6 +258,10 @@ describe('McpServerDetailDialog installed entry handling', () => { expect(lookupMock).toHaveBeenCalledTimes(1); expect(lookupMock).toHaveBeenCalledWith(['CONTEXT7_API_KEY']); + const projectOption = host.querySelector('option[value="project"]') as HTMLOptionElement; + const localOption = host.querySelector('option[value="local"]') as HTMLOptionElement; + expect(projectOption.disabled).toBe(true); + expect(localOption.disabled).toBe(true); await act(async () => { root.unmount(); @@ -352,6 +356,90 @@ describe('McpServerDetailDialog installed entry handling', () => { }); }); + it('passes project path for local-scoped installs and uninstalls', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const projectPath = '/tmp/local-context7'; + const installedEntry: InstalledMcpEntry = { + name: 'context7-local', + scope: 'local', + }; + + await act(async () => { + root.render( + React.createElement(McpServerDetailDialog, { + server: makeServer(), + isInstalled: true, + installedEntry, + installedEntries: [installedEntry], + diagnostic: null, + diagnosticsLoading: false, + projectPath, + open: true, + onClose: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + const uninstallButton = host.querySelector('[data-testid="install-button"]') as HTMLButtonElement; + await act(async () => { + uninstallButton.click(); + await Promise.resolve(); + }); + + expect(storeState.uninstallMcpServer).toHaveBeenCalledWith( + 'io.github.upstash/context7', + 'context7-local', + 'local', + projectPath + ); + + await act(async () => { + root.render( + React.createElement(McpServerDetailDialog, { + server: makeServer(), + isInstalled: false, + installedEntry: null, + installedEntries: [], + diagnostic: null, + diagnosticsLoading: false, + projectPath, + open: true, + onClose: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; + await act(async () => { + scopeSelect.value = 'local'; + scopeSelect.dispatchEvent(new Event('change', { bubbles: true })); + await Promise.resolve(); + }); + + const installButton = host.querySelector('[data-testid="install-button"]') as HTMLButtonElement; + await act(async () => { + installButton.click(); + await Promise.resolve(); + }); + + expect(storeState.installMcpServer).toHaveBeenCalledWith( + expect.objectContaining({ + registryId: 'io.github.upstash/context7', + scope: 'local', + projectPath, + }) + ); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('uses selected scope instead of aggregated installed state', async () => { const host = document.createElement('div'); document.body.appendChild(host); From 66cf1443b2a5b763e16e4f0177c13d91b8d832a8 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 22:57:22 +0300 Subject: [PATCH 073/121] fix(extensions): scope mcp operation state by install scope --- .../extensions/mcp/McpServerCard.tsx | 6 +- .../extensions/mcp/McpServerDetailDialog.tsx | 8 +- src/renderer/store/slices/extensionsSlice.ts | 170 ++++++++++++++---- src/shared/utils/extensionNormalizers.ts | 7 + .../extensions/mcp/McpServerCard.test.ts | 62 ++++++- .../mcp/McpServerDetailDialog.test.ts | 56 ++++++ test/renderer/store/extensionsSlice.test.ts | 90 +++++++++- .../shared/utils/extensionNormalizers.test.ts | 9 + 8 files changed, 359 insertions(+), 49 deletions(-) diff --git a/src/renderer/components/extensions/mcp/McpServerCard.tsx b/src/renderer/components/extensions/mcp/McpServerCard.tsx index c1e0d260..afae2142 100644 --- a/src/renderer/components/extensions/mcp/McpServerCard.tsx +++ b/src/renderer/components/extensions/mcp/McpServerCard.tsx @@ -13,6 +13,7 @@ import { useStore } from '@renderer/store'; import { formatCompactNumber, formatRelativeTime } from '@renderer/utils/formatters'; import { getMcpInstallationSummaryLabel, + getMcpOperationKey, sanitizeMcpServerName, } from '@shared/utils/extensionNormalizers'; import { Clock, Cloud, Globe, KeyRound, Lock, Monitor, Star, Tag, Wrench } from 'lucide-react'; @@ -46,10 +47,11 @@ export const McpServerCard = ({ diagnosticsLoading, onClick, }: McpServerCardProps): React.JSX.Element => { - const installProgress = useStore((s) => s.mcpInstallProgress[server.id] ?? 'idle'); + const operationKey = getMcpOperationKey(server.id, 'user'); + 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 ); diff --git a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx index 43b49c27..d16e0885 100644 --- a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx +++ b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx @@ -27,6 +27,7 @@ import { import { useStore } from '@renderer/store'; import { getMcpInstallationSummaryLabel, + getMcpOperationKey, getPreferredMcpInstallationEntry, sanitizeMcpServerName, } from '@shared/utils/extensionNormalizers'; @@ -73,17 +74,18 @@ export const McpServerDetailDialog = ({ open, onClose, }: McpServerDetailDialogProps): React.JSX.Element => { + const [scope, setScope] = useState('user'); + const operationKey = server ? getMcpOperationKey(server.id, scope) : null; const installProgress = useStore( - (s) => (server ? s.mcpInstallProgress[server.id] : undefined) ?? 'idle' + (s) => (operationKey ? s.mcpInstallProgress[operationKey] : undefined) ?? 'idle' ); const installMcpServer = useStore((s) => s.installMcpServer); const uninstallMcpServer = useStore((s) => s.uninstallMcpServer); - const installError = useStore((s) => (server ? s.installErrors[server.id] : undefined)); + const installError = useStore((s) => (operationKey ? s.installErrors[operationKey] : undefined)); const stars = useStore((s) => server?.repositoryUrl ? s.mcpGitHubStars[server.repositoryUrl] : undefined ); - const [scope, setScope] = useState('user'); const [serverName, setServerName] = useState(''); const [envValues, setEnvValues] = useState>({}); const [headers, setHeaders] = useState([]); diff --git a/src/renderer/store/slices/extensionsSlice.ts b/src/renderer/store/slices/extensionsSlice.ts index fdea0a79..6f56629b 100644 --- a/src/renderer/store/slices/extensionsSlice.ts +++ b/src/renderer/store/slices/extensionsSlice.ts @@ -5,7 +5,7 @@ import { api } from '@renderer/api'; import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli'; -import { getPluginOperationKey } from '@shared/utils/extensionNormalizers'; +import { getMcpOperationKey, getPluginOperationKey } from '@shared/utils/extensionNormalizers'; import { findPaneByTabId, updatePane } from '../utils/paneHelpers'; @@ -60,7 +60,7 @@ export interface ExtensionsSlice { // ── Install progress ── pluginInstallProgress: Record; mcpInstallProgress: Record; - installErrors: Record; // keyed by pluginId or registryId + installErrors: Record; // keyed by scoped operation key // ── API Keys ── apiKeys: ApiKeyEntry[]; @@ -131,6 +131,7 @@ export interface ExtensionsSlice { let pluginFetchInFlight: { key: string; promise: Promise } | null = null; let pluginCatalogRequestSeq = 0; const pluginSuccessResetTimers = new Map>(); +const mcpSuccessResetTimers = new Map>(); let mcpDiagnosticsInFlight: Promise | null = null; let skillsCatalogRequestSeq = 0; let skillsDetailRequestSeq = 0; @@ -221,6 +222,82 @@ function schedulePluginSuccessReset( pluginSuccessResetTimers.set(operationKey, timer); } +function getCustomMcpOperationKey(serverName: string, scope: InstallScope): string { + return `mcp-custom:${serverName}:${scope}`; +} + +function clearMcpSuccessResetTimer(operationKey: string): void { + const timer = mcpSuccessResetTimers.get(operationKey); + if (!timer) { + return; + } + + clearTimeout(timer); + mcpSuccessResetTimers.delete(operationKey); +} + +function scheduleMcpSuccessReset( + operationKey: string, + set: Parameters>[0] +): void { + clearMcpSuccessResetTimer(operationKey); + const timer = setTimeout(() => { + mcpSuccessResetTimers.delete(operationKey); + set((prev) => { + if (prev.mcpInstallProgress[operationKey] !== 'success') { + return {}; + } + + return { + mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'idle' }, + }; + }); + }, SUCCESS_DISPLAY_MS); + mcpSuccessResetTimers.set(operationKey, timer); +} + +function clearMcpProjectScopedOperationState( + mcpInstallProgress: Record, + installErrors: Record +): { + mcpInstallProgress: Record; + installErrors: Record; +} { + const nextMcpInstallProgress = { ...mcpInstallProgress }; + const nextInstallErrors = { ...installErrors }; + + for (const operationKey of Object.keys(nextMcpInstallProgress)) { + if ( + (operationKey.startsWith('mcp:') || operationKey.startsWith('mcp-custom:')) && + (operationKey.endsWith(':project') || operationKey.endsWith(':local')) + ) { + delete nextMcpInstallProgress[operationKey]; + } + } + + for (const operationKey of Object.keys(nextInstallErrors)) { + if ( + (operationKey.startsWith('mcp:') || operationKey.startsWith('mcp-custom:')) && + (operationKey.endsWith(':project') || operationKey.endsWith(':local')) + ) { + delete nextInstallErrors[operationKey]; + } + } + + return { + mcpInstallProgress: nextMcpInstallProgress, + installErrors: nextInstallErrors, + }; +} + +function clearMcpProjectScopedSuccessResetTimers(): void { + for (const operationKey of Array.from(mcpSuccessResetTimers.keys())) { + if (operationKey.endsWith(':project') || operationKey.endsWith(':local')) { + clearMcpSuccessResetTimer(operationKey); + } + } +} + function getSkillsCatalogKey(projectPath?: string): string { return projectPath ?? USER_SKILLS_CATALOG_KEY; } @@ -434,9 +511,26 @@ export const createExtensionsSlice: StateCreator { + const nextProjectPath = projectPath ?? null; + const isSameProjectContext = prev.mcpInstalledProjectPath === nextProjectPath; + const nextOperationState = isSameProjectContext + ? { + mcpInstallProgress: prev.mcpInstallProgress, + installErrors: prev.installErrors, + } + : clearMcpProjectScopedOperationState(prev.mcpInstallProgress, prev.installErrors); + + if (!isSameProjectContext) { + clearMcpProjectScopedSuccessResetTimers(); + } + + return { + mcpInstalledServers: installed, + mcpInstalledProjectPath: nextProjectPath, + mcpInstallProgress: nextOperationState.mcpInstallProgress, + installErrors: nextOperationState.installErrors, + }; }); } catch { // Silently fail — installed state is supplementary @@ -833,29 +927,32 @@ export const createExtensionsSlice: StateCreator { + const operationKey = getMcpOperationKey(request.registryId, request.scope); if (!api.mcpRegistry) { + clearMcpSuccessResetTimer(operationKey); set((prev) => ({ - mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'error' }, + mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'error' }, installErrors: { ...prev.installErrors, - [request.registryId]: 'MCP Registry not available', + [operationKey]: 'MCP Registry not available', }, })); return; } + clearMcpSuccessResetTimer(operationKey); set((prev) => ({ - mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'pending' }, + mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'pending' }, })); try { const result = await api.mcpRegistry.install(request); if (result.state === 'error') { set((prev) => ({ - mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'error' }, + mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'error' }, installErrors: { ...prev.installErrors, - [request.registryId]: result.error ?? 'Install failed', + [operationKey]: result.error ?? 'Install failed', }, })); return; @@ -867,27 +964,26 @@ export const createExtensionsSlice: StateCreator ({ - mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'success' }, + mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'success' }, })); - setTimeout(() => { - set((prev) => ({ - mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'idle' }, - })); - }, SUCCESS_DISPLAY_MS); + scheduleMcpSuccessReset(operationKey, set); } catch (err) { + clearMcpSuccessResetTimer(operationKey); const message = err instanceof Error ? err.message : 'Install failed'; set((prev) => ({ - mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'error' }, - installErrors: { ...prev.installErrors, [request.registryId]: message }, + mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'error' }, + installErrors: { ...prev.installErrors, [operationKey]: message }, })); } }, // ── MCP custom install ── installCustomMcpServer: async (request: McpCustomInstallRequest) => { + const operationScope = request.scope; + const progressKey = getCustomMcpOperationKey(request.serverName, operationScope); if (!api.mcpRegistry) { - const progressKey = `custom:${request.serverName}`; + clearMcpSuccessResetTimer(progressKey); set((prev) => ({ mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'error' }, installErrors: { ...prev.installErrors, [progressKey]: 'MCP Registry not available' }, @@ -895,7 +991,7 @@ export const createExtensionsSlice: StateCreator ({ mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'pending' }, })); @@ -919,12 +1015,9 @@ export const createExtensionsSlice: StateCreator { - set((prev) => ({ - mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'idle' }, - })); - }, SUCCESS_DISPLAY_MS); + scheduleMcpSuccessReset(progressKey, set); } catch (err) { + clearMcpSuccessResetTimer(progressKey); const message = err instanceof Error ? err.message : 'Install failed'; set((prev) => ({ mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'error' }, @@ -940,26 +1033,30 @@ export const createExtensionsSlice: StateCreator { + const operationScope: InstallScope = scope === 'project' || scope === 'local' ? scope : 'user'; + const operationKey = getMcpOperationKey(registryId, operationScope); if (!api.mcpRegistry) { + clearMcpSuccessResetTimer(operationKey); set((prev) => ({ - mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'error' }, - installErrors: { ...prev.installErrors, [registryId]: 'MCP Registry not available' }, + mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'error' }, + installErrors: { ...prev.installErrors, [operationKey]: 'MCP Registry not available' }, })); return; } + clearMcpSuccessResetTimer(operationKey); set((prev) => ({ - mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'pending' }, + mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'pending' }, })); try { const result = await api.mcpRegistry.uninstall(name, scope, projectPath); if (result.state === 'error') { set((prev) => ({ - mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'error' }, + mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'error' }, installErrors: { ...prev.installErrors, - [registryId]: result.error ?? 'Uninstall failed', + [operationKey]: result.error ?? 'Uninstall failed', }, })); return; @@ -971,19 +1068,16 @@ export const createExtensionsSlice: StateCreator ({ - mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'success' }, + mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'success' }, })); - setTimeout(() => { - set((prev) => ({ - mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'idle' }, - })); - }, SUCCESS_DISPLAY_MS); + scheduleMcpSuccessReset(operationKey, set); } catch (err) { + clearMcpSuccessResetTimer(operationKey); const message = err instanceof Error ? err.message : 'Uninstall failed'; set((prev) => ({ - mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'error' }, - installErrors: { ...prev.installErrors, [registryId]: message }, + mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'error' }, + installErrors: { ...prev.installErrors, [operationKey]: message }, })); } }, diff --git a/src/shared/utils/extensionNormalizers.ts b/src/shared/utils/extensionNormalizers.ts index 9eb456c9..acdf3a88 100644 --- a/src/shared/utils/extensionNormalizers.ts +++ b/src/shared/utils/extensionNormalizers.ts @@ -108,6 +108,13 @@ export function getPluginOperationKey(pluginId: string, scope: InstallScope): st return `plugin:${pluginId}:${scope}`; } +/** + * Namespaced operation-state key for MCP install/uninstall UI state. + */ +export function getMcpOperationKey(registryId: string, scope: InstallScope): string { + return `mcp:${registryId}:${scope}`; +} + /** * Check whether a plugin has an installation for the selected scope. */ diff --git a/test/renderer/components/extensions/mcp/McpServerCard.test.ts b/test/renderer/components/extensions/mcp/McpServerCard.test.ts index a122cd66..3a5509ec 100644 --- a/test/renderer/components/extensions/mcp/McpServerCard.test.ts +++ b/test/renderer/components/extensions/mcp/McpServerCard.test.ts @@ -2,6 +2,7 @@ import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getMcpOperationKey } from '@shared/utils/extensionNormalizers'; import type { InstalledMcpEntry, McpCatalogItem } from '@shared/types/extensions'; interface StoreState { @@ -55,7 +56,23 @@ vi.mock('@renderer/components/ui/tooltip', () => ({ })); vi.mock('@renderer/components/extensions/common/InstallButton', () => ({ - InstallButton: () => React.createElement('button', { type: 'button', 'data-testid': 'install-button' }, 'Install'), + InstallButton: ({ + state, + errorMessage, + }: { + state?: string; + errorMessage?: string; + }) => + React.createElement( + 'button', + { + type: 'button', + 'data-testid': 'install-button', + 'data-state': state, + 'data-error': errorMessage, + }, + 'Install' + ), })); vi.mock('@renderer/components/extensions/common/SourceBadge', () => ({ @@ -225,4 +242,47 @@ describe('McpServerCard direct action safety', () => { await Promise.resolve(); }); }); + + it('reads direct-action state from the user-scope operation key', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const installedEntry: InstalledMcpEntry = { + name: 'context7', + scope: 'user', + }; + + storeState.mcpInstallProgress = { + [getMcpOperationKey('io.github.upstash/context7', 'project')]: 'error', + [getMcpOperationKey('io.github.upstash/context7', 'user')]: 'pending', + }; + storeState.installErrors = { + [getMcpOperationKey('io.github.upstash/context7', 'project')]: 'Project failed', + [getMcpOperationKey('io.github.upstash/context7', 'user')]: 'User failed', + }; + + await act(async () => { + root.render( + React.createElement(McpServerCard, { + server: makeServer(), + isInstalled: true, + installedEntry, + installedEntries: [installedEntry], + diagnostic: null, + diagnosticsLoading: false, + onClick: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + const installButton = host.querySelector('[data-testid="install-button"]') as HTMLButtonElement; + expect(installButton.dataset.state).toBe('pending'); + expect(installButton.dataset.error).toBe('User failed'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts b/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts index 262161bd..c6f4c204 100644 --- a/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts +++ b/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts @@ -2,6 +2,7 @@ import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getMcpOperationKey } from '@shared/utils/extensionNormalizers'; import type { InstalledMcpEntry, McpCatalogItem } from '@shared/types/extensions'; interface StoreState { @@ -104,10 +105,14 @@ vi.mock('@renderer/components/ui/select', () => ({ vi.mock('@renderer/components/extensions/common/InstallButton', () => ({ InstallButton: ({ isInstalled, + state, + errorMessage, onInstall, onUninstall, }: { isInstalled: boolean; + state?: string; + errorMessage?: string; onInstall: () => void; onUninstall: () => void; }) => @@ -116,6 +121,8 @@ vi.mock('@renderer/components/extensions/common/InstallButton', () => ({ { type: 'button', 'data-testid': 'install-button', + 'data-state': state, + 'data-error': errorMessage, onClick: () => (isInstalled ? onUninstall() : onInstall()), }, isInstalled ? 'Uninstall' : 'Install' @@ -533,4 +540,53 @@ describe('McpServerDetailDialog installed entry handling', () => { await Promise.resolve(); }); }); + + it('reads install state from the selected scope operation key', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + storeState.mcpInstallProgress = { + [getMcpOperationKey('io.github.upstash/context7', 'user')]: 'success', + [getMcpOperationKey('io.github.upstash/context7', 'project')]: 'error', + }; + storeState.installErrors = { + [getMcpOperationKey('io.github.upstash/context7', 'project')]: 'Project failed', + }; + + await act(async () => { + root.render( + React.createElement(McpServerDetailDialog, { + server: makeServer(), + isInstalled: false, + installedEntry: null, + installedEntries: [], + diagnostic: null, + diagnosticsLoading: false, + projectPath: '/tmp/project', + open: true, + onClose: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + const installButton = host.querySelector('[data-testid="install-button"]') as HTMLButtonElement; + expect(installButton.dataset.state).toBe('success'); + expect(installButton.dataset.error ?? '').toBe(''); + + const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; + await act(async () => { + scopeSelect.value = 'project'; + scopeSelect.dispatchEvent(new Event('change', { bubbles: true })); + await Promise.resolve(); + }); + + expect(installButton.dataset.state).toBe('error'); + expect(installButton.dataset.error).toBe('Project failed'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/store/extensionsSlice.test.ts b/test/renderer/store/extensionsSlice.test.ts index 8e2a0c8b..16733b56 100644 --- a/test/renderer/store/extensionsSlice.test.ts +++ b/test/renderer/store/extensionsSlice.test.ts @@ -40,7 +40,10 @@ vi.mock('../../../src/renderer/api', () => ({ })); import { api } from '../../../src/renderer/api'; -import { getPluginOperationKey } from '../../../src/shared/utils/extensionNormalizers'; +import { + getMcpOperationKey, + getPluginOperationKey, +} from '../../../src/shared/utils/extensionNormalizers'; import type { EnrichedPlugin, @@ -136,6 +139,8 @@ const makeReadyCliStatus = () => ({ const pluginOperationKey = (pluginId: string, scope: 'user' | 'project' | 'local' = 'user') => getPluginOperationKey(pluginId, scope); +const mcpOperationKey = (registryId: string, scope: 'user' | 'project' | 'local' = 'user') => + getMcpOperationKey(registryId, scope); describe('extensionsSlice', () => { let store: TestStore; @@ -371,6 +376,43 @@ describe('extensionsSlice', () => { expect(store.getState().mcpInstalledServers).toEqual(installed); }); + + it('clears stale project- and local-scoped MCP operation state when project changes', async () => { + store.setState({ + mcpInstalledProjectPath: '/tmp/project-a', + mcpInstallProgress: { + [mcpOperationKey('project-server', 'project')]: 'error', + [mcpOperationKey('local-server', 'local')]: 'success', + [mcpOperationKey('user-server', 'user')]: 'pending', + }, + installErrors: { + [mcpOperationKey('project-server', 'project')]: 'Project failed', + [mcpOperationKey('local-server', 'local')]: 'Local failed', + [mcpOperationKey('user-server', 'user')]: 'Keep user state', + 'plugin:test@marketplace:user': 'Keep plugin state', + 'mcp-custom:custom-server:project': 'Clear custom project state', + }, + }); + (api.mcpRegistry!.getInstalled as ReturnType).mockResolvedValue([]); + + await store.getState().mcpFetchInstalled('/tmp/project-b'); + + expect(store.getState().mcpInstalledProjectPath).toBe('/tmp/project-b'); + expect(store.getState().mcpInstallProgress[mcpOperationKey('project-server', 'project')]).toBeUndefined(); + expect(store.getState().mcpInstallProgress[mcpOperationKey('local-server', 'local')]).toBeUndefined(); + expect(store.getState().mcpInstallProgress[mcpOperationKey('user-server', 'user')]).toBe( + 'pending', + ); + expect(store.getState().installErrors[mcpOperationKey('project-server', 'project')]).toBeUndefined(); + expect(store.getState().installErrors[mcpOperationKey('local-server', 'local')]).toBeUndefined(); + expect(store.getState().installErrors[mcpOperationKey('user-server', 'user')]).toBe( + 'Keep user state', + ); + expect(store.getState().installErrors['mcp-custom:custom-server:project']).toBeUndefined(); + expect(store.getState().installErrors['plugin:test@marketplace:user']).toBe( + 'Keep plugin state', + ); + }); }); describe('openExtensionsTab', () => { @@ -663,10 +705,44 @@ describe('extensionsSlice', () => { headers: [], }); - expect(store.getState().mcpInstallProgress['test-id']).toBe('pending'); + expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'user')]).toBe( + 'pending', + ); await promise; - expect(store.getState().mcpInstallProgress['test-id']).toBe('success'); + expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'user')]).toBe( + 'success', + ); + }); + + it('does not restore idle state after project switch clears a pending project-scope success timer', async () => { + vi.useFakeTimers(); + store.setState({ + mcpInstalledProjectPath: '/tmp/project-a', + }); + (api.mcpRegistry!.install as ReturnType).mockResolvedValue({ state: 'success' }); + (api.mcpRegistry!.getInstalled as ReturnType).mockResolvedValue([]); + (api.mcpRegistry!.diagnose as ReturnType).mockResolvedValue([]); + + await store.getState().installMcpServer({ + registryId: 'test-id', + serverName: 'test-server', + scope: 'project', + projectPath: '/tmp/project-a', + envValues: {}, + headers: [], + }); + + expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'project')]).toBe( + 'success', + ); + + await store.getState().mcpFetchInstalled('/tmp/project-b'); + expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'project')]).toBeUndefined(); + + await vi.advanceTimersByTimeAsync(2_000); + + expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'project')]).toBeUndefined(); }); }); @@ -678,10 +754,14 @@ describe('extensionsSlice', () => { const promise = store.getState().uninstallMcpServer('test-id', 'test-server', 'user'); - expect(store.getState().mcpInstallProgress['test-id']).toBe('pending'); + expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'user')]).toBe( + 'pending', + ); await promise; - expect(store.getState().mcpInstallProgress['test-id']).toBe('success'); + expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'user')]).toBe( + 'success', + ); }); }); diff --git a/test/shared/utils/extensionNormalizers.test.ts b/test/shared/utils/extensionNormalizers.test.ts index 6b4708fc..087d1f07 100644 --- a/test/shared/utils/extensionNormalizers.test.ts +++ b/test/shared/utils/extensionNormalizers.test.ts @@ -9,6 +9,7 @@ import { getCapabilityLabel, getInstallationSummaryLabel, getMcpInstallationSummaryLabel, + getMcpOperationKey, getPreferredMcpInstallationEntry, getPluginOperationKey, getPrimaryCapabilityLabel, @@ -163,6 +164,14 @@ describe('getPluginOperationKey', () => { }); }); +describe('getMcpOperationKey', () => { + it('namespaces MCP operation keys by scope', () => { + expect(getMcpOperationKey('io.github.upstash/context7', 'project')).toBe( + 'mcp:io.github.upstash/context7:project', + ); + }); +}); + describe('hasInstallationInScope', () => { it('returns true when the selected scope exists', () => { expect( From c02a12b3f66049836342e749248a25cefad4c173 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 23:01:33 +0300 Subject: [PATCH 074/121] fix(extensions): keep skill import destination in sync --- .../extensions/skills/SkillImportDialog.tsx | 29 ++- .../skills/SkillImportDialog.test.ts | 237 ++++++++++++++++++ 2 files changed, 261 insertions(+), 5 deletions(-) create mode 100644 test/renderer/components/extensions/skills/SkillImportDialog.test.ts diff --git a/src/renderer/components/extensions/skills/SkillImportDialog.tsx b/src/renderer/components/extensions/skills/SkillImportDialog.tsx index 507b4f8c..4697dae0 100644 --- a/src/renderer/components/extensions/skills/SkillImportDialog.tsx +++ b/src/renderer/components/extensions/skills/SkillImportDialog.tsx @@ -56,6 +56,11 @@ interface SkillImportDialogProps { onImported: (skillId: string | null) => void; } +function getFolderNameFromPath(value: string): string { + const segments = value.split(/[\\/]/u).filter(Boolean); + return segments.at(-1) ?? ''; +} + export const SkillImportDialog = ({ open, projectPath, @@ -68,6 +73,7 @@ export const SkillImportDialog = ({ const [sourceDir, setSourceDir] = useState(''); const [folderName, setFolderName] = useState(''); + const [folderNameEdited, setFolderNameEdited] = useState(false); const [scope, setScope] = useState<'user' | 'project'>('user'); const [rootKind, setRootKind] = useState<'claude' | 'cursor' | 'agents'>('claude'); const [preview, setPreview] = useState(null); @@ -80,6 +86,7 @@ export const SkillImportDialog = ({ if (!open) return; setSourceDir(''); setFolderName(''); + setFolderNameEdited(false); setScope(projectPath ? 'project' : 'user'); setRootKind('claude'); setPreview(null); @@ -89,15 +96,24 @@ export const SkillImportDialog = ({ setMutationError(null); }, [open, projectPath]); + useEffect(() => { + if (!open || folderNameEdited) { + return; + } + setFolderName(getFolderNameFromPath(sourceDir)); + }, [folderNameEdited, open, sourceDir]); + + useEffect(() => { + if (open && scope === 'project' && !projectPath) { + setScope('user'); + } + }, [open, projectPath, scope]); + async function handleChooseFolder(): Promise { const selected = await api.config.selectFolders(); const first = selected[0]; if (!first) return; setSourceDir(first); - if (!folderName) { - const segments = first.split(/[\\/]/u).filter(Boolean); - setFolderName(segments.at(-1) ?? ''); - } } async function handleReview(): Promise { @@ -190,7 +206,10 @@ export const SkillImportDialog = ({ setFolderName(event.target.value)} + onChange={(event) => { + setFolderNameEdited(true); + setFolderName(event.target.value); + }} placeholder="Defaults to source folder name" />
diff --git a/test/renderer/components/extensions/skills/SkillImportDialog.test.ts b/test/renderer/components/extensions/skills/SkillImportDialog.test.ts new file mode 100644 index 00000000..d1be6e25 --- /dev/null +++ b/test/renderer/components/extensions/skills/SkillImportDialog.test.ts @@ -0,0 +1,237 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +interface StoreState { + previewSkillImport: ReturnType; + applySkillImport: ReturnType; +} + +const storeState = {} as StoreState; +const selectFoldersMock = vi.fn(); + +vi.mock('@renderer/store', () => ({ + useStore: (selector: (state: StoreState) => unknown) => selector(storeState), +})); + +vi.mock('@renderer/api', () => ({ + api: { + config: { + selectFolders: (...args: unknown[]) => selectFoldersMock(...args), + }, + }, +})); + +vi.mock('@renderer/components/ui/button', () => ({ + Button: ({ + children, + onClick, + type = 'button', + disabled, + }: React.PropsWithChildren<{ + onClick?: () => void; + type?: 'button' | 'submit' | 'reset'; + disabled?: boolean; + }>) => + React.createElement( + 'button', + { + type, + disabled, + onClick, + }, + children + ), +})); + +vi.mock('@renderer/components/ui/dialog', () => ({ + Dialog: ({ open, children }: React.PropsWithChildren<{ open: boolean }>) => + open ? React.createElement('div', null, children) : null, + DialogContent: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children), + DialogDescription: ({ children }: React.PropsWithChildren) => + React.createElement('p', null, children), + DialogFooter: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children), + DialogHeader: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children), + DialogTitle: ({ children }: React.PropsWithChildren) => React.createElement('h2', null, children), +})); + +vi.mock('@renderer/components/ui/input', () => ({ + Input: (props: React.InputHTMLAttributes) => + React.createElement('input', props), +})); + +vi.mock('@renderer/components/ui/label', () => ({ + Label: ({ children, htmlFor }: React.PropsWithChildren<{ htmlFor?: string }>) => + React.createElement('label', { htmlFor }, children), +})); + +vi.mock('@renderer/components/ui/select', () => ({ + Select: ({ + children, + value, + onValueChange, + }: React.PropsWithChildren<{ value: string; onValueChange: (value: string) => void }>) => + React.createElement( + 'select', + { + 'data-testid': 'select', + value, + onChange: (event: React.ChangeEvent) => onValueChange(event.target.value), + }, + children + ), + SelectTrigger: () => null, + SelectValue: () => null, + SelectContent: ({ children }: React.PropsWithChildren) => + React.createElement(React.Fragment, null, children), + SelectItem: ({ + children, + value, + disabled, + }: React.PropsWithChildren<{ value: string; disabled?: boolean }>) => + React.createElement('option', { value, disabled }, children), +})); + +vi.mock('@renderer/components/extensions/skills/SkillReviewDialog', () => ({ + SkillReviewDialog: () => null, +})); + +vi.mock('lucide-react', () => { + const Icon = (props: React.SVGProps) => React.createElement('svg', props); + return { + FileSearch: Icon, + FolderOpen: Icon, + X: Icon, + }; +}); + +import { SkillImportDialog } from '@renderer/components/extensions/skills/SkillImportDialog'; + +describe('SkillImportDialog', () => { + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.previewSkillImport = vi.fn(); + storeState.applySkillImport = vi.fn(); + selectFoldersMock.mockReset(); + }); + + afterEach(() => { + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + }); + + it('keeps destination folder name synced with the chosen source until edited manually', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + selectFoldersMock + .mockResolvedValueOnce(['/tmp/first-skill']) + .mockResolvedValueOnce(['/tmp/second-skill']) + .mockResolvedValueOnce(['/tmp/third-skill']); + + await act(async () => { + root.render( + React.createElement(SkillImportDialog, { + open: true, + projectPath: null, + projectLabel: null, + onClose: vi.fn(), + onImported: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + const browseButton = Array.from(host.querySelectorAll('button')).find( + (button) => button.textContent?.includes('Browse') + ) as HTMLButtonElement; + const sourceInput = host.querySelector('#skill-import-source') as HTMLInputElement; + const folderInput = host.querySelector('#skill-import-folder') as HTMLInputElement; + + await act(async () => { + browseButton.click(); + await Promise.resolve(); + }); + + expect(sourceInput.value).toBe('/tmp/first-skill'); + expect(folderInput.value).toBe('first-skill'); + + await act(async () => { + browseButton.click(); + await Promise.resolve(); + }); + + expect(sourceInput.value).toBe('/tmp/second-skill'); + expect(folderInput.value).toBe('second-skill'); + + await act(async () => { + const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; + setValue?.call(folderInput, 'custom-name'); + folderInput.dispatchEvent(new Event('input', { bubbles: true })); + await Promise.resolve(); + }); + + expect(folderInput.value).toBe('custom-name'); + + await act(async () => { + browseButton.click(); + await Promise.resolve(); + }); + + expect(sourceInput.value).toBe('/tmp/third-skill'); + expect(folderInput.value).toBe('custom-name'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('falls back to user scope when the project context disappears mid-dialog', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(SkillImportDialog, { + open: true, + projectPath: '/tmp/project-a', + projectLabel: 'Project A', + onClose: vi.fn(), + onImported: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + const scopeSelect = host.querySelectorAll('select')[0] as HTMLSelectElement; + expect(scopeSelect.value).toBe('project'); + + await act(async () => { + root.render( + React.createElement(SkillImportDialog, { + open: true, + projectPath: null, + projectLabel: null, + onClose: vi.fn(), + onImported: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + const updatedScopeSelect = host.querySelectorAll('select')[0] as HTMLSelectElement; + expect(updatedScopeSelect.value).toBe('user'); + const projectOption = Array.from(updatedScopeSelect.options).find( + (option) => option.value === 'project' + ) as HTMLOptionElement; + expect(projectOption.disabled).toBe(true); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +}); From 3e5ec7c173b28829bbf6ff06dfc1ecf7d52896ef Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 23:03:55 +0300 Subject: [PATCH 075/121] fix(extensions): preserve skill project context in dialogs --- .../extensions/skills/SkillDetailDialog.tsx | 20 +- .../extensions/skills/SkillEditorDialog.tsx | 23 +- .../extensions/skills/SkillImportDialog.tsx | 5 +- .../extensions/skills/skillProjectUtils.ts | 13 + .../skills/SkillDetailDialog.test.ts | 280 ++++++++++++++++++ .../skills/skillProjectUtils.test.ts | 19 ++ 6 files changed, 350 insertions(+), 10 deletions(-) create mode 100644 src/renderer/components/extensions/skills/skillProjectUtils.ts create mode 100644 test/renderer/components/extensions/skills/SkillDetailDialog.test.ts create mode 100644 test/renderer/components/extensions/skills/skillProjectUtils.test.ts diff --git a/src/renderer/components/extensions/skills/SkillDetailDialog.tsx b/src/renderer/components/extensions/skills/SkillDetailDialog.tsx index d70238d6..2b57a9ca 100644 --- a/src/renderer/components/extensions/skills/SkillDetailDialog.tsx +++ b/src/renderer/components/extensions/skills/SkillDetailDialog.tsx @@ -26,6 +26,8 @@ import { useStore } from '@renderer/store'; import { AlertTriangle, ExternalLink, FolderOpen, Pencil, Trash2 } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; +import { resolveSkillProjectPath } from './skillProjectUtils'; + interface SkillDetailDialogProps { skillId: string | null; open: boolean; @@ -58,8 +60,13 @@ export const SkillDetailDialog = ({ useEffect(() => { if (!open || !skillId) return; - void fetchSkillDetail(skillId, projectPath ?? undefined).catch(() => undefined); - }, [fetchSkillDetail, open, projectPath, skillId]); + void fetchSkillDetail( + skillId, + detail?.item.scope + ? resolveSkillProjectPath(detail.item.scope, projectPath, detail.item.projectRoot) + : (projectPath ?? undefined) + ).catch(() => undefined); + }, [detail?.item.projectRoot, detail?.item.scope, fetchSkillDetail, open, projectPath, skillId]); useEffect(() => { if (!open) { @@ -70,6 +77,9 @@ export const SkillDetailDialog = ({ }, [open]); const item = detail?.item; + const effectiveProjectPath = item + ? resolveSkillProjectPath(item.scope, projectPath, item.projectRoot) + : (projectPath ?? undefined); function formatRootKind(rootKind: 'claude' | 'cursor' | 'agents'): string { return `.${rootKind}`; @@ -92,7 +102,7 @@ export const SkillDetailDialog = ({ try { await deleteSkill({ skillId: item.id, - projectPath: projectPath ?? undefined, + projectPath: effectiveProjectPath, }); setDeleteConfirmOpen(false); onDeleted(); @@ -125,7 +135,7 @@ export const SkillDetailDialog = ({ variant="outline" size="sm" onClick={() => { - void fetchSkillDetail(skillId, projectPath ?? undefined).catch(() => undefined); + void fetchSkillDetail(skillId, effectiveProjectPath).catch(() => undefined); }} > Retry @@ -288,7 +298,7 @@ export const SkillDetailDialog = ({
@@ -338,6 +362,8 @@ export const SkillsPanel = ({ ['all', 'All skills'], ['project', 'Project'], ['personal', 'Personal'], + ['shared', 'Shared'], + ['codex-only', 'Codex only'], ['needs-attention', 'Needs attention'], ['has-scripts', 'Has scripts'], ] as [SkillsQuickFilter, string][] @@ -449,7 +475,10 @@ export const SkillsPanel = ({
- Stored in {formatRootKind(skill.rootKind)} + Stored in {formatSkillRootKind(skill.rootKind)} + + + {getSkillAudienceLabel(skill.rootKind)} {skill.flags.hasScripts && ( @@ -532,7 +561,10 @@ export const SkillsPanel = ({
- Stored in {formatRootKind(skill.rootKind)} + Stored in {formatSkillRootKind(skill.rootKind)} + + + {getSkillAudienceLabel(skill.rootKind)} {skill.flags.hasScripts && ( diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index eff3ceea..c461aa94 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -237,7 +237,8 @@ export const MessageComposer = ({ buildSlashCommandSuggestions( getSuggestedSlashCommandsForProvider(leadProviderId), projectSkills, - userSkills + userSkills, + leadProviderId ), [leadProviderId, projectSkills, userSkills] ); diff --git a/src/renderer/utils/skillCommandSuggestions.ts b/src/renderer/utils/skillCommandSuggestions.ts index 370df4e9..63bc0b43 100644 --- a/src/renderer/utils/skillCommandSuggestions.ts +++ b/src/renderer/utils/skillCommandSuggestions.ts @@ -1,13 +1,41 @@ +import { getSkillAudienceLabel, isSkillAvailableForProvider } from '@shared/utils/skillRoots'; import { isSupportedSlashCommandName } from '@shared/utils/slashCommands'; import type { MentionSuggestion } from '@renderer/types/mention'; import type { SkillCatalogItem } from '@shared/types/extensions'; +import type { TeamProviderId } from '@shared/types'; import type { KnownSlashCommandDefinition } from '@shared/utils/slashCommands'; +function orderSkillsForProvider( + projectSkills: readonly SkillCatalogItem[], + userSkills: readonly SkillCatalogItem[], + providerId?: TeamProviderId +): SkillCatalogItem[] { + const visibleProjectSkills = projectSkills.filter((skill) => + isSkillAvailableForProvider(skill.rootKind, providerId) + ); + const visibleUserSkills = userSkills.filter((skill) => + isSkillAvailableForProvider(skill.rootKind, providerId) + ); + + if (providerId !== 'codex') { + return [...visibleProjectSkills, ...visibleUserSkills]; + } + + const isCodexOnly = (skill: SkillCatalogItem) => skill.rootKind === 'codex'; + return [ + ...visibleProjectSkills.filter(isCodexOnly), + ...visibleProjectSkills.filter((skill) => !isCodexOnly(skill)), + ...visibleUserSkills.filter(isCodexOnly), + ...visibleUserSkills.filter((skill) => !isCodexOnly(skill)), + ]; +} + export function buildSlashCommandSuggestions( builtIns: readonly KnownSlashCommandDefinition[], projectSkills: readonly SkillCatalogItem[], - userSkills: readonly SkillCatalogItem[] + userSkills: readonly SkillCatalogItem[], + providerId?: TeamProviderId ): MentionSuggestion[] { const builtInNames = new Set(builtIns.map((command) => command.name.trim().toLowerCase())); const builtInSuggestions: MentionSuggestion[] = builtIns.map((command) => ({ @@ -21,7 +49,7 @@ export function buildSlashCommandSuggestions( const seenSkillNames = new Set(); const skillSuggestions: MentionSuggestion[] = []; - for (const skill of [...projectSkills, ...userSkills]) { + for (const skill of orderSkillsForProvider(projectSkills, userSkills, providerId)) { const normalizedFolderName = skill.folderName.trim().toLowerCase(); if ( !skill.isValid || @@ -39,7 +67,7 @@ export function buildSlashCommandSuggestions( name: skill.folderName, command: `/${normalizedFolderName}`, description: skill.description, - subtitle: skill.scope === 'project' ? 'Project skill' : 'Personal skill', + subtitle: `${skill.scope === 'project' ? 'Project skill' : 'Personal skill'} - ${getSkillAudienceLabel(skill.rootKind)}`, searchText: `${skill.name} ${skill.folderName}`, type: 'skill', }); diff --git a/src/shared/types/extensions/skill.ts b/src/shared/types/extensions/skill.ts index 5921acc7..9aca8729 100644 --- a/src/shared/types/extensions/skill.ts +++ b/src/shared/types/extensions/skill.ts @@ -4,7 +4,7 @@ export type SkillScope = 'user' | 'project'; -export type SkillRootKind = 'claude' | 'cursor' | 'agents'; +export type SkillRootKind = 'claude' | 'cursor' | 'agents' | 'codex'; export type SkillSourceType = 'filesystem'; diff --git a/src/shared/utils/skillRoots.ts b/src/shared/utils/skillRoots.ts new file mode 100644 index 00000000..e2f84a42 --- /dev/null +++ b/src/shared/utils/skillRoots.ts @@ -0,0 +1,61 @@ +import type { TeamProviderId } from '@shared/types'; +import type { SkillRootKind } from '@shared/types/extensions'; + +export type SkillAudience = 'shared' | 'codex'; + +export interface SkillRootDefinition { + rootKind: SkillRootKind; + directoryName: `.${string}`; + segments: [string, 'skills']; + audience: SkillAudience; +} + +export const SKILL_ROOT_DEFINITIONS: readonly SkillRootDefinition[] = [ + { + rootKind: 'claude', + directoryName: '.claude', + segments: ['.claude', 'skills'], + audience: 'shared', + }, + { + rootKind: 'cursor', + directoryName: '.cursor', + segments: ['.cursor', 'skills'], + audience: 'shared', + }, + { + rootKind: 'agents', + directoryName: '.agents', + segments: ['.agents', 'skills'], + audience: 'shared', + }, + { + rootKind: 'codex', + directoryName: '.codex', + segments: ['.codex', 'skills'], + audience: 'codex', + }, +] as const; + +export function getSkillRootDefinition(rootKind: SkillRootKind): SkillRootDefinition { + return SKILL_ROOT_DEFINITIONS.find((definition) => definition.rootKind === rootKind)!; +} + +export function formatSkillRootKind(rootKind: SkillRootKind): string { + return getSkillRootDefinition(rootKind).directoryName; +} + +export function getSkillAudience(rootKind: SkillRootKind): SkillAudience { + return getSkillRootDefinition(rootKind).audience; +} + +export function getSkillAudienceLabel(rootKind: SkillRootKind): string { + return getSkillAudience(rootKind) === 'codex' ? 'Codex only' : 'Shared'; +} + +export function isSkillAvailableForProvider( + rootKind: SkillRootKind, + providerId?: TeamProviderId +): boolean { + return getSkillAudience(rootKind) === 'shared' || providerId === 'codex'; +} diff --git a/test/main/services/extensions/SkillRootsResolver.test.ts b/test/main/services/extensions/SkillRootsResolver.test.ts index 2c87294a..db7f60ca 100644 --- a/test/main/services/extensions/SkillRootsResolver.test.ts +++ b/test/main/services/extensions/SkillRootsResolver.test.ts @@ -8,9 +8,9 @@ describe('SkillRootsResolver', () => { const roots = resolver.resolve(); - expect(roots).toHaveLength(3); + expect(roots).toHaveLength(4); expect(roots.every((root) => root.scope === 'user')).toBe(true); - expect(roots.map((root) => root.rootKind)).toEqual(['claude', 'cursor', 'agents']); + expect(roots.map((root) => root.rootKind)).toEqual(['claude', 'cursor', 'agents', 'codex']); }); it('returns project and user roots when project path is provided', () => { @@ -18,8 +18,8 @@ describe('SkillRootsResolver', () => { const roots = resolver.resolve('/tmp/demo-project'); - expect(roots).toHaveLength(6); - expect(roots.filter((root) => root.scope === 'project')).toHaveLength(3); - expect(roots.filter((root) => root.scope === 'user')).toHaveLength(3); + expect(roots).toHaveLength(8); + expect(roots.filter((root) => root.scope === 'project')).toHaveLength(4); + expect(roots.filter((root) => root.scope === 'user')).toHaveLength(4); }); }); diff --git a/test/main/services/extensions/SkillValidator.test.ts b/test/main/services/extensions/SkillValidator.test.ts index 513f6959..0fc22594 100644 --- a/test/main/services/extensions/SkillValidator.test.ts +++ b/test/main/services/extensions/SkillValidator.test.ts @@ -40,6 +40,18 @@ describe('SkillValidator', () => { expect(result[1].issues.map((issue) => issue.code)).toContain('duplicate-name'); }); + it('does not warn when shared and codex-only overlays reuse the same skill name', () => { + const validator = new SkillValidator(); + + const result = validator.annotateCatalog([ + makeSkill({ id: '/a', scope: 'project', rootKind: 'claude' }), + makeSkill({ id: '/b', scope: 'project', rootKind: 'codex' }), + ]); + + expect(result[0].issues.map((issue) => issue.code)).not.toContain('duplicate-name'); + expect(result[1].issues.map((issue) => issue.code)).not.toContain('duplicate-name'); + }); + it('sorts by validity, scope, root precedence, then name', () => { const validator = new SkillValidator(); @@ -47,6 +59,7 @@ describe('SkillValidator', () => { makeSkill({ id: '/3', name: 'z-user', scope: 'user', rootKind: 'claude' }), makeSkill({ id: '/2', name: 'b-project-cursor', scope: 'project', rootKind: 'cursor' }), makeSkill({ id: '/1', name: 'a-project-claude', scope: 'project', rootKind: 'claude' }), + makeSkill({ id: '/5', name: 'c-project-codex', scope: 'project', rootKind: 'codex' }), makeSkill({ id: '/4', name: 'invalid', @@ -55,6 +68,6 @@ describe('SkillValidator', () => { }), ]); - expect(result.map((item) => item.id)).toEqual(['/1', '/2', '/3', '/4']); + expect(result.map((item) => item.id)).toEqual(['/1', '/2', '/5', '/3', '/4']); }); }); diff --git a/test/renderer/utils/skillCommandSuggestions.test.ts b/test/renderer/utils/skillCommandSuggestions.test.ts index 528a2f2d..43384c6e 100644 --- a/test/renderer/utils/skillCommandSuggestions.test.ts +++ b/test/renderer/utils/skillCommandSuggestions.test.ts @@ -41,7 +41,7 @@ describe('buildSlashCommandSuggestions', () => { { name: 'review-skill', command: '/review-skill', - subtitle: 'Project skill', + subtitle: 'Project skill - Shared', type: 'skill', } ); @@ -76,6 +76,46 @@ describe('buildSlashCommandSuggestions', () => { ); }); + it('hides codex-only skills when the provider is not codex', () => { + const suggestions = buildSlashCommandSuggestions( + KNOWN_SLASH_COMMANDS, + [createSkill({ id: 'codex-project', folderName: 'codex-skill', rootKind: 'codex' })], + [], + 'anthropic' + ); + + expect(suggestions.find((suggestion) => suggestion.id === 'skill:codex-project')).toBeUndefined(); + }); + + it('prefers codex-only overlays ahead of shared skills for codex teams', () => { + const suggestions = buildSlashCommandSuggestions( + KNOWN_SLASH_COMMANDS, + [ + createSkill({ + id: 'shared-project', + folderName: 'review-skill', + rootKind: 'claude', + scope: 'project', + }), + createSkill({ + id: 'codex-project', + folderName: 'review-skill', + rootKind: 'codex', + scope: 'project', + }), + ], + [], + 'codex' + ); + + expect(suggestions.filter((suggestion) => suggestion.command === '/review-skill')).toHaveLength( + 1 + ); + expect(suggestions.find((suggestion) => suggestion.command === '/review-skill')?.id).toBe( + 'skill:codex-project' + ); + }); + it('uses the provided built-in set when filtering skill collisions', () => { const suggestions = buildSlashCommandSuggestions( [ From 22209ba95870df74dc3d1824d4d79a08316d645f Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 17 Apr 2026 13:23:30 +0300 Subject: [PATCH 088/121] feat(extensions): support multimodel global mcp scope --- .../extensions/install/McpInstallService.ts | 9 ++-- .../extensions/runtime/mcpRuntimeJson.ts | 8 ++- .../extensions/mcp/CustomMcpServerDialog.tsx | 40 ++++++++------ .../extensions/mcp/McpServerCard.tsx | 19 ++++--- .../extensions/mcp/McpServerDetailDialog.tsx | 52 ++++++++++++------- src/renderer/store/slices/extensionsSlice.ts | 4 +- src/shared/types/extensions/common.ts | 2 +- src/shared/types/extensions/mcp.ts | 4 +- src/shared/utils/extensionNormalizers.ts | 2 + src/shared/utils/mcpScopes.ts | 32 ++++++++++++ .../mcp/CustomMcpServerDialog.test.ts | 28 ++++++++++ .../extensions/mcp/McpServerCard.test.ts | 35 +++++++++++++ .../mcp/McpServerDetailDialog.test.ts | 34 ++++++++++++ .../shared/utils/extensionNormalizers.test.ts | 4 ++ 14 files changed, 219 insertions(+), 54 deletions(-) create mode 100644 src/shared/utils/mcpScopes.ts diff --git a/src/main/services/extensions/install/McpInstallService.ts b/src/main/services/extensions/install/McpInstallService.ts index 70daa65e..31e89219 100644 --- a/src/main/services/extensions/install/McpInstallService.ts +++ b/src/main/services/extensions/install/McpInstallService.ts @@ -11,6 +11,7 @@ import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; import { execCli } from '@main/utils/childProcess'; 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'; @@ -29,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; @@ -40,7 +41,7 @@ const HEADER_KEY_RE = /^[A-Za-z][\w-]{0,100}$/; const TIMEOUT_MS = 30_000; function scopeRequiresProjectPath(scope?: string): boolean { - return scope === 'local' || scope === 'project'; + return isProjectScopedMcpScope(scope); } export class McpInstallService { @@ -64,7 +65,7 @@ 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.`, }; } @@ -337,7 +338,7 @@ 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.`, }; } diff --git a/src/main/services/extensions/runtime/mcpRuntimeJson.ts b/src/main/services/extensions/runtime/mcpRuntimeJson.ts index f6a52f45..858f532c 100644 --- a/src/main/services/extensions/runtime/mcpRuntimeJson.ts +++ b/src/main/services/extensions/runtime/mcpRuntimeJson.ts @@ -1,3 +1,5 @@ +import { isInstalledMcpScope } from '@shared/utils/mcpScopes'; + import type { InstalledMcpEntry } from '@shared/types/extensions'; interface McpListJsonServer { @@ -24,15 +26,11 @@ function extractJsonObject(raw: string): T { } } -function isSupportedScope(scope: unknown): scope is InstalledMcpEntry['scope'] { - return scope === 'user' || scope === 'project' || scope === 'local'; -} - export function parseInstalledMcpJsonOutput(output: string): InstalledMcpEntry[] { const parsed = extractJsonObject(output); return (parsed.servers ?? []).flatMap((entry) => { - if (typeof entry.name !== 'string' || !isSupportedScope(entry.scope)) { + if (typeof entry.name !== 'string' || !isInstalledMcpScope(entry.scope)) { return []; } diff --git a/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx b/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx index 7cb8e740..59c28f6f 100644 --- a/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx +++ b/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx @@ -24,6 +24,11 @@ import { SelectValue, } from '@renderer/components/ui/select'; import { useStore } from '@renderer/store'; +import { + getDefaultMcpSharedScope, + getMcpScopeLabel, + isProjectScopedMcpScope, +} from '@shared/utils/mcpScopes'; import { Plus, Server, Trash2 } from 'lucide-react'; import type { @@ -42,13 +47,7 @@ interface CustomMcpServerDialogProps { type TransportMode = 'stdio' | 'http'; type HttpTransport = 'streamable-http' | 'sse' | 'http'; -type Scope = 'local' | 'user' | 'project'; - -const SCOPE_OPTIONS: { value: Scope; label: string }[] = [ - { value: 'user', label: 'User (global)' }, - { value: 'project', label: 'Project' }, - { value: 'local', label: 'Local' }, -]; +type Scope = 'local' | 'user' | 'project' | 'global'; const HTTP_TRANSPORT_OPTIONS: { value: HttpTransport; label: string }[] = [ { value: 'streamable-http', label: 'Streamable HTTP' }, @@ -67,11 +66,18 @@ export const CustomMcpServerDialog = ({ projectPath, }: CustomMcpServerDialogProps): React.JSX.Element => { const installCustomMcpServer = useStore((s) => s.installCustomMcpServer); + const cliStatus = useStore((s) => s.cliStatus); + 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(''); @@ -92,7 +98,7 @@ export const CustomMcpServerDialog = ({ if (open) { setServerName(''); setTransportMode('stdio'); - setScope('user'); + setScope(defaultSharedScope); setNpmPackage(''); setNpmVersion(''); setHttpUrl(''); @@ -102,13 +108,13 @@ export const CustomMcpServerDialog = ({ setError(null); setInstalling(false); } - }, [open]); + }, [defaultSharedScope, open]); useEffect(() => { - if (open && scope !== 'user' && !projectPath) { - setScope('user'); + if (open && isProjectScopedMcpScope(scope) && !projectPath) { + setScope(defaultSharedScope); } - }, [open, projectPath, scope]); + }, [defaultSharedScope, open, projectPath, scope]); // Auto-fill env vars from saved API keys useEffect(() => { @@ -177,7 +183,7 @@ export const CustomMcpServerDialog = ({ const request: McpCustomInstallRequest = { serverName, scope, - projectPath: scope !== 'user' ? (projectPath ?? undefined) : undefined, + projectPath: isProjectScopedMcpScope(scope) ? (projectPath ?? undefined) : undefined, installSpec, envValues, headers: headers.filter((h) => h.key.trim() && h.value.trim()), @@ -207,7 +213,7 @@ export const CustomMcpServerDialog = ({ const canSubmit = serverName.trim() && (transportMode === 'stdio' ? npmPackage.trim() : httpUrl.trim()) && - !(scope !== 'user' && !projectPath) && + !(isProjectScopedMcpScope(scope) && !projectPath) && !installing; return ( @@ -382,11 +388,11 @@ export const CustomMcpServerDialog = ({ - {SCOPE_OPTIONS.map((opt) => ( + {scopeOptions.map((opt) => ( {opt.label} diff --git a/src/renderer/components/extensions/mcp/McpServerCard.tsx b/src/renderer/components/extensions/mcp/McpServerCard.tsx index 4f58b343..90a34c4c 100644 --- a/src/renderer/components/extensions/mcp/McpServerCard.tsx +++ b/src/renderer/components/extensions/mcp/McpServerCard.tsx @@ -11,6 +11,7 @@ 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 { getDefaultMcpSharedScope } from '@shared/utils/mcpScopes'; import { getMcpInstallationSummaryLabel, getMcpOperationKey, @@ -47,7 +48,9 @@ export const McpServerCard = ({ diagnosticsLoading, onClick, }: McpServerCardProps): React.JSX.Element => { - const operationKey = getMcpOperationKey(server.id, 'user'); + 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); @@ -67,13 +70,13 @@ export const McpServerCard = ({ server.requiresAuth || (server.authHeaders?.length ?? 0) > 0; const defaultServerName = sanitizeMcpServerName(server.name); - const userInstallEntry = - normalizedInstalledEntries.find((entry) => entry.scope === 'user') ?? null; + const sharedInstallEntry = + normalizedInstalledEntries.find((entry) => entry.scope === sharedScope) ?? null; const installSummaryLabel = getMcpInstallationSummaryLabel(normalizedInstalledEntries); const supportsDirectInstalledAction = isInstalled && normalizedInstalledEntries.length === 1 && - userInstallEntry?.name === defaultServerName && + sharedInstallEntry?.name === defaultServerName && !requiresConfiguration; const shouldShowDirectInstallButton = canAutoInstall && (!isInstalled ? !requiresConfiguration : supportsDirectInstalledAction); @@ -263,13 +266,17 @@ export const McpServerCard = ({ installMcpServer({ registryId: server.id, serverName: defaultServerName, - scope: 'user', + scope: sharedScope, envValues: {}, headers: [], }) } onUninstall={() => - uninstallMcpServer(server.id, userInstallEntry?.name ?? defaultServerName, 'user') + uninstallMcpServer( + server.id, + sharedInstallEntry?.name ?? defaultServerName, + sharedScope + ) } size="sm" errorMessage={installError} diff --git a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx index 412be5a8..c6515f8d 100644 --- a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx +++ b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx @@ -25,6 +25,11 @@ import { SelectValue, } from '@renderer/components/ui/select'; import { useStore } from '@renderer/store'; +import { + getDefaultMcpSharedScope, + getMcpScopeLabel, + isProjectScopedMcpScope, +} from '@shared/utils/mcpScopes'; import { getMcpInstallationSummaryLabel, getMcpOperationKey, @@ -55,13 +60,7 @@ interface McpServerDetailDialogProps { onClose: () => void; } -type Scope = 'local' | 'user' | 'project'; - -const SCOPE_OPTIONS: { value: Scope; label: string }[] = [ - { value: 'user', label: 'User (global)' }, - { value: 'project', label: 'Project' }, - { value: 'local', label: 'Local' }, -]; +type Scope = 'local' | 'user' | 'project' | 'global'; export const McpServerDetailDialog = ({ server, @@ -74,7 +73,9 @@ export const McpServerDetailDialog = ({ open, onClose, }: McpServerDetailDialogProps): React.JSX.Element => { - const [scope, setScope] = useState('user'); + const cliStatus = useStore((s) => s.cliStatus); + const defaultSharedScope = getDefaultMcpSharedScope(cliStatus?.flavor); + const [scope, setScope] = useState(defaultSharedScope); const operationKey = server ? getMcpOperationKey(server.id, scope) : null; const installProgress = useStore( (s) => (operationKey ? s.mcpInstallProgress[operationKey] : undefined) ?? 'idle' @@ -96,6 +97,15 @@ export const McpServerDetailDialog = ({ : installedEntry ? [installedEntry] : []; + const scopeOptions: { value: Scope; label: string }[] = [ + { value: defaultSharedScope, label: getMcpScopeLabel(defaultSharedScope, cliStatus?.flavor) }, + ...(defaultSharedScope !== 'user' && + normalizedInstalledEntries.some((entry) => entry.scope === 'user') + ? [{ value: 'user' as const, label: getMcpScopeLabel('user', cliStatus?.flavor) }] + : []), + { value: 'project', label: 'Project' }, + { value: 'local', label: 'Local' }, + ]; const preferredInstalledEntry = getPreferredMcpInstallationEntry(normalizedInstalledEntries); const selectedInstalledEntry = normalizedInstalledEntries.find((entry) => entry.scope === scope) ?? null; @@ -120,10 +130,16 @@ export const McpServerDetailDialog = ({ })) ); setServerName(preferredInstalledEntry?.name ?? sanitizeMcpServerName(server.name)); - setScope(preferredInstalledEntry?.scope ?? 'user'); + setScope((preferredInstalledEntry?.scope as Scope | undefined) ?? defaultSharedScope); setImgError(false); setAutoFilledFields(new Set()); - }, [open, preferredInstalledEntry?.name, preferredInstalledEntry?.scope, server?.id]); + }, [ + defaultSharedScope, + open, + preferredInstalledEntry?.name, + preferredInstalledEntry?.scope, + server?.id, + ]); useEffect(() => { if (!server || !open) { @@ -134,10 +150,10 @@ export const McpServerDetailDialog = ({ }, [open, scope, selectedInstalledEntry?.name, server]); useEffect(() => { - if (open && scope !== 'user' && !projectPath) { - setScope('user'); + if (open && isProjectScopedMcpScope(scope) && !projectPath) { + setScope(defaultSharedScope); } - }, [open, projectPath, scope]); + }, [defaultSharedScope, open, projectPath, scope]); // Auto-fill env values from saved API keys useEffect(() => { @@ -181,7 +197,7 @@ export const McpServerDetailDialog = ({ const isInstalledForScope = selectedInstalledEntry !== null; const uninstallServerName = selectedInstalledEntry?.name ?? serverName; const uninstallScope = selectedInstalledEntry?.scope ?? scope; - const scopeRequiresProjectPath = scope !== 'user' && !projectPath; + const scopeRequiresProjectPath = isProjectScopedMcpScope(scope) && !projectPath; const installDisabled = !serverName.trim() || missingRequiredEnvVars || @@ -201,7 +217,7 @@ export const McpServerDetailDialog = ({ registryId: server.id, serverName, scope, - projectPath: scope !== 'user' ? (projectPath ?? undefined) : undefined, + projectPath: isProjectScopedMcpScope(scope) ? (projectPath ?? undefined) : undefined, envValues, headers, }); @@ -212,7 +228,7 @@ export const McpServerDetailDialog = ({ server.id, uninstallServerName, uninstallScope, - uninstallScope !== 'user' ? (projectPath ?? undefined) : undefined + isProjectScopedMcpScope(uninstallScope) ? (projectPath ?? undefined) : undefined ); }; @@ -415,11 +431,11 @@ export const McpServerDetailDialog = ({ - {SCOPE_OPTIONS.map((opt) => ( + {scopeOptions.map((opt) => ( {opt.label} diff --git a/src/renderer/store/slices/extensionsSlice.ts b/src/renderer/store/slices/extensionsSlice.ts index e1ed7a2d..c73b6b70 100644 --- a/src/renderer/store/slices/extensionsSlice.ts +++ b/src/renderer/store/slices/extensionsSlice.ts @@ -5,6 +5,7 @@ import { api } from '@renderer/api'; import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli'; +import { isProjectScopedMcpScope } from '@shared/utils/mcpScopes'; import { getMcpOperationKey, getPluginOperationKey } from '@shared/utils/extensionNormalizers'; import { findPaneByTabId, updatePane } from '../utils/paneHelpers'; @@ -1034,7 +1035,8 @@ export const createExtensionsSlice: StateCreator { - const operationScope: InstallScope = scope === 'project' || scope === 'local' ? scope : 'user'; + const operationScope: InstallScope = + scope === 'global' || scope === 'user' || isProjectScopedMcpScope(scope) ? scope : 'user'; const operationKey = getMcpOperationKey(registryId, operationScope); if (!api.mcpRegistry) { clearMcpSuccessResetTimer(operationKey); diff --git a/src/shared/types/extensions/common.ts b/src/shared/types/extensions/common.ts index b5fd304f..23f31ee8 100644 --- a/src/shared/types/extensions/common.ts +++ b/src/shared/types/extensions/common.ts @@ -6,7 +6,7 @@ export type ExtensionOperationState = 'idle' | 'pending' | 'success' | 'error'; /** Installation scope — where the extension is installed */ -export type InstallScope = 'local' | 'user' | 'project'; +export type InstallScope = 'local' | 'user' | 'project' | 'global'; /** Result of a mutation operation */ export interface OperationResult { diff --git a/src/shared/types/extensions/mcp.ts b/src/shared/types/extensions/mcp.ts index cc971734..ec92a6b9 100644 --- a/src/shared/types/extensions/mcp.ts +++ b/src/shared/types/extensions/mcp.ts @@ -83,7 +83,7 @@ export interface McpHeaderDef { export interface InstalledMcpEntry { name: string; - scope: 'local' | 'user' | 'project'; + scope: 'local' | 'user' | 'project' | 'global'; transport?: string; } @@ -100,7 +100,7 @@ export interface McpServerDiagnostic { // ── Install request (renderer → main, minimal trusted data) ──────────────── -export type McpInstallScope = 'local' | 'user' | 'project'; +export type McpInstallScope = 'local' | 'user' | 'project' | 'global'; export interface McpInstallRequest { registryId: string; // server ID from registry (NOT full catalog item) diff --git a/src/shared/utils/extensionNormalizers.ts b/src/shared/utils/extensionNormalizers.ts index 4bb90652..b0cdb96b 100644 --- a/src/shared/utils/extensionNormalizers.ts +++ b/src/shared/utils/extensionNormalizers.ts @@ -160,6 +160,7 @@ export function getInstallationSummaryLabel( const MCP_SCOPE_PRIORITY: Record = { local: 0, project: 1, + global: 2, user: 2, }; @@ -195,6 +196,7 @@ export function getMcpInstallationSummaryLabel( } switch (scopes[0]) { + case 'global': case 'user': return 'Installed globally'; case 'project': diff --git a/src/shared/utils/mcpScopes.ts b/src/shared/utils/mcpScopes.ts new file mode 100644 index 00000000..44903816 --- /dev/null +++ b/src/shared/utils/mcpScopes.ts @@ -0,0 +1,32 @@ +import type { CliFlavor } from '@shared/types'; +import type { InstalledMcpEntry } from '@shared/types/extensions'; + +export type McpInstalledScope = InstalledMcpEntry['scope']; +export type McpSharedScope = Extract; + +export function getDefaultMcpSharedScope(flavor?: CliFlavor | null): McpSharedScope { + return flavor === 'agent_teams_orchestrator' ? 'global' : 'user'; +} + +export function isProjectScopedMcpScope(scope?: string): scope is 'project' | 'local' { + return scope === 'project' || scope === 'local'; +} + +export function isInstalledMcpScope(scope: unknown): scope is McpInstalledScope { + return scope === 'user' || scope === 'global' || scope === 'project' || scope === 'local'; +} + +export function getMcpScopeLabel(scope: McpInstalledScope, flavor?: CliFlavor | null): string { + switch (scope) { + case 'global': + return 'Global'; + case 'user': + return flavor === 'agent_teams_orchestrator' ? 'User (legacy)' : 'User (global)'; + case 'project': + return 'Project'; + case 'local': + return 'Local'; + default: + return scope; + } +} diff --git a/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts b/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts index 31ef3d9f..9947c28d 100644 --- a/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts +++ b/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; interface StoreState { installCustomMcpServer: ReturnType; + cliStatus?: { flavor: 'claude' | 'agent_teams_orchestrator' } | null; } const storeState = {} as StoreState; @@ -116,6 +117,7 @@ describe('CustomMcpServerDialog project scope', () => { beforeEach(() => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.installCustomMcpServer = vi.fn().mockResolvedValue(undefined); + storeState.cliStatus = null; lookupMock.mockReset(); lookupMock.mockResolvedValue([]); }); @@ -152,6 +154,32 @@ describe('CustomMcpServerDialog project scope', () => { }); }); + it('defaults to global scope in multimodel mode', async () => { + storeState.cliStatus = { flavor: 'agent_teams_orchestrator' }; + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(CustomMcpServerDialog, { + open: true, + onClose: vi.fn(), + projectPath: null, + }) + ); + await Promise.resolve(); + }); + + const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; + expect(scopeSelect.value).toBe('global'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('passes projectPath for project-scoped custom installs', async () => { const host = document.createElement('div'); document.body.appendChild(host); diff --git a/test/renderer/components/extensions/mcp/McpServerCard.test.ts b/test/renderer/components/extensions/mcp/McpServerCard.test.ts index 3a5509ec..85ccf6d5 100644 --- a/test/renderer/components/extensions/mcp/McpServerCard.test.ts +++ b/test/renderer/components/extensions/mcp/McpServerCard.test.ts @@ -11,6 +11,7 @@ interface StoreState { uninstallMcpServer: ReturnType; installErrors: Record; mcpGitHubStars: Record; + cliStatus?: { flavor: 'claude' | 'agent_teams_orchestrator' } | null; } const storeState = {} as StoreState; @@ -127,6 +128,7 @@ describe('McpServerCard direct action safety', () => { storeState.uninstallMcpServer = vi.fn(); storeState.installErrors = {}; storeState.mcpGitHubStars = {}; + storeState.cliStatus = null; }); afterEach(() => { @@ -285,4 +287,37 @@ describe('McpServerCard direct action safety', () => { await Promise.resolve(); }); }); + + it('keeps direct actions for standard global installs in multimodel mode', async () => { + storeState.cliStatus = { flavor: 'agent_teams_orchestrator' }; + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const installedEntry: InstalledMcpEntry = { + name: 'context7', + scope: 'global', + }; + + await act(async () => { + root.render( + React.createElement(McpServerCard, { + server: makeServer(), + isInstalled: true, + installedEntry, + installedEntries: [installedEntry], + diagnostic: null, + diagnosticsLoading: false, + onClick: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + expect(host.querySelector('[data-testid="install-button"]')).not.toBeNull(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts b/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts index c6f4c204..a5e9ea00 100644 --- a/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts +++ b/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts @@ -11,6 +11,7 @@ interface StoreState { uninstallMcpServer: ReturnType; installErrors: Record; mcpGitHubStars: Record; + cliStatus?: { flavor: 'claude' | 'agent_teams_orchestrator' } | null; } const storeState = {} as StoreState; @@ -172,6 +173,7 @@ describe('McpServerDetailDialog installed entry handling', () => { storeState.uninstallMcpServer = vi.fn(); storeState.installErrors = {}; storeState.mcpGitHubStars = {}; + storeState.cliStatus = null; lookupMock.mockReset(); lookupMock.mockResolvedValue([]); }); @@ -276,6 +278,38 @@ describe('McpServerDetailDialog installed entry handling', () => { }); }); + it('defaults to global scope in multimodel mode', async () => { + storeState.cliStatus = { flavor: 'agent_teams_orchestrator' }; + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(McpServerDetailDialog, { + server: makeServer(), + isInstalled: false, + installedEntry: null, + installedEntries: [], + diagnostic: null, + diagnosticsLoading: false, + projectPath: null, + open: true, + onClose: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; + expect(scopeSelect.value).toBe('global'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('passes project path for project-scoped installs and uninstalls', async () => { const host = document.createElement('div'); document.body.appendChild(host); diff --git a/test/shared/utils/extensionNormalizers.test.ts b/test/shared/utils/extensionNormalizers.test.ts index 39ba92fc..de60b2ed 100644 --- a/test/shared/utils/extensionNormalizers.test.ts +++ b/test/shared/utils/extensionNormalizers.test.ts @@ -241,6 +241,10 @@ describe('getMcpInstallationSummaryLabel', () => { expect(getMcpInstallationSummaryLabel([{ scope: 'local' }])).toBe('Installed locally'); }); + it('describes a single global MCP installation', () => { + expect(getMcpInstallationSummaryLabel([{ scope: 'global' }])).toBe('Installed globally'); + }); + it('summarizes multiple MCP scopes', () => { expect( getMcpInstallationSummaryLabel([ From fd4fd135a1efd7ae2ec1a063229be60c21e5d8ec Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 17 Apr 2026 13:53:39 +0300 Subject: [PATCH 089/121] fix(extensions): hide gemini runtime card --- .../components/extensions/ExtensionStoreView.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/renderer/components/extensions/ExtensionStoreView.tsx b/src/renderer/components/extensions/ExtensionStoreView.tsx index 1f9ea351..516727d3 100644 --- a/src/renderer/components/extensions/ExtensionStoreView.tsx +++ b/src/renderer/components/extensions/ExtensionStoreView.tsx @@ -7,6 +7,7 @@ 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'; @@ -166,7 +167,9 @@ export const ExtensionStoreView = (): React.JSX.Element => { cliStatusLoading || apiKeysLoading || pluginCatalogLoading || mcpBrowseLoading || skillsLoading; const cliStatusBanner = useMemo(() => { const providers = cliStatus?.providers ?? []; - const isMultimodel = cliStatus?.flavor === 'agent_teams_orchestrator' && providers.length > 0; + const visibleProviders = providers.filter((provider) => provider.providerId !== 'gemini'); + const isMultimodel = + cliStatus?.flavor === 'agent_teams_orchestrator' && visibleProviders.length > 0; if (cliStatusLoading || cliStatus === null) { return ( @@ -247,7 +250,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
- {providers.map((provider) => { + {visibleProviders.map((provider) => { const statusTone = provider.authenticated ? 'border-emerald-500/30 bg-emerald-500/5 text-emerald-300' : provider.supported @@ -267,7 +270,13 @@ export const ExtensionStoreView = (): React.JSX.Element => { >
-

{provider.displayName}

+

+ + {provider.displayName} +

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

From 81c59440bfac4c1ff3f249116336ecc61e44d482 Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 17 Apr 2026 14:21:17 +0300 Subject: [PATCH 090/121] fix(extensions): harden mcp diagnostics output --- .../runtime/ExtensionsRuntimeAdapter.ts | 2 +- .../runtime/mcpDiagnosticsParser.ts | 51 +++++++++++++++++-- .../extensions/mcp/McpServersPanel.tsx | 23 +++++++-- src/renderer/store/slices/extensionsSlice.ts | 10 +++- src/shared/types/extensions/mcp.ts | 2 + src/shared/utils/extensionNormalizers.ts | 8 +++ .../McpHealthDiagnosticsService.test.ts | 9 +++- test/renderer/store/extensionsSlice.test.ts | 35 +++++++++++++ 8 files changed, 128 insertions(+), 12 deletions(-) diff --git a/src/main/services/extensions/runtime/ExtensionsRuntimeAdapter.ts b/src/main/services/extensions/runtime/ExtensionsRuntimeAdapter.ts index 66c21893..0b53f392 100644 --- a/src/main/services/extensions/runtime/ExtensionsRuntimeAdapter.ts +++ b/src/main/services/extensions/runtime/ExtensionsRuntimeAdapter.ts @@ -12,7 +12,7 @@ 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 = 30_000; +const MCP_DIAGNOSE_TIMEOUT_MS = 60_000; export interface ExtensionsRuntimeAdapter { readonly flavor: CliFlavor; diff --git a/src/main/services/extensions/runtime/mcpDiagnosticsParser.ts b/src/main/services/extensions/runtime/mcpDiagnosticsParser.ts index 5ab55bed..911b054f 100644 --- a/src/main/services/extensions/runtime/mcpDiagnosticsParser.ts +++ b/src/main/services/extensions/runtime/mcpDiagnosticsParser.ts @@ -3,6 +3,8 @@ import type { McpServerDiagnostic, McpServerHealthStatus } from '@shared/types/e interface McpDiagnoseJsonEntry { name?: string; target?: string; + scope?: 'local' | 'user' | 'project' | 'global' | 'dynamic' | 'managed'; + transport?: string; status?: 'connected' | 'needs-authentication' | 'failed' | 'timeout'; statusLabel?: string; } @@ -12,6 +14,10 @@ interface McpDiagnoseJsonPayload { diagnostics?: McpDiagnoseJsonEntry[]; } +const EMBEDDED_HTTP_URL_PATTERN = /https?:\/\/[^\s"'`]+/gi; +const SENSITIVE_FLAG_VALUE_PATTERN = + /(--(?:api[-_]?key|access[-_]?token|auth[-_]?token|token|secret|password|client[-_]?secret))(?:=([^\s]+)|\s+([^\s]+))/gi; + function extractJsonObject(raw: string): T { const trimmed = raw.trim(); try { @@ -45,6 +51,42 @@ function parseStatusChunk(statusChunk: string): { } } +function redactHttpUrl(urlString: string): string { + try { + const parsed = new URL(urlString); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return urlString; + } + + if (!parsed.username && !parsed.password && !parsed.search && !parsed.hash) { + return urlString; + } + + if (parsed.username) parsed.username = '***'; + if (parsed.password) parsed.password = '***'; + + for (const key of new Set(parsed.searchParams.keys())) { + parsed.searchParams.set(key, 'REDACTED'); + } + + if (parsed.hash) { + parsed.hash = 'REDACTED'; + } + + return parsed.toString(); + } 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) => + inlineValue ? `${flag}=REDACTED` : `${flag} REDACTED` + ); +} + function parseDiagnosticLine(line: string, checkedAt: number): McpServerDiagnostic | null { const statusSeparatorIdx = line.lastIndexOf(' - '); if (statusSeparatorIdx === -1) { @@ -60,7 +102,7 @@ function parseDiagnosticLine(line: string, checkedAt: number): McpServerDiagnost } const name = descriptor.slice(0, nameSeparatorIdx).trim(); - const target = descriptor.slice(nameSeparatorIdx + 2).trim(); + const target = redactDiagnosticTarget(descriptor.slice(nameSeparatorIdx + 2).trim()); if (!name || !target) { return null; } @@ -102,6 +144,7 @@ export function parseMcpDiagnosticsJsonOutput(output: string): McpServerDiagnost return []; } + const redactedTarget = redactDiagnosticTarget(entry.target); const normalizedStatus: McpServerHealthStatus = entry.status === 'connected' ? 'connected' @@ -111,11 +154,13 @@ export function parseMcpDiagnosticsJsonOutput(output: string): McpServerDiagnost ? 'failed' : 'unknown'; - const rawLine = `${entry.name}: ${entry.target} - ${entry.statusLabel}`; + const rawLine = `${entry.name}: ${redactedTarget} - ${entry.statusLabel}`; return [ { name: entry.name, - target: entry.target, + target: redactedTarget, + scope: entry.scope, + transport: entry.transport, status: normalizedStatus, statusLabel: entry.statusLabel, rawLine, diff --git a/src/renderer/components/extensions/mcp/McpServersPanel.tsx b/src/renderer/components/extensions/mcp/McpServersPanel.tsx index e321a1bd..c799433a 100644 --- a/src/renderer/components/extensions/mcp/McpServersPanel.tsx +++ b/src/renderer/components/extensions/mcp/McpServersPanel.tsx @@ -17,6 +17,7 @@ import { useStore } from '@renderer/store'; import { formatRelativeTime } from '@renderer/utils/formatters'; import { CLI_NOT_FOUND_MARKER } from '@shared/constants/cli'; import { + getMcpDiagnosticKey, getPreferredMcpInstallationEntry, sanitizeMcpServerName, } from '@shared/utils/extensionNormalizers'; @@ -164,7 +165,12 @@ export const McpServersPanel = ({ 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( @@ -250,11 +256,18 @@ export const McpServersPanel = ({
{allDiagnostics.map((diagnostic) => (
-

{diagnostic.name}

+
+

{diagnostic.name}

+ {diagnostic.scope && ( + + {diagnostic.scope} + + )} +

) : ( -

Waiting for `claude mcp list` results...

+

+ Waiting for {diagnosticsCommand} results... +

)}
)} diff --git a/src/renderer/store/slices/extensionsSlice.ts b/src/renderer/store/slices/extensionsSlice.ts index c73b6b70..15fb4556 100644 --- a/src/renderer/store/slices/extensionsSlice.ts +++ b/src/renderer/store/slices/extensionsSlice.ts @@ -6,7 +6,11 @@ import { api } from '@renderer/api'; import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli'; import { isProjectScopedMcpScope } from '@shared/utils/mcpScopes'; -import { getMcpOperationKey, getPluginOperationKey } from '@shared/utils/extensionNormalizers'; +import { + getMcpDiagnosticKey, + getMcpOperationKey, + getPluginOperationKey, +} from '@shared/utils/extensionNormalizers'; import { findPaneByTabId, updatePane } from '../utils/paneHelpers'; @@ -555,7 +559,9 @@ export const createExtensionsSlice: StateCreator [entry.name, entry] as const) + diagnostics.map( + (entry) => [getMcpDiagnosticKey(entry.name, entry.scope), entry] as const + ) ), mcpDiagnosticsLoading: false, mcpDiagnosticsLastCheckedAt: Date.now(), diff --git a/src/shared/types/extensions/mcp.ts b/src/shared/types/extensions/mcp.ts index ec92a6b9..dcd720db 100644 --- a/src/shared/types/extensions/mcp.ts +++ b/src/shared/types/extensions/mcp.ts @@ -92,6 +92,8 @@ export type McpServerHealthStatus = 'connected' | 'needs-authentication' | 'fail export interface McpServerDiagnostic { name: string; target: string; + scope?: 'local' | 'user' | 'project' | 'global' | 'dynamic' | 'managed'; + transport?: string; status: McpServerHealthStatus; statusLabel: string; rawLine: string; diff --git a/src/shared/utils/extensionNormalizers.ts b/src/shared/utils/extensionNormalizers.ts index b0cdb96b..5601f944 100644 --- a/src/shared/utils/extensionNormalizers.ts +++ b/src/shared/utils/extensionNormalizers.ts @@ -120,6 +120,14 @@ export function getMcpOperationKey(registryId: string, scope: InstallScope): str return `mcp:${registryId}:${scope}`; } +/** + * Namespaced lookup key for MCP diagnostics. Scope is included when available + * so the same server name can coexist across global/project/local installs. + */ +export function getMcpDiagnosticKey(name: string, scope?: string | null): string { + return scope ? `mcp-diagnostic:${scope}:${name}` : `mcp-diagnostic:${name}`; +} + /** * Check whether a plugin has an installation for the selected scope. */ diff --git a/test/main/services/extensions/McpHealthDiagnosticsService.test.ts b/test/main/services/extensions/McpHealthDiagnosticsService.test.ts index afb7b4eb..c3db915f 100644 --- a/test/main/services/extensions/McpHealthDiagnosticsService.test.ts +++ b/test/main/services/extensions/McpHealthDiagnosticsService.test.ts @@ -25,7 +25,7 @@ alpic: https://mcp.alpic.ai (HTTP) - ! Needs authentication`); }); expect(diagnostics[3]).toMatchObject({ name: 'tavily-remote-mcp', - target: 'npx -y mcp-remote https://mcp.tavily.com/mcp/?tavilyApiKey=test', + target: 'npx -y mcp-remote https://mcp.tavily.com/mcp/?tavilyApiKey=REDACTED', status: 'failed', statusLabel: 'Failed to connect', }); @@ -58,7 +58,9 @@ another log line`); }, { name: 'tavily', - target: 'https://mcp.tavily.com/mcp', + target: 'https://mcp.tavily.com/mcp?token=secret', + scope: 'global', + transport: 'http', status: 'timeout', statusLabel: 'Timed out', }, @@ -74,6 +76,9 @@ another log line`); }), expect.objectContaining({ name: 'tavily', + target: 'https://mcp.tavily.com/mcp?token=REDACTED', + scope: 'global', + transport: 'http', status: 'failed', statusLabel: 'Timed out', }), diff --git a/test/renderer/store/extensionsSlice.test.ts b/test/renderer/store/extensionsSlice.test.ts index ceca353d..c9a5337c 100644 --- a/test/renderer/store/extensionsSlice.test.ts +++ b/test/renderer/store/extensionsSlice.test.ts @@ -55,6 +55,7 @@ vi.mock('../../../src/renderer/api', () => ({ import { api } from '../../../src/renderer/api'; import { + getMcpDiagnosticKey, getMcpOperationKey, getPluginOperationKey, } from '../../../src/shared/utils/extensionNormalizers'; @@ -833,6 +834,40 @@ describe('extensionsSlice', () => { expect(api.cliInstaller!.getStatus).toHaveBeenCalled(); expect(store.getState().apiKeys).toEqual([]); }); + + it('keys MCP diagnostics by scope when the same server exists in multiple scopes', async () => { + (api.mcpRegistry!.diagnose as ReturnType).mockResolvedValue([ + { + name: 'context7', + scope: 'global', + target: 'npx -y @upstash/context7-mcp', + status: 'connected', + statusLabel: 'Connected', + rawLine: 'context7: npx -y @upstash/context7-mcp - Connected', + checkedAt: 1, + }, + { + name: 'context7', + scope: 'project', + target: 'uvx context7-project', + status: 'failed', + statusLabel: 'Failed to connect', + rawLine: 'context7: uvx context7-project - Failed to connect', + checkedAt: 1, + }, + ]); + + await store.getState().runMcpDiagnostics('/tmp/project-a'); + + expect(store.getState().mcpDiagnostics).toMatchObject({ + [getMcpDiagnosticKey('context7', 'global')]: expect.objectContaining({ + target: 'npx -y @upstash/context7-mcp', + }), + [getMcpDiagnosticKey('context7', 'project')]: expect.objectContaining({ + target: 'uvx context7-project', + }), + }); + }); }); describe('skills state hardening', () => { From 489e3eb96787b02a0d4066d7c3eb67e0455dd9dd Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 17 Apr 2026 14:28:25 +0300 Subject: [PATCH 091/121] fix(extensions): scope mcp renderer state by project --- .../extensions/mcp/McpServerDetailDialog.tsx | 2 +- .../extensions/mcp/McpServersPanel.tsx | 44 ++++-- src/renderer/store/slices/extensionsSlice.ts | 124 +++++++++++++---- src/shared/utils/extensionNormalizers.ts | 16 ++- .../extensions/mcp/McpServerCard.test.ts | 5 +- .../mcp/McpServerDetailDialog.test.ts | 5 +- .../extensions/mcp/McpServersPanel.test.ts | 13 ++ test/renderer/store/extensionsSlice.test.ts | 125 +++++++++++++++--- .../shared/utils/extensionNormalizers.test.ts | 4 +- 9 files changed, 277 insertions(+), 61 deletions(-) diff --git a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx index c6515f8d..87ef0a08 100644 --- a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx +++ b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx @@ -76,7 +76,7 @@ export const McpServerDetailDialog = ({ const cliStatus = useStore((s) => s.cliStatus); const defaultSharedScope = getDefaultMcpSharedScope(cliStatus?.flavor); const [scope, setScope] = useState(defaultSharedScope); - const operationKey = server ? getMcpOperationKey(server.id, scope) : null; + const operationKey = server ? getMcpOperationKey(server.id, scope, projectPath) : null; const installProgress = useStore( (s) => (operationKey ? s.mcpInstallProgress[operationKey] : undefined) ?? 'idle' ); diff --git a/src/renderer/components/extensions/mcp/McpServersPanel.tsx b/src/renderer/components/extensions/mcp/McpServersPanel.tsx index c799433a..816ba2b0 100644 --- a/src/renderer/components/extensions/mcp/McpServersPanel.tsx +++ b/src/renderer/components/extensions/mcp/McpServersPanel.tsx @@ -18,6 +18,7 @@ import { formatRelativeTime } from '@renderer/utils/formatters'; import { CLI_NOT_FOUND_MARKER } from '@shared/constants/cli'; import { getMcpDiagnosticKey, + getMcpProjectStateKey, getPreferredMcpInstallationEntry, sanitizeMcpServerName, } from '@shared/utils/extensionNormalizers'; @@ -79,18 +80,24 @@ 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, } = useStore( @@ -100,16 +107,33 @@ 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, })) ); + 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'); diff --git a/src/renderer/store/slices/extensionsSlice.ts b/src/renderer/store/slices/extensionsSlice.ts index 15fb4556..20dbae3b 100644 --- a/src/renderer/store/slices/extensionsSlice.ts +++ b/src/renderer/store/slices/extensionsSlice.ts @@ -8,6 +8,7 @@ import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli'; import { isProjectScopedMcpScope } from '@shared/utils/mcpScopes'; import { getMcpDiagnosticKey, + getMcpProjectStateKey, getMcpOperationKey, getPluginOperationKey, } from '@shared/utils/extensionNormalizers'; @@ -56,11 +57,16 @@ export interface ExtensionsSlice { mcpBrowseLoading: boolean; mcpBrowseError: string | null; mcpInstalledServers: InstalledMcpEntry[]; + mcpInstalledServersByProjectPath: Record; mcpInstalledProjectPath: string | null; mcpDiagnostics: Record; + mcpDiagnosticsByProjectPath: Record>; mcpDiagnosticsLoading: boolean; + mcpDiagnosticsLoadingByProjectPath: Record; mcpDiagnosticsError: string | null; + mcpDiagnosticsErrorByProjectPath: Record; mcpDiagnosticsLastCheckedAt: number | null; + mcpDiagnosticsLastCheckedAtByProjectPath: Record; // ── Install progress ── pluginInstallProgress: Record; @@ -137,7 +143,7 @@ let pluginFetchInFlight: { key: string; promise: Promise } | null = null; let pluginCatalogRequestSeq = 0; const pluginSuccessResetTimers = new Map>(); const mcpSuccessResetTimers = new Map>(); -let mcpDiagnosticsInFlight: Promise | null = null; +const mcpDiagnosticsInFlightByKey = new Map>(); let skillsCatalogRequestSeq = 0; let skillsDetailRequestSeq = 0; const latestSkillsCatalogRequestByKey = new Map(); @@ -227,10 +233,26 @@ function schedulePluginSuccessReset( pluginSuccessResetTimers.set(operationKey, timer); } -function getCustomMcpOperationKey(serverName: string, scope: InstallScope): string { +function getCustomMcpOperationKey( + serverName: string, + scope: InstallScope, + projectPath?: string | null +): string { + if (scope === 'project' || scope === 'local') { + return `mcp-custom:${serverName}:${scope}:${getMcpProjectStateKey(projectPath)}`; + } return `mcp-custom:${serverName}:${scope}`; } +function isProjectScopedMcpOperationKey(operationKey: string): boolean { + return ( + operationKey.includes(':project:') || + operationKey.endsWith(':project') || + operationKey.includes(':local:') || + operationKey.endsWith(':local') + ); +} + function clearMcpSuccessResetTimer(operationKey: string): void { const timer = mcpSuccessResetTimers.get(operationKey); if (!timer) { @@ -274,7 +296,7 @@ function clearMcpProjectScopedOperationState( for (const operationKey of Object.keys(nextMcpInstallProgress)) { if ( (operationKey.startsWith('mcp:') || operationKey.startsWith('mcp-custom:')) && - (operationKey.endsWith(':project') || operationKey.endsWith(':local')) + isProjectScopedMcpOperationKey(operationKey) ) { delete nextMcpInstallProgress[operationKey]; } @@ -283,7 +305,7 @@ function clearMcpProjectScopedOperationState( for (const operationKey of Object.keys(nextInstallErrors)) { if ( (operationKey.startsWith('mcp:') || operationKey.startsWith('mcp-custom:')) && - (operationKey.endsWith(':project') || operationKey.endsWith(':local')) + isProjectScopedMcpOperationKey(operationKey) ) { delete nextInstallErrors[operationKey]; } @@ -297,7 +319,7 @@ function clearMcpProjectScopedOperationState( function clearMcpProjectScopedSuccessResetTimers(): void { for (const operationKey of Array.from(mcpSuccessResetTimers.keys())) { - if (operationKey.endsWith(':project') || operationKey.endsWith(':local')) { + if (isProjectScopedMcpOperationKey(operationKey)) { clearMcpSuccessResetTimer(operationKey); } } @@ -336,11 +358,16 @@ export const createExtensionsSlice: StateCreator { const nextProjectPath = projectPath ?? null; + const stateKey = getMcpProjectStateKey(nextProjectPath); const isSameProjectContext = prev.mcpInstalledProjectPath === nextProjectPath; const nextOperationState = isSameProjectContext ? { @@ -533,6 +561,10 @@ export const createExtensionsSlice: StateCreator { const mcpRegistry = api.mcpRegistry; if (!mcpRegistry) return; + const projectStateKey = getMcpProjectStateKey(projectPath); - if (mcpDiagnosticsInFlight) { - await mcpDiagnosticsInFlight; + const existing = mcpDiagnosticsInFlightByKey.get(projectStateKey); + if (existing) { + await existing; return; } - set({ mcpDiagnosticsLoading: true, mcpDiagnosticsError: null }); + set((prev) => ({ + mcpDiagnosticsLoading: true, + mcpDiagnosticsError: null, + mcpDiagnosticsLoadingByProjectPath: { + ...prev.mcpDiagnosticsLoadingByProjectPath, + [projectStateKey]: true, + }, + mcpDiagnosticsErrorByProjectPath: { + ...prev.mcpDiagnosticsErrorByProjectPath, + [projectStateKey]: null, + }, + })); const promise = (async () => { try { const diagnostics = await mcpRegistry.diagnose(projectPath); + const diagnosticsRecord = Object.fromEntries( + diagnostics.map((entry) => [getMcpDiagnosticKey(entry.name, entry.scope), entry] as const) + ); + const checkedAt = Date.now(); set({ - mcpDiagnostics: Object.fromEntries( - diagnostics.map( - (entry) => [getMcpDiagnosticKey(entry.name, entry.scope), entry] as const - ) - ), + mcpDiagnostics: diagnosticsRecord, mcpDiagnosticsLoading: false, - mcpDiagnosticsLastCheckedAt: Date.now(), + mcpDiagnosticsByProjectPath: { + ...get().mcpDiagnosticsByProjectPath, + [projectStateKey]: diagnosticsRecord, + }, + mcpDiagnosticsLoadingByProjectPath: { + ...get().mcpDiagnosticsLoadingByProjectPath, + [projectStateKey]: false, + }, + mcpDiagnosticsLastCheckedAt: checkedAt, + mcpDiagnosticsLastCheckedAtByProjectPath: { + ...get().mcpDiagnosticsLastCheckedAtByProjectPath, + [projectStateKey]: checkedAt, + }, }); } catch (err) { + const errorMessage = + err instanceof Error ? err.message : 'Failed to check MCP server health'; set({ mcpDiagnosticsLoading: false, - mcpDiagnosticsError: - err instanceof Error ? err.message : 'Failed to check MCP server health', + mcpDiagnosticsError: errorMessage, + mcpDiagnosticsLoadingByProjectPath: { + ...get().mcpDiagnosticsLoadingByProjectPath, + [projectStateKey]: false, + }, + mcpDiagnosticsErrorByProjectPath: { + ...get().mcpDiagnosticsErrorByProjectPath, + [projectStateKey]: errorMessage, + }, }); } finally { - mcpDiagnosticsInFlight = null; + mcpDiagnosticsInFlightByKey.delete(projectStateKey); } })(); - mcpDiagnosticsInFlight = promise; + mcpDiagnosticsInFlightByKey.set(projectStateKey, promise); await promise; }, @@ -935,7 +1001,7 @@ export const createExtensionsSlice: StateCreator { - const operationKey = getMcpOperationKey(request.registryId, request.scope); + const operationKey = getMcpOperationKey(request.registryId, request.scope, request.projectPath); if (!api.mcpRegistry) { clearMcpSuccessResetTimer(operationKey); set((prev) => ({ @@ -967,8 +1033,8 @@ export const createExtensionsSlice: StateCreator ({ @@ -989,7 +1055,11 @@ export const createExtensionsSlice: StateCreator { const operationScope = request.scope; - const progressKey = getCustomMcpOperationKey(request.serverName, operationScope); + const progressKey = getCustomMcpOperationKey( + request.serverName, + operationScope, + request.projectPath + ); if (!api.mcpRegistry) { clearMcpSuccessResetTimer(progressKey); set((prev) => ({ @@ -1015,8 +1085,8 @@ export const createExtensionsSlice: StateCreator ({ @@ -1043,7 +1113,7 @@ export const createExtensionsSlice: StateCreator { const operationScope: InstallScope = scope === 'global' || scope === 'user' || isProjectScopedMcpScope(scope) ? scope : 'user'; - const operationKey = getMcpOperationKey(registryId, operationScope); + const operationKey = getMcpOperationKey(registryId, operationScope, projectPath); if (!api.mcpRegistry) { clearMcpSuccessResetTimer(operationKey); set((prev) => ({ @@ -1072,8 +1142,8 @@ export const createExtensionsSlice: StateCreator ({ diff --git a/src/shared/utils/extensionNormalizers.ts b/src/shared/utils/extensionNormalizers.ts index 5601f944..356946bf 100644 --- a/src/shared/utils/extensionNormalizers.ts +++ b/src/shared/utils/extensionNormalizers.ts @@ -116,7 +116,14 @@ export function getPluginOperationKey(pluginId: string, scope: InstallScope): st /** * Namespaced operation-state key for MCP install/uninstall UI state. */ -export function getMcpOperationKey(registryId: string, scope: InstallScope): string { +export function getMcpOperationKey( + registryId: string, + scope: InstallScope, + projectPath?: string | null +): string { + if (scope === 'project' || scope === 'local') { + return `mcp:${registryId}:${scope}:${getMcpProjectStateKey(projectPath)}`; + } return `mcp:${registryId}:${scope}`; } @@ -128,6 +135,13 @@ export function getMcpDiagnosticKey(name: string, scope?: string | null): string return scope ? `mcp-diagnostic:${scope}:${name}` : `mcp-diagnostic:${name}`; } +/** + * Stable project-aware cache key for MCP installed/diagnostics state. + */ +export function getMcpProjectStateKey(projectPath?: string | null): string { + return projectPath ?? '__global__'; +} + /** * Check whether a plugin has an installation for the selected scope. */ diff --git a/test/renderer/components/extensions/mcp/McpServerCard.test.ts b/test/renderer/components/extensions/mcp/McpServerCard.test.ts index 85ccf6d5..db2adef6 100644 --- a/test/renderer/components/extensions/mcp/McpServerCard.test.ts +++ b/test/renderer/components/extensions/mcp/McpServerCard.test.ts @@ -255,11 +255,12 @@ describe('McpServerCard direct action safety', () => { }; storeState.mcpInstallProgress = { - [getMcpOperationKey('io.github.upstash/context7', 'project')]: 'error', + [getMcpOperationKey('io.github.upstash/context7', 'project', '/tmp/project')]: 'error', [getMcpOperationKey('io.github.upstash/context7', 'user')]: 'pending', }; storeState.installErrors = { - [getMcpOperationKey('io.github.upstash/context7', 'project')]: 'Project failed', + [getMcpOperationKey('io.github.upstash/context7', 'project', '/tmp/project')]: + 'Project failed', [getMcpOperationKey('io.github.upstash/context7', 'user')]: 'User failed', }; diff --git a/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts b/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts index a5e9ea00..7e1b32e9 100644 --- a/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts +++ b/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts @@ -581,10 +581,11 @@ describe('McpServerDetailDialog installed entry handling', () => { const root = createRoot(host); storeState.mcpInstallProgress = { [getMcpOperationKey('io.github.upstash/context7', 'user')]: 'success', - [getMcpOperationKey('io.github.upstash/context7', 'project')]: 'error', + [getMcpOperationKey('io.github.upstash/context7', 'project', '/tmp/project')]: 'error', }; storeState.installErrors = { - [getMcpOperationKey('io.github.upstash/context7', 'project')]: 'Project failed', + [getMcpOperationKey('io.github.upstash/context7', 'project', '/tmp/project')]: + 'Project failed', }; await act(async () => { diff --git a/test/renderer/components/extensions/mcp/McpServersPanel.test.ts b/test/renderer/components/extensions/mcp/McpServersPanel.test.ts index 0d9451df..85a2281d 100644 --- a/test/renderer/components/extensions/mcp/McpServersPanel.test.ts +++ b/test/renderer/components/extensions/mcp/McpServersPanel.test.ts @@ -18,11 +18,19 @@ interface StoreState { mcpBrowseError: string | null; mcpBrowse: ReturnType; mcpInstalledServers: Array<{ name: string; scope: 'local' | 'user' | 'project' }>; + mcpInstalledServersByProjectPath?: Record< + string, + Array<{ name: string; scope: 'local' | 'user' | 'project' }> + >; fetchMcpGitHubStars: ReturnType; mcpDiagnostics: Record; + mcpDiagnosticsByProjectPath?: Record>; mcpDiagnosticsLoading: boolean; + mcpDiagnosticsLoadingByProjectPath?: Record; mcpDiagnosticsError: string | null; + mcpDiagnosticsErrorByProjectPath?: Record; mcpDiagnosticsLastCheckedAt: number | null; + mcpDiagnosticsLastCheckedAtByProjectPath?: Record; runMcpDiagnostics: ReturnType; } @@ -121,11 +129,16 @@ describe('McpServersPanel initial browse loading', () => { storeState.mcpBrowseError = null; storeState.mcpBrowse = vi.fn(); storeState.mcpInstalledServers = []; + storeState.mcpInstalledServersByProjectPath = undefined; storeState.fetchMcpGitHubStars = vi.fn(); storeState.mcpDiagnostics = {}; + storeState.mcpDiagnosticsByProjectPath = undefined; storeState.mcpDiagnosticsLoading = false; + storeState.mcpDiagnosticsLoadingByProjectPath = undefined; storeState.mcpDiagnosticsError = null; + storeState.mcpDiagnosticsErrorByProjectPath = undefined; storeState.mcpDiagnosticsLastCheckedAt = null; + storeState.mcpDiagnosticsLastCheckedAtByProjectPath = undefined; storeState.runMcpDiagnostics = vi.fn(); }); diff --git a/test/renderer/store/extensionsSlice.test.ts b/test/renderer/store/extensionsSlice.test.ts index c9a5337c..350e074c 100644 --- a/test/renderer/store/extensionsSlice.test.ts +++ b/test/renderer/store/extensionsSlice.test.ts @@ -56,6 +56,7 @@ vi.mock('../../../src/renderer/api', () => ({ import { api } from '../../../src/renderer/api'; import { getMcpDiagnosticKey, + getMcpProjectStateKey, getMcpOperationKey, getPluginOperationKey, } from '../../../src/shared/utils/extensionNormalizers'; @@ -154,8 +155,11 @@ const makeReadyCliStatus = () => ({ const pluginOperationKey = (pluginId: string, scope: 'user' | 'project' | 'local' = 'user') => getPluginOperationKey(pluginId, scope); -const mcpOperationKey = (registryId: string, scope: 'user' | 'project' | 'local' = 'user') => - getMcpOperationKey(registryId, scope); +const mcpOperationKey = ( + registryId: string, + scope: 'user' | 'project' | 'local' | 'global' = 'user', + projectPath?: string +) => getMcpOperationKey(registryId, scope, projectPath); describe('extensionsSlice', () => { let store: TestStore; @@ -393,20 +397,34 @@ describe('extensionsSlice', () => { expect(store.getState().mcpInstalledServers).toEqual(installed); }); + it('stores installed MCP servers independently per project context', async () => { + (api.mcpRegistry!.getInstalled as ReturnType) + .mockResolvedValueOnce([{ name: 'global-server', scope: 'global' as const }]) + .mockResolvedValueOnce([{ name: 'project-server', scope: 'project' as const }]); + + await store.getState().mcpFetchInstalled(); + await store.getState().mcpFetchInstalled('/tmp/project-a'); + + expect(store.getState().mcpInstalledServersByProjectPath).toMatchObject({ + [getMcpProjectStateKey()]: [{ name: 'global-server', scope: 'global' }], + [getMcpProjectStateKey('/tmp/project-a')]: [{ name: 'project-server', scope: 'project' }], + }); + }); + it('clears stale project- and local-scoped MCP operation state when project changes', async () => { store.setState({ mcpInstalledProjectPath: '/tmp/project-a', mcpInstallProgress: { - [mcpOperationKey('project-server', 'project')]: 'error', - [mcpOperationKey('local-server', 'local')]: 'success', + [mcpOperationKey('project-server', 'project', '/tmp/project-a')]: 'error', + [mcpOperationKey('local-server', 'local', '/tmp/project-a')]: 'success', [mcpOperationKey('user-server', 'user')]: 'pending', }, installErrors: { - [mcpOperationKey('project-server', 'project')]: 'Project failed', - [mcpOperationKey('local-server', 'local')]: 'Local failed', + [mcpOperationKey('project-server', 'project', '/tmp/project-a')]: 'Project failed', + [mcpOperationKey('local-server', 'local', '/tmp/project-a')]: 'Local failed', [mcpOperationKey('user-server', 'user')]: 'Keep user state', 'plugin:test@marketplace:user': 'Keep plugin state', - 'mcp-custom:custom-server:project': 'Clear custom project state', + 'mcp-custom:custom-server:project:/tmp/project-a': 'Clear custom project state', }, }); (api.mcpRegistry!.getInstalled as ReturnType).mockResolvedValue([]); @@ -414,13 +432,21 @@ describe('extensionsSlice', () => { await store.getState().mcpFetchInstalled('/tmp/project-b'); expect(store.getState().mcpInstalledProjectPath).toBe('/tmp/project-b'); - expect(store.getState().mcpInstallProgress[mcpOperationKey('project-server', 'project')]).toBeUndefined(); - expect(store.getState().mcpInstallProgress[mcpOperationKey('local-server', 'local')]).toBeUndefined(); + expect( + store.getState().mcpInstallProgress[mcpOperationKey('project-server', 'project', '/tmp/project-a')] + ).toBeUndefined(); + expect( + store.getState().mcpInstallProgress[mcpOperationKey('local-server', 'local', '/tmp/project-a')] + ).toBeUndefined(); expect(store.getState().mcpInstallProgress[mcpOperationKey('user-server', 'user')]).toBe( 'pending', ); - expect(store.getState().installErrors[mcpOperationKey('project-server', 'project')]).toBeUndefined(); - expect(store.getState().installErrors[mcpOperationKey('local-server', 'local')]).toBeUndefined(); + expect( + store.getState().installErrors[mcpOperationKey('project-server', 'project', '/tmp/project-a')] + ).toBeUndefined(); + expect( + store.getState().installErrors[mcpOperationKey('local-server', 'local', '/tmp/project-a')] + ).toBeUndefined(); expect(store.getState().installErrors[mcpOperationKey('user-server', 'user')]).toBe( 'Keep user state', ); @@ -749,16 +775,20 @@ describe('extensionsSlice', () => { headers: [], }); - expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'project')]).toBe( - 'success', - ); + expect( + store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'project', '/tmp/project-a')] + ).toBe('success'); await store.getState().mcpFetchInstalled('/tmp/project-b'); - expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'project')]).toBeUndefined(); + expect( + store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'project', '/tmp/project-a')] + ).toBeUndefined(); await vi.advanceTimersByTimeAsync(2_000); - expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'project')]).toBeUndefined(); + expect( + store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'project', '/tmp/project-a')] + ).toBeUndefined(); }); }); @@ -868,6 +898,69 @@ describe('extensionsSlice', () => { }), }); }); + + it('stores MCP diagnostics independently per project context', async () => { + (api.mcpRegistry!.diagnose as ReturnType) + .mockResolvedValueOnce([ + { + name: 'global-server', + scope: 'global', + target: 'npx global-server', + status: 'connected', + statusLabel: 'Connected', + rawLine: 'global-server: npx global-server - Connected', + checkedAt: 1, + }, + ]) + .mockResolvedValueOnce([ + { + name: 'project-server', + scope: 'project', + target: 'uvx project-server', + status: 'failed', + statusLabel: 'Failed to connect', + rawLine: 'project-server: uvx project-server - Failed to connect', + checkedAt: 2, + }, + ]); + + await store.getState().runMcpDiagnostics(); + await store.getState().runMcpDiagnostics('/tmp/project-a'); + + expect(store.getState().mcpDiagnosticsByProjectPath).toMatchObject({ + [getMcpProjectStateKey()]: { + [getMcpDiagnosticKey('global-server', 'global')]: expect.objectContaining({ + target: 'npx global-server', + }), + }, + [getMcpProjectStateKey('/tmp/project-a')]: { + [getMcpDiagnosticKey('project-server', 'project')]: expect.objectContaining({ + target: 'uvx project-server', + }), + }, + }); + }); + + it('refreshes MCP install state using the operation project context instead of the last viewed tab', async () => { + store.setState({ + mcpInstalledProjectPath: '/tmp/project-b', + }); + (api.mcpRegistry!.install as ReturnType).mockResolvedValue({ state: 'success' }); + (api.mcpRegistry!.getInstalled as ReturnType).mockResolvedValue([]); + (api.mcpRegistry!.diagnose as ReturnType).mockResolvedValue([]); + + await store.getState().installMcpServer({ + registryId: 'test-id', + serverName: 'test-server', + scope: 'project', + projectPath: '/tmp/project-a', + envValues: {}, + headers: [], + }); + + expect(api.mcpRegistry!.getInstalled).toHaveBeenLastCalledWith('/tmp/project-a'); + expect(api.mcpRegistry!.diagnose).toHaveBeenLastCalledWith('/tmp/project-a'); + }); }); describe('skills state hardening', () => { diff --git a/test/shared/utils/extensionNormalizers.test.ts b/test/shared/utils/extensionNormalizers.test.ts index de60b2ed..979c318d 100644 --- a/test/shared/utils/extensionNormalizers.test.ts +++ b/test/shared/utils/extensionNormalizers.test.ts @@ -166,8 +166,8 @@ describe('getPluginOperationKey', () => { describe('getMcpOperationKey', () => { it('namespaces MCP operation keys by scope', () => { - expect(getMcpOperationKey('io.github.upstash/context7', 'project')).toBe( - 'mcp:io.github.upstash/context7:project', + expect(getMcpOperationKey('io.github.upstash/context7', 'project', '/tmp/project')).toBe( + 'mcp:io.github.upstash/context7:project:/tmp/project', ); }); }); From 33917a3161ea44d5f4994004af2c4fa68664c55d Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 17 Apr 2026 14:34:46 +0300 Subject: [PATCH 092/121] fix(extensions): support project-scoped api keys --- src/main/ipc/extensions.ts | 8 +- .../extensions/apikeys/ApiKeyService.ts | 61 +++++++-- src/preload/index.ts | 4 +- .../extensions/ExtensionStoreView.tsx | 2 +- .../extensions/apikeys/ApiKeyCard.tsx | 6 + .../extensions/apikeys/ApiKeyFormDialog.tsx | 46 ++++++- .../extensions/apikeys/ApiKeysPanel.tsx | 18 ++- .../extensions/mcp/CustomMcpServerDialog.tsx | 11 +- .../extensions/mcp/McpServerDetailDialog.tsx | 9 +- .../runtime/ProviderRuntimeSettingsDialog.tsx | 2 +- src/shared/types/extensions/api.ts | 2 +- src/shared/types/extensions/apikey.ts | 2 + .../services/extensions/ApiKeyService.test.ts | 120 ++++++++++++++++++ .../mcp/CustomMcpServerDialog.test.ts | 41 ++++++ .../mcp/McpServerDetailDialog.test.ts | 34 ++++- 15 files changed, 334 insertions(+), 32 deletions(-) create mode 100644 test/main/services/extensions/ApiKeyService.test.ts diff --git a/src/main/ipc/extensions.ts b/src/main/ipc/extensions.ts index 7577c112..3a3cccc0 100644 --- a/src/main/ipc/extensions.ts +++ b/src/main/ipc/extensions.ts @@ -421,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/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/preload/index.ts b/src/preload/index.ts index eb2ffbf3..772422e1 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1623,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/components/extensions/ExtensionStoreView.tsx b/src/renderer/components/extensions/ExtensionStoreView.tsx index 516727d3..396271ab 100644 --- a/src/renderer/components/extensions/ExtensionStoreView.tsx +++ b/src/renderer/components/extensions/ExtensionStoreView.tsx @@ -431,7 +431,7 @@ export const ExtensionStoreView = (): React.JSX.Element => { - + 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 a45b0e58..3352fb3c 100644 --- a/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx +++ b/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx @@ -15,7 +15,15 @@ import { ApiKeyFormDialog } from './ApiKeyFormDialog'; import type { ApiKeyEntry } from '@shared/types/extensions'; -export const ApiKeysPanel = (): React.JSX.Element => { +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) => ({ @@ -213,7 +221,13 @@ export const ApiKeysPanel = (): React.JSX.Element => { )} {/* Form dialog */} - +
); }; diff --git a/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx b/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx index 59c28f6f..bba98439 100644 --- a/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx +++ b/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx @@ -92,6 +92,11 @@ export const CustomMcpServerDialog = ({ const [envVars, setEnvVars] = useState([]); const [error, setError] = useState(null); const [installing, setInstalling] = useState(false); + const envVarLookupNames = envVars + .map((entry) => entry.key.trim()) + .filter(Boolean) + .sort() + .join('\0'); // Reset on open useEffect(() => { @@ -120,10 +125,10 @@ export const CustomMcpServerDialog = ({ 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, projectPath ?? undefined).then( (results) => { if (results.length === 0) return; const lookup = new Map(results.map((r) => [r.envVarName, r.value])); @@ -135,7 +140,7 @@ export const CustomMcpServerDialog = ({ // Silently fail } ); - }, [open, envVars.length]); // eslint-disable-line react-hooks/exhaustive-deps + }, [envVarLookupNames, envVars, open, projectPath]); // eslint-disable-line react-hooks/exhaustive-deps const handleInstall = async () => { setError(null); diff --git a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx index 87ef0a08..8ac44ee7 100644 --- a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx +++ b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx @@ -110,6 +110,11 @@ export const McpServerDetailDialog = ({ const selectedInstalledEntry = normalizedInstalledEntries.find((entry) => entry.scope === scope) ?? null; const installSummaryLabel = getMcpInstallationSummaryLabel(normalizedInstalledEntries); + const envVarLookupNames = + server?.envVars + .map((entry) => entry.name) + .sort() + .join('\0') ?? ''; // Initialize form when dialog opens or server changes useEffect(() => { @@ -160,7 +165,7 @@ export const McpServerDetailDialog = ({ if (!server || !open || server.envVars.length === 0 || !api.apiKeys) return; const envVarNames = server.envVars.map((e) => e.name); - void api.apiKeys.lookup(envVarNames).then( + void api.apiKeys.lookup(envVarNames, projectPath ?? undefined).then( (results) => { if (results.length === 0) return; const filled = new Set(); @@ -176,7 +181,7 @@ export const McpServerDetailDialog = ({ // Silently fail — auto-fill is supplementary } ); - }, [server?.id, open]); // eslint-disable-line react-hooks/exhaustive-deps + }, [envVarLookupNames, open, projectPath, server?.id]); // eslint-disable-line react-hooks/exhaustive-deps if (!server) return <>; diff --git a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx index 62f80d62..dcdb4855 100644 --- a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx +++ b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx @@ -105,7 +105,7 @@ function isApiKeyProviderId(providerId: CliProviderId): providerId is ApiKeyProv function findPreferredApiKeyEntry(apiKeys: ApiKeyEntry[], envVarName: string): ApiKeyEntry | null { const matches = apiKeys.filter((entry) => entry.envVarName === envVarName); - return matches.find((entry) => entry.scope === 'user') ?? matches[0] ?? null; + return matches.find((entry) => entry.scope === 'user') ?? null; } function getConnectionDescription(provider: CliProviderStatus): string { diff --git a/src/shared/types/extensions/api.ts b/src/shared/types/extensions/api.ts index c72935c1..e6d9735e 100644 --- a/src/shared/types/extensions/api.ts +++ b/src/shared/types/extensions/api.ts @@ -80,6 +80,6 @@ export interface ApiKeysAPI { list: () => Promise; save: (request: ApiKeySaveRequest) => Promise; delete: (id: string) => Promise; - lookup: (envVarNames: string[]) => Promise; + lookup: (envVarNames: string[], projectPath?: string) => Promise; getStorageStatus: () => Promise; } diff --git a/src/shared/types/extensions/apikey.ts b/src/shared/types/extensions/apikey.ts index c36f11b9..d69af4f6 100644 --- a/src/shared/types/extensions/apikey.ts +++ b/src/shared/types/extensions/apikey.ts @@ -9,6 +9,7 @@ export interface ApiKeyEntry { envVarName: string; maskedValue: string; scope: 'user' | 'project'; + projectPath?: string; createdAt: string; } @@ -19,6 +20,7 @@ export interface ApiKeySaveRequest { envVarName: string; value: string; scope: 'user' | 'project'; + projectPath?: string; } /** Decrypted key lookup result (for auto-fill) */ diff --git a/test/main/services/extensions/ApiKeyService.test.ts b/test/main/services/extensions/ApiKeyService.test.ts new file mode 100644 index 00000000..71e27ec9 --- /dev/null +++ b/test/main/services/extensions/ApiKeyService.test.ts @@ -0,0 +1,120 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('electron', () => ({ + safeStorage: { + isEncryptionAvailable: vi.fn(() => false), + getSelectedStorageBackend: vi.fn(() => 'basic_text'), + encryptString: vi.fn(), + decryptString: vi.fn(), + }, +})); + +import { ApiKeyService } from '@main/services/extensions/apikeys/ApiKeyService'; + +describe('ApiKeyService', () => { + let tempDir: string; + let service: ApiKeyService; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'apikey-service-')); + service = new ApiKeyService(tempDir); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('persists projectPath for project-scoped API keys', async () => { + const saved = await service.save({ + name: 'Project Tavily', + envVarName: 'TAVILY_API_KEY', + value: 'secret', + scope: 'project', + projectPath: '/tmp/project-a', + }); + + expect(saved.scope).toBe('project'); + expect(saved.projectPath).toBe('/tmp/project-a'); + + await expect(service.list()).resolves.toEqual([ + expect.objectContaining({ + scope: 'project', + projectPath: '/tmp/project-a', + }), + ]); + }); + + it('rejects project-scoped keys without a project path', async () => { + await expect( + service.save({ + name: 'Broken key', + envVarName: 'TAVILY_API_KEY', + value: 'secret', + scope: 'project', + }) + ).rejects.toThrow('project path'); + }); + + it('prefers exact project matches over user keys during lookup', async () => { + await service.save({ + name: 'Shared Tavily', + envVarName: 'TAVILY_API_KEY', + value: 'user-secret', + scope: 'user', + }); + await service.save({ + name: 'Project Tavily', + envVarName: 'TAVILY_API_KEY', + value: 'project-secret', + scope: 'project', + projectPath: '/tmp/project-a', + }); + + await expect(service.lookup(['TAVILY_API_KEY'], '/tmp/project-a')).resolves.toEqual([ + { + envVarName: 'TAVILY_API_KEY', + value: 'project-secret', + }, + ]); + }); + + it('falls back to user keys when project-specific matches do not exist', async () => { + await service.save({ + name: 'Shared Tavily', + envVarName: 'TAVILY_API_KEY', + value: 'user-secret', + scope: 'user', + }); + await service.save({ + name: 'Other project Tavily', + envVarName: 'TAVILY_API_KEY', + value: 'project-secret', + scope: 'project', + projectPath: '/tmp/project-b', + }); + + await expect(service.lookup(['TAVILY_API_KEY'], '/tmp/project-a')).resolves.toEqual([ + { + envVarName: 'TAVILY_API_KEY', + value: 'user-secret', + }, + ]); + }); + + it('does not leak project-scoped keys without project context', async () => { + await service.save({ + name: 'Project only key', + envVarName: 'TAVILY_API_KEY', + value: 'project-secret', + scope: 'project', + projectPath: '/tmp/project-a', + }); + + await expect(service.lookup(['TAVILY_API_KEY'])).resolves.toEqual([]); + await expect(service.lookupPreferred('TAVILY_API_KEY')).resolves.toBeNull(); + }); +}); diff --git a/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts b/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts index 9947c28d..29e23faa 100644 --- a/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts +++ b/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts @@ -180,6 +180,47 @@ describe('CustomMcpServerDialog project scope', () => { }); }); + it('passes projectPath into API key lookup for project-aware autofill', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(CustomMcpServerDialog, { + open: true, + onClose: vi.fn(), + projectPath: '/tmp/custom-mcp-project', + }) + ); + await Promise.resolve(); + }); + + const addEnvButton = Array.from(host.querySelectorAll('button')).find((button) => + button.textContent?.includes('Add') + ) as HTMLButtonElement; + await act(async () => { + addEnvButton.click(); + await Promise.resolve(); + }); + + const envKeyInput = host.querySelector( + 'input[placeholder="ENV_VAR_NAME"]' + ) as HTMLInputElement; + await act(async () => { + setNativeValue(envKeyInput, 'CONTEXT7_API_KEY', 'input'); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(lookupMock).toHaveBeenCalledWith(['CONTEXT7_API_KEY'], '/tmp/custom-mcp-project'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('passes projectPath for project-scoped custom installs', async () => { const host = document.createElement('div'); document.body.appendChild(host); diff --git a/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts b/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts index 7e1b32e9..18c78c6d 100644 --- a/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts +++ b/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts @@ -266,7 +266,7 @@ describe('McpServerDetailDialog installed entry handling', () => { }); expect(lookupMock).toHaveBeenCalledTimes(1); - expect(lookupMock).toHaveBeenCalledWith(['CONTEXT7_API_KEY']); + expect(lookupMock).toHaveBeenCalledWith(['CONTEXT7_API_KEY'], undefined); const projectOption = host.querySelector('option[value="project"]') as HTMLOptionElement; const localOption = host.querySelector('option[value="local"]') as HTMLOptionElement; expect(projectOption.disabled).toBe(true); @@ -278,6 +278,38 @@ describe('McpServerDetailDialog installed entry handling', () => { }); }); + it('passes projectPath into API key lookup for project-aware autofill', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const server = makeServer(); + server.envVars = [{ name: 'CONTEXT7_API_KEY', isSecret: true }]; + + await act(async () => { + root.render( + React.createElement(McpServerDetailDialog, { + server, + isInstalled: false, + installedEntry: null, + diagnostic: null, + diagnosticsLoading: false, + projectPath: '/tmp/project-context7', + open: true, + onClose: vi.fn(), + }) + ); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(lookupMock).toHaveBeenCalledWith(['CONTEXT7_API_KEY'], '/tmp/project-context7'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('defaults to global scope in multimodel mode', async () => { storeState.cliStatus = { flavor: 'agent_teams_orchestrator' }; const host = document.createElement('div'); From 24782411f35320c997022ccb84fd5ffaa5e05930 Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 17 Apr 2026 14:39:26 +0300 Subject: [PATCH 093/121] fix(extensions): scope plugin operation state by project --- .../extensions/plugins/PluginDetailDialog.tsx | 4 +- src/renderer/store/slices/extensionsSlice.ts | 43 ++++++++++----- src/shared/utils/extensionNormalizers.ts | 9 +++- .../plugins/PluginDetailDialog.test.ts | 52 ++++++++++++++++++ test/renderer/store/extensionsSlice.test.ts | 54 ++++++++++++++++++- .../shared/utils/extensionNormalizers.test.ts | 12 +++-- 6 files changed, 155 insertions(+), 19 deletions(-) diff --git a/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx b/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx index 298788c7..e7aedff4 100644 --- a/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx +++ b/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx @@ -91,7 +91,9 @@ export const PluginDetailDialog = ({ } }, [projectScopeAvailable, scope]); - const operationKey = plugin ? getPluginOperationKey(plugin.pluginId, scope) : null; + const operationKey = plugin + ? getPluginOperationKey(plugin.pluginId, scope, scope !== 'user' ? projectPath : undefined) + : null; const installProgress = useStore( (s) => (operationKey ? s.pluginInstallProgress[operationKey] : undefined) ?? 'idle' ); diff --git a/src/renderer/store/slices/extensionsSlice.ts b/src/renderer/store/slices/extensionsSlice.ts index 20dbae3b..4ae2855a 100644 --- a/src/renderer/store/slices/extensionsSlice.ts +++ b/src/renderer/store/slices/extensionsSlice.ts @@ -163,8 +163,8 @@ function buildPluginIdSet(catalog: EnrichedPlugin[]): Set { return new Set(catalog.map((plugin) => plugin.pluginId)); } -function buildPluginOperationKeys(pluginId: string): string[] { - return PLUGIN_OPERATION_SCOPES.map((scope) => getPluginOperationKey(pluginId, scope)); +function isPluginOperationKeyForPlugin(operationKey: string, pluginId: string): boolean { + return operationKey.startsWith(`plugin:${pluginId}:`); } function clearPluginOperationState( @@ -181,10 +181,16 @@ function clearPluginOperationState( const nextPluginInstallProgress = { ...pluginInstallProgress }; const nextInstallErrors = { ...installErrors }; + const pluginIdsList = Array.from(pluginIds); - for (const pluginId of pluginIds) { - for (const operationKey of buildPluginOperationKeys(pluginId)) { + for (const operationKey of Object.keys(nextPluginInstallProgress)) { + if (pluginIdsList.some((pluginId) => isPluginOperationKeyForPlugin(operationKey, pluginId))) { delete nextPluginInstallProgress[operationKey]; + } + } + + for (const operationKey of Object.keys(nextInstallErrors)) { + if (pluginIdsList.some((pluginId) => isPluginOperationKeyForPlugin(operationKey, pluginId))) { delete nextInstallErrors[operationKey]; } } @@ -206,8 +212,9 @@ function clearPluginSuccessResetTimer(operationKey: string): void { } function clearPluginSuccessResetTimers(pluginIds: Set): void { - for (const pluginId of pluginIds) { - for (const operationKey of buildPluginOperationKeys(pluginId)) { + const pluginIdsList = Array.from(pluginIds); + for (const operationKey of Array.from(pluginSuccessResetTimers.keys())) { + if (pluginIdsList.some((pluginId) => isPluginOperationKeyForPlugin(operationKey, pluginId))) { clearPluginSuccessResetTimer(operationKey); } } @@ -339,8 +346,6 @@ const CLI_STATUS_UNKNOWN_MESSAGE = 'Unable to verify Claude CLI status. Open the Dashboard and check the CLI before retrying.'; const PROJECT_SCOPE_REQUIRED_MESSAGE = 'Project- and local-scoped plugins require an active project in the Extensions tab.'; -const PLUGIN_OPERATION_SCOPES: InstallScope[] = ['user', 'project', 'local']; - export const createExtensionsSlice: StateCreator = ( set, get @@ -865,11 +870,16 @@ export const createExtensionsSlice: StateCreator { if (!api.plugins) return; + const catalogProjectPathAtOperationStart = get().pluginCatalogProjectPath ?? undefined; const effectiveProjectPath = request.scope !== 'user' ? (request.projectPath ?? get().pluginCatalogProjectPath ?? undefined) : request.projectPath; - const operationKey = getPluginOperationKey(request.pluginId, request.scope); + const operationKey = getPluginOperationKey( + request.pluginId, + request.scope, + effectiveProjectPath + ); const effectiveRequest = effectiveProjectPath === request.projectPath ? request @@ -931,7 +941,12 @@ export const createExtensionsSlice: StateCreator { if (!api.plugins) return; + const catalogProjectPathAtOperationStart = get().pluginCatalogProjectPath ?? undefined; const effectiveScope = scope ?? 'user'; - const operationKey = getPluginOperationKey(pluginId, effectiveScope); const effectiveProjectPath = effectiveScope !== 'user' ? (projectPath ?? get().pluginCatalogProjectPath ?? undefined) : projectPath; + const operationKey = getPluginOperationKey(pluginId, effectiveScope, effectiveProjectPath); if (effectiveScope !== 'user' && !effectiveProjectPath) { clearPluginSuccessResetTimer(operationKey); set((prev) => ({ @@ -986,7 +1002,10 @@ export const createExtensionsSlice: StateCreator ({ vi.mock('@renderer/components/extensions/common/InstallButton', () => ({ InstallButton: ({ + state, + errorMessage, isInstalled, onInstall, onUninstall, }: { + state?: string; + errorMessage?: string; isInstalled: boolean; onInstall: () => void; onUninstall: () => void; @@ -124,6 +128,8 @@ vi.mock('@renderer/components/extensions/common/InstallButton', () => ({ { type: 'button', 'data-testid': 'install-button', + 'data-state': state, + 'data-error-message': errorMessage, onClick: () => (isInstalled ? onUninstall() : onInstall()), }, isInstalled ? 'Uninstall' : 'Install' @@ -150,6 +156,7 @@ vi.mock('lucide-react', () => { }); import { PluginDetailDialog } from '@renderer/components/extensions/plugins/PluginDetailDialog'; +import { getPluginOperationKey } from '@shared/utils/extensionNormalizers'; const makePlugin = (): EnrichedPlugin => ({ pluginId: 'context7@claude-plugins-official', @@ -270,4 +277,49 @@ describe('PluginDetailDialog project context', () => { await Promise.resolve(); }); }); + + it('reads project-scope action state from the current tab project path', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const plugin = makePlugin(); + + storeState.pluginInstallProgress = { + [getPluginOperationKey(plugin.pluginId, 'project', '/tmp/tab-project')]: 'pending', + }; + storeState.installErrors = { + [getPluginOperationKey(plugin.pluginId, 'project', '/tmp/other-project')]: 'Wrong project', + }; + + await act(async () => { + root.render( + React.createElement(PluginDetailDialog, { + plugin, + open: true, + onClose: vi.fn(), + projectPath: '/tmp/tab-project', + }) + ); + await Promise.resolve(); + }); + + const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; + const installButton = host.querySelector('[data-testid="install-button"]') as HTMLButtonElement; + expect(scopeSelect).not.toBeNull(); + expect(installButton).not.toBeNull(); + + await act(async () => { + scopeSelect.value = 'project'; + scopeSelect.dispatchEvent(new Event('change', { bubbles: true })); + await Promise.resolve(); + }); + + expect(installButton.getAttribute('data-state')).toBe('pending'); + expect(installButton.getAttribute('data-error-message')).toBeNull(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/store/extensionsSlice.test.ts b/test/renderer/store/extensionsSlice.test.ts index 350e074c..065b6555 100644 --- a/test/renderer/store/extensionsSlice.test.ts +++ b/test/renderer/store/extensionsSlice.test.ts @@ -153,8 +153,11 @@ const makeReadyCliStatus = () => ({ providers: [], }); -const pluginOperationKey = (pluginId: string, scope: 'user' | 'project' | 'local' = 'user') => - getPluginOperationKey(pluginId, scope); +const pluginOperationKey = ( + pluginId: string, + scope: 'user' | 'project' | 'local' = 'user', + projectPath?: string +) => getPluginOperationKey(pluginId, scope, projectPath); const mcpOperationKey = ( registryId: string, scope: 'user' | 'project' | 'local' | 'global' = 'user', @@ -574,6 +577,33 @@ describe('extensionsSlice', () => { }); }); + it('keys project-scope install state by project path and refreshes that same project context', async () => { + store.setState({ + cliStatus: makeReadyCliStatus(), + pluginCatalogProjectPath: '/tmp/project-b', + }); + (api.plugins!.getAll as ReturnType).mockResolvedValue([]); + (api.plugins!.install as ReturnType).mockResolvedValue({ state: 'success' }); + + await store.getState().installPlugin({ + pluginId: 'project@m', + scope: 'project', + projectPath: '/tmp/project-a', + }); + + expect( + store.getState().pluginInstallProgress[ + pluginOperationKey('project@m', 'project', '/tmp/project-a') + ] + ).toBe('success'); + expect( + store.getState().pluginInstallProgress[ + pluginOperationKey('project@m', 'project', '/tmp/project-b') + ] + ).toBeUndefined(); + expect(api.plugins!.getAll).toHaveBeenLastCalledWith('/tmp/project-a', true); + }); + it('fails fast for project scope when there is no active project path', async () => { store.setState({ cliStatus: makeReadyCliStatus(), pluginCatalogProjectPath: null }); @@ -673,6 +703,26 @@ describe('extensionsSlice', () => { expect(api.plugins!.uninstall).toHaveBeenCalledWith('project@m', 'project', '/tmp/project-a'); }); + it('keys project-scope uninstall state by project path and refreshes that same project context', async () => { + store.setState({ pluginCatalogProjectPath: '/tmp/project-b' }); + (api.plugins!.getAll as ReturnType).mockResolvedValue([]); + (api.plugins!.uninstall as ReturnType).mockResolvedValue({ state: 'success' }); + + await store.getState().uninstallPlugin('project@m', 'project', '/tmp/project-a'); + + expect( + store.getState().pluginInstallProgress[ + pluginOperationKey('project@m', 'project', '/tmp/project-a') + ] + ).toBe('success'); + expect( + store.getState().pluginInstallProgress[ + pluginOperationKey('project@m', 'project', '/tmp/project-b') + ] + ).toBeUndefined(); + expect(api.plugins!.getAll).toHaveBeenLastCalledWith('/tmp/project-a', true); + }); + it('fails fast for project uninstall when there is no active project path', async () => { store.setState({ pluginCatalogProjectPath: null }); diff --git a/test/shared/utils/extensionNormalizers.test.ts b/test/shared/utils/extensionNormalizers.test.ts index 979c318d..7e37218e 100644 --- a/test/shared/utils/extensionNormalizers.test.ts +++ b/test/shared/utils/extensionNormalizers.test.ts @@ -157,11 +157,17 @@ describe('buildPluginId', () => { }); describe('getPluginOperationKey', () => { - it('namespaces plugin operation keys by scope', () => { - expect(getPluginOperationKey('context7@claude-plugins-official', 'local')).toBe( - 'plugin:context7@claude-plugins-official:local', + it('namespaces user-scope plugin operation keys without a project suffix', () => { + expect(getPluginOperationKey('context7@claude-plugins-official', 'user')).toBe( + 'plugin:context7@claude-plugins-official:user', ); }); + + it('namespaces repo-scoped plugin operation keys by project path', () => { + expect( + getPluginOperationKey('context7@claude-plugins-official', 'local', '/tmp/project'), + ).toBe('plugin:context7@claude-plugins-official:local:/tmp/project'); + }); }); describe('getMcpOperationKey', () => { From 5007f3eebb6b062ae2c10c677fac40a8198366e8 Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 17 Apr 2026 14:44:05 +0300 Subject: [PATCH 094/121] fix(extensions): scope mcp api key autofill by install target --- .../extensions/mcp/CustomMcpServerDialog.tsx | 50 +++++++++-- .../extensions/mcp/McpServerDetailDialog.tsx | 40 ++++++--- .../mcp/CustomMcpServerDialog.test.ts | 85 ++++++++++++++++++- .../mcp/McpServerDetailDialog.test.ts | 78 ++++++++++++++++- 4 files changed, 232 insertions(+), 21 deletions(-) diff --git a/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx b/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx index bba98439..66fc266d 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'; @@ -92,11 +92,15 @@ export const CustomMcpServerDialog = ({ const [envVars, setEnvVars] = useState([]); const [error, setError] = useState(null); const [installing, setInstalling] = useState(false); + const autoFilledValuesRef = useRef>({}); const envVarLookupNames = envVars .map((entry) => entry.key.trim()) .filter(Boolean) .sort() .join('\0'); + const apiKeyLookupProjectPath = isProjectScopedMcpScope(scope) + ? (projectPath ?? undefined) + : undefined; // Reset on open useEffect(() => { @@ -112,6 +116,7 @@ export const CustomMcpServerDialog = ({ setEnvVars([]); setError(null); setInstalling(false); + autoFilledValuesRef.current = {}; } }, [defaultSharedScope, open]); @@ -128,19 +133,50 @@ export const CustomMcpServerDialog = ({ const envVarNames = envVars.map((e) => e.key.trim()).filter(Boolean); if (envVarNames.length === 0) return; - void api.apiKeys.lookup(envVarNames, projectPath ?? undefined).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 } ); - }, [envVarLookupNames, envVars, open, projectPath]); // eslint-disable-line react-hooks/exhaustive-deps + }, [apiKeyLookupProjectPath, envVarLookupNames, open]); // eslint-disable-line react-hooks/exhaustive-deps const handleInstall = async () => { setError(null); diff --git a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx index 8ac44ee7..10fb5579 100644 --- a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx +++ b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx @@ -3,7 +3,7 @@ * Uses Radix UI Kit for all form elements. */ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { api } from '@renderer/api'; import { Badge } from '@renderer/components/ui/badge'; @@ -92,6 +92,7 @@ export const McpServerDetailDialog = ({ const [headers, setHeaders] = useState([]); const [imgError, setImgError] = useState(false); const [autoFilledFields, setAutoFilledFields] = useState>(new Set()); + const autoFilledValuesRef = useRef>({}); const normalizedInstalledEntries = installedEntries.length ? installedEntries : installedEntry @@ -115,6 +116,9 @@ export const McpServerDetailDialog = ({ .map((entry) => entry.name) .sort() .join('\0') ?? ''; + const apiKeyLookupProjectPath = isProjectScopedMcpScope(scope) + ? (projectPath ?? undefined) + : undefined; // Initialize form when dialog opens or server changes useEffect(() => { @@ -138,6 +142,7 @@ export const McpServerDetailDialog = ({ setScope((preferredInstalledEntry?.scope as Scope | undefined) ?? defaultSharedScope); setImgError(false); setAutoFilledFields(new Set()); + autoFilledValuesRef.current = {}; }, [ defaultSharedScope, open, @@ -165,23 +170,38 @@ export const McpServerDetailDialog = ({ if (!server || !open || server.envVars.length === 0 || !api.apiKeys) return; const envVarNames = server.envVars.map((e) => e.name); - void api.apiKeys.lookup(envVarNames, projectPath ?? undefined).then( + void api.apiKeys.lookup(envVarNames, apiKeyLookupProjectPath).then( (results) => { - if (results.length === 0) return; - const filled = new Set(); - const values: Record = {}; + const previousAutoFilledValues = autoFilledValuesRef.current; + const nextAutoFilledValues: Record = {}; for (const r of results) { - values[r.envVarName] = r.value; - filled.add(r.envVarName); + nextAutoFilledValues[r.envVarName] = r.value; } - setEnvValues((prev) => ({ ...prev, ...values })); - setAutoFilledFields(filled); + setEnvValues((prev) => { + const next = { ...prev }; + + for (const [envVarName, previousValue] of Object.entries(previousAutoFilledValues)) { + if (!(envVarName in nextAutoFilledValues) && next[envVarName] === previousValue) { + next[envVarName] = ''; + } + } + + for (const [envVarName, nextValue] of Object.entries(nextAutoFilledValues)) { + if (!next[envVarName] || next[envVarName] === previousAutoFilledValues[envVarName]) { + next[envVarName] = nextValue; + } + } + + return next; + }); + setAutoFilledFields(new Set(Object.keys(nextAutoFilledValues))); + autoFilledValuesRef.current = nextAutoFilledValues; }, () => { // Silently fail — auto-fill is supplementary } ); - }, [envVarLookupNames, open, projectPath, server?.id]); // eslint-disable-line react-hooks/exhaustive-deps + }, [apiKeyLookupProjectPath, envVarLookupNames, open, server?.id]); // eslint-disable-line react-hooks/exhaustive-deps if (!server) return <>; diff --git a/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts b/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts index 29e23faa..177ffa11 100644 --- a/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts +++ b/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts @@ -180,7 +180,7 @@ describe('CustomMcpServerDialog project scope', () => { }); }); - it('passes projectPath into API key lookup for project-aware autofill', async () => { + it('looks up project-scoped API keys only when project scope is selected', async () => { const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); @@ -213,7 +213,88 @@ describe('CustomMcpServerDialog project scope', () => { await Promise.resolve(); }); - expect(lookupMock).toHaveBeenCalledWith(['CONTEXT7_API_KEY'], '/tmp/custom-mcp-project'); + expect(lookupMock).toHaveBeenCalledWith(['CONTEXT7_API_KEY'], undefined); + + const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; + await act(async () => { + setNativeValue(scopeSelect, 'project', 'change'); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(lookupMock).toHaveBeenLastCalledWith(['CONTEXT7_API_KEY'], '/tmp/custom-mcp-project'); + + await act(async () => { + setNativeValue(scopeSelect, 'user', 'change'); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(lookupMock).toHaveBeenLastCalledWith(['CONTEXT7_API_KEY'], undefined); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('clears stale project auto-filled values when switching back to user scope', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + lookupMock + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ envVarName: 'CONTEXT7_API_KEY', value: 'project-secret' }]) + .mockResolvedValueOnce([]); + + await act(async () => { + root.render( + React.createElement(CustomMcpServerDialog, { + open: true, + onClose: vi.fn(), + projectPath: '/tmp/custom-mcp-project', + }) + ); + await Promise.resolve(); + }); + + const addEnvButton = Array.from(host.querySelectorAll('button')).find((button) => + button.textContent?.includes('Add') + ) as HTMLButtonElement; + await act(async () => { + addEnvButton.click(); + await Promise.resolve(); + }); + + const envKeyInput = host.querySelector( + 'input[placeholder="ENV_VAR_NAME"]' + ) as HTMLInputElement; + const envValueInput = host.querySelector( + 'input[placeholder="value"]' + ) as HTMLInputElement; + const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; + + await act(async () => { + setNativeValue(envKeyInput, 'CONTEXT7_API_KEY', 'input'); + await Promise.resolve(); + await Promise.resolve(); + }); + + await act(async () => { + setNativeValue(scopeSelect, 'project', 'change'); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(envValueInput.value).toBe('project-secret'); + + await act(async () => { + setNativeValue(scopeSelect, 'user', 'change'); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(envValueInput.value).toBe(''); await act(async () => { root.unmount(); diff --git a/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts b/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts index 18c78c6d..14fe2b8c 100644 --- a/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts +++ b/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts @@ -278,7 +278,7 @@ describe('McpServerDetailDialog installed entry handling', () => { }); }); - it('passes projectPath into API key lookup for project-aware autofill', async () => { + it('looks up project-scoped API keys only when project scope is selected', async () => { const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); @@ -302,7 +302,81 @@ describe('McpServerDetailDialog installed entry handling', () => { await Promise.resolve(); }); - expect(lookupMock).toHaveBeenCalledWith(['CONTEXT7_API_KEY'], '/tmp/project-context7'); + expect(lookupMock).toHaveBeenCalledWith(['CONTEXT7_API_KEY'], undefined); + + const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; + await act(async () => { + scopeSelect.value = 'project'; + scopeSelect.dispatchEvent(new Event('change', { bubbles: true })); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(lookupMock).toHaveBeenLastCalledWith(['CONTEXT7_API_KEY'], '/tmp/project-context7'); + + await act(async () => { + scopeSelect.value = 'user'; + scopeSelect.dispatchEvent(new Event('change', { bubbles: true })); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(lookupMock).toHaveBeenLastCalledWith(['CONTEXT7_API_KEY'], undefined); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('clears stale project auto-filled values when switching back to user scope', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const server = makeServer(); + server.envVars = [{ name: 'CONTEXT7_API_KEY', isSecret: true }]; + lookupMock + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ envVarName: 'CONTEXT7_API_KEY', value: 'project-secret' }]) + .mockResolvedValueOnce([]); + + await act(async () => { + root.render( + React.createElement(McpServerDetailDialog, { + server, + isInstalled: false, + installedEntry: null, + diagnostic: null, + diagnosticsLoading: false, + projectPath: '/tmp/project-context7', + open: true, + onClose: vi.fn(), + }) + ); + await Promise.resolve(); + await Promise.resolve(); + }); + + const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; + const envValueInput = host.querySelector('input[type="password"]') as HTMLInputElement; + + await act(async () => { + scopeSelect.value = 'project'; + scopeSelect.dispatchEvent(new Event('change', { bubbles: true })); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(envValueInput.value).toBe('project-secret'); + + await act(async () => { + scopeSelect.value = 'user'; + scopeSelect.dispatchEvent(new Event('change', { bubbles: true })); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(envValueInput.value).toBe(''); await act(async () => { root.unmount(); From 8423656b9729501910b262a53034b232dd029ddd Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 17 Apr 2026 14:46:43 +0300 Subject: [PATCH 095/121] fix(extensions): use safe legacy multimodel capability fallback --- .../runtime/ClaudeMultimodelBridgeService.ts | 11 +++-- .../utils/providerExtensionCapabilities.ts | 35 ++++++++++++++- .../ClaudeMultimodelBridgeService.test.ts | 39 ++++++++++++++++ .../providerExtensionCapabilities.test.ts | 44 +++++++++++++++++++ 4 files changed, 125 insertions(+), 4 deletions(-) create mode 100644 test/shared/utils/providerExtensionCapabilities.test.ts diff --git a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts index 98b6063f..53a6bb17 100644 --- a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts +++ b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts @@ -1,7 +1,10 @@ import { execCli } from '@main/utils/childProcess'; import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; import { createLogger } from '@shared/utils/logger'; -import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities'; +import { + createDefaultCliExtensionCapabilities, + createLegacyRuntimeFallbackCliExtensionCapabilities, +} from '@shared/utils/providerExtensionCapabilities'; import { resolveGeminiRuntimeAuth } from './geminiRuntimeAuth'; import { buildProviderAwareCliEnv } from './providerAwareCliEnv'; @@ -145,7 +148,7 @@ function createDefaultProviderStatus(providerId: CliProviderId): CliProviderStat capabilities: { teamLaunch: false, oneShot: false, - extensions: createDefaultCliExtensionCapabilities(), + extensions: createLegacyRuntimeFallbackCliExtensionCapabilities(), }, selectedBackendId: null, resolvedBackendId: null, @@ -159,7 +162,9 @@ function createDefaultProviderStatus(providerId: CliProviderId): CliProviderStat function mapRuntimeExtensionCapabilities( capabilities?: RuntimeExtensionCapabilitiesResponse ): CliProviderStatus['capabilities']['extensions'] { - const defaults = createDefaultCliExtensionCapabilities(); + const defaults = capabilities + ? createDefaultCliExtensionCapabilities() + : createLegacyRuntimeFallbackCliExtensionCapabilities(); return { plugins: { diff --git a/src/shared/utils/providerExtensionCapabilities.ts b/src/shared/utils/providerExtensionCapabilities.ts index c6d03b49..4d5d0c90 100644 --- a/src/shared/utils/providerExtensionCapabilities.ts +++ b/src/shared/utils/providerExtensionCapabilities.ts @@ -10,6 +10,27 @@ const SUPPORTED_SHARED_CAPABILITY: CliExtensionCapability = { reason: null, }; +const LEGACY_MULTIMODEL_FALLBACK_CAPABILITIES: CliExtensionCapabilities = { + plugins: { + status: 'unsupported', + ownership: 'shared', + reason: + 'This runtime does not declare plugin capability support. Upgrade the runtime to manage plugins here.', + }, + mcp: { + status: 'read-only', + ownership: 'shared', + reason: + 'This runtime does not declare MCP management support. Upgrade the runtime to install or remove MCP servers here.', + }, + skills: { + ...SUPPORTED_SHARED_CAPABILITY, + }, + apiKeys: { + ...SUPPORTED_SHARED_CAPABILITY, + }, +}; + export function createDefaultCliExtensionCapabilities( overrides?: Partial ): CliExtensionCapabilities { @@ -22,10 +43,22 @@ export function createDefaultCliExtensionCapabilities( }; } +export function createLegacyRuntimeFallbackCliExtensionCapabilities( + overrides?: Partial +): CliExtensionCapabilities { + return { + plugins: { ...LEGACY_MULTIMODEL_FALLBACK_CAPABILITIES.plugins }, + mcp: { ...LEGACY_MULTIMODEL_FALLBACK_CAPABILITIES.mcp }, + skills: { ...LEGACY_MULTIMODEL_FALLBACK_CAPABILITIES.skills }, + apiKeys: { ...LEGACY_MULTIMODEL_FALLBACK_CAPABILITIES.apiKeys }, + ...overrides, + }; +} + export function getCliProviderExtensionCapabilities( provider: Pick ): CliExtensionCapabilities { - return provider.capabilities.extensions ?? createDefaultCliExtensionCapabilities(); + return provider.capabilities.extensions ?? createLegacyRuntimeFallbackCliExtensionCapabilities(); } export function getCliProviderExtensionCapability( diff --git a/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts index d6cde5c7..d69ea045 100644 --- a/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts +++ b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts @@ -254,4 +254,43 @@ describe('ClaudeMultimodelBridgeService', () => { }); expect(provider.statusMessage).toContain('ANTHROPIC_API_KEY'); }); + + it('falls back conservatively when the runtime omits extension capability metadata', async () => { + execCliMock.mockResolvedValue({ + stdout: JSON.stringify({ + providers: { + codex: { + supported: true, + authenticated: true, + verificationState: 'verified', + canLoginFromUi: true, + capabilities: { + teamLaunch: true, + oneShot: true, + }, + }, + }, + }), + stderr: '', + exitCode: 0, + }); + + const { ClaudeMultimodelBridgeService } = + await import('@main/services/runtime/ClaudeMultimodelBridgeService'); + const service = new ClaudeMultimodelBridgeService(); + + const provider = await service.getProviderStatus('/mock/agent_teams_orchestrator', 'codex'); + + expect(provider).toMatchObject({ + providerId: 'codex', + capabilities: { + extensions: { + plugins: { status: 'unsupported' }, + mcp: { status: 'read-only' }, + skills: { status: 'supported' }, + apiKeys: { status: 'supported' }, + }, + }, + }); + }); }); diff --git a/test/shared/utils/providerExtensionCapabilities.test.ts b/test/shared/utils/providerExtensionCapabilities.test.ts new file mode 100644 index 00000000..da8dd4a6 --- /dev/null +++ b/test/shared/utils/providerExtensionCapabilities.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; + +import { + createLegacyRuntimeFallbackCliExtensionCapabilities, + getCliProviderExtensionCapabilities, +} from '@shared/utils/providerExtensionCapabilities'; + +import type { CliProviderStatus } from '@shared/types'; + +function makeProvider( + overrides?: Partial +): Pick { + return { + capabilities: { + teamLaunch: false, + oneShot: false, + ...(overrides?.capabilities ?? {}), + } as CliProviderStatus['capabilities'], + }; +} + +describe('providerExtensionCapabilities', () => { + it('returns conservative fallback capabilities when runtime omits extension metadata', () => { + const capabilities = getCliProviderExtensionCapabilities( + makeProvider({ + capabilities: { + teamLaunch: true, + oneShot: true, + } as CliProviderStatus['capabilities'], + }) + ); + + expect(capabilities).toEqual(createLegacyRuntimeFallbackCliExtensionCapabilities()); + }); + + it('keeps plugins unsupported and mcp read-only in the legacy multimodel fallback', () => { + const capabilities = createLegacyRuntimeFallbackCliExtensionCapabilities(); + + expect(capabilities.plugins.status).toBe('unsupported'); + expect(capabilities.mcp.status).toBe('read-only'); + expect(capabilities.skills.status).toBe('supported'); + expect(capabilities.apiKeys.status).toBe('supported'); + }); +}); From 14a38212c23aec287c8711e926c60df32dc4cd0c Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 17 Apr 2026 14:51:58 +0300 Subject: [PATCH 096/121] fix(extensions): gate codex skill overlays by runtime --- .../extensions/skills/SkillEditorDialog.tsx | 20 ++++- .../extensions/skills/SkillImportDialog.tsx | 14 ++- .../extensions/skills/SkillsPanel.tsx | 27 ++++-- src/shared/utils/skillRoots.ts | 23 +++++ .../skills/SkillEditorDialog.test.ts | 88 +++++++++++++++++++ .../skills/SkillImportDialog.test.ts | 38 ++++++++ .../extensions/skills/SkillsPanel.test.ts | 65 +++++++++++++- 7 files changed, 264 insertions(+), 11 deletions(-) diff --git a/src/renderer/components/extensions/skills/SkillEditorDialog.tsx b/src/renderer/components/extensions/skills/SkillEditorDialog.tsx index 6375f148..b377ac16 100644 --- a/src/renderer/components/extensions/skills/SkillEditorDialog.tsx +++ b/src/renderer/components/extensions/skills/SkillEditorDialog.tsx @@ -52,6 +52,7 @@ interface SkillEditorDialogProps { mode: EditorMode; projectPath: string | null; projectLabel: string | null; + allowCodexRootKind: boolean; detail: SkillDetail | null; onClose: () => void; onSaved: (skillId: string | null) => void; @@ -70,6 +71,7 @@ export const SkillEditorDialog = ({ mode, projectPath, projectLabel, + allowCodexRootKind, detail, onClose, onSaved, @@ -220,7 +222,7 @@ export const SkillEditorDialog = ({ setReviewLoading(false); setSaveLoading(false); setMutationError(null); - }, [detail, mode, open, projectPath]); + }, [allowCodexRootKind, detail, mode, open, projectPath]); useEffect(() => { if (open) { @@ -240,6 +242,12 @@ export const SkillEditorDialog = ({ } }, [mode, open, projectPath, scope]); + useEffect(() => { + if (open && mode === 'create' && rootKind === 'codex' && !allowCodexRootKind) { + setRootKind('claude'); + } + }, [allowCodexRootKind, mode, open, rootKind]); + useEffect(() => { rawContentRef.current = rawContent; }, [rawContent]); @@ -291,6 +299,14 @@ export const SkillEditorDialog = ({ ); const canUseProjectScope = Boolean(projectPath); + const visibleRootDefinitions = useMemo( + () => + SKILL_ROOT_DEFINITIONS.filter( + (definition) => + definition.rootKind !== 'codex' || allowCodexRootKind || detail?.item.rootKind === 'codex' + ), + [allowCodexRootKind, detail?.item.rootKind] + ); const instructionsLocked = manualRawEdit || customMarkdownDetected; const title = mode === 'create' ? 'Create skill' : 'Edit skill'; const descriptionText = @@ -436,7 +452,7 @@ export const SkillEditorDialog = ({ - {SKILL_ROOT_DEFINITIONS.map((definition) => ( + {visibleRootDefinitions.map((definition) => ( {definition.directoryName} {definition.audience === 'codex' ? ' - Codex only' : ' - Shared'} diff --git a/src/renderer/components/extensions/skills/SkillImportDialog.tsx b/src/renderer/components/extensions/skills/SkillImportDialog.tsx index 942cbdd9..5270af35 100644 --- a/src/renderer/components/extensions/skills/SkillImportDialog.tsx +++ b/src/renderer/components/extensions/skills/SkillImportDialog.tsx @@ -56,6 +56,7 @@ interface SkillImportDialogProps { open: boolean; projectPath: string | null; projectLabel: string | null; + allowCodexRootKind: boolean; onClose: () => void; onImported: (skillId: string | null) => void; } @@ -64,6 +65,7 @@ export const SkillImportDialog = ({ open, projectPath, projectLabel, + allowCodexRootKind, onClose, onImported, }: SkillImportDialogProps): React.JSX.Element => { @@ -120,6 +122,16 @@ export const SkillImportDialog = ({ } }, [open, projectPath, scope]); + useEffect(() => { + if (open && rootKind === 'codex' && !allowCodexRootKind) { + setRootKind('claude'); + } + }, [allowCodexRootKind, open, rootKind]); + + const visibleRootDefinitions = SKILL_ROOT_DEFINITIONS.filter( + (definition) => definition.rootKind !== 'codex' || allowCodexRootKind + ); + async function handleChooseFolder(): Promise { const selected = await api.config.selectFolders(); const first = selected[0]; @@ -280,7 +292,7 @@ export const SkillImportDialog = ({ - {SKILL_ROOT_DEFINITIONS.map((definition) => ( + {visibleRootDefinitions.map((definition) => ( {definition.directoryName} {definition.audience === 'codex' ? ' - Codex only' : ' - Shared'} diff --git a/src/renderer/components/extensions/skills/SkillsPanel.tsx b/src/renderer/components/extensions/skills/SkillsPanel.tsx index 474c08d4..cd9fabe5 100644 --- a/src/renderer/components/extensions/skills/SkillsPanel.tsx +++ b/src/renderer/components/extensions/skills/SkillsPanel.tsx @@ -10,6 +10,7 @@ import { formatSkillRootKind, getSkillAudience, getSkillAudienceLabel, + isCodexSkillOverlayAvailable, } from '@shared/utils/skillRoots'; import { AlertTriangle, @@ -126,11 +127,16 @@ export const SkillsPanel = ({ () => [...projectSkills, ...userSkills], [projectSkills, userSkills] ); + const codexSkillOverlayAvailable = useMemo( + () => isCodexSkillOverlayAvailable(cliStatus), + [cliStatus] + ); const codexOnlySkillsCount = useMemo( () => mergedSkills.filter((skill) => getSkillAudience(skill.rootKind) === 'codex').length, [mergedSkills] ); const sharedSkillsCount = mergedSkills.length - codexOnlySkillsCount; + const showCodexOnlyUi = codexSkillOverlayAvailable || codexOnlySkillsCount > 0; const selectedDetail = selectedSkillId ? (detailById[selectedSkillId] ?? null) : null; selectedSkillItemRef.current = selectedSkillId ? (selectedDetail?.item ?? mergedSkills.find((skill) => skill.id === selectedSkillId) ?? null) @@ -266,8 +272,10 @@ export const SkillsPanel = ({

Use personal skills for habits you want everywhere. Use project skills for workflows - that only make sense inside one codebase. Use `.codex` when a skill should stay - Codex-only. + that only make sense inside one codebase. + {codexSkillOverlayAvailable + ? ' Use `.codex` when a skill should stay Codex-only.' + : ' Existing `.codex` skills stay editable here, but new Codex-only skills need the Codex runtime enabled.'}

@@ -348,9 +356,11 @@ export const SkillsPanel = ({ {sharedSkillsCount} shared - - {codexOnlySkillsCount} Codex only - + {showCodexOnlyUi && ( + + {codexOnlySkillsCount} Codex only + + )}
@@ -363,7 +373,9 @@ export const SkillsPanel = ({ ['project', 'Project'], ['personal', 'Personal'], ['shared', 'Shared'], - ['codex-only', 'Codex only'], + ...(showCodexOnlyUi + ? ([['codex-only', 'Codex only']] as [SkillsQuickFilter, string][]) + : []), ['needs-attention', 'Needs attention'], ['has-scripts', 'Has scripts'], ] as [SkillsQuickFilter, string][] @@ -616,6 +628,7 @@ export const SkillsPanel = ({ mode="create" projectPath={projectPath} projectLabel={projectLabel} + allowCodexRootKind={codexSkillOverlayAvailable} detail={null} onClose={() => setCreateOpen(false)} onSaved={(skillId) => { @@ -631,6 +644,7 @@ export const SkillsPanel = ({ mode="edit" projectPath={projectPath} projectLabel={projectLabel} + allowCodexRootKind={codexSkillOverlayAvailable} detail={editingDetail} onClose={() => { setEditOpen(false); @@ -648,6 +662,7 @@ export const SkillsPanel = ({ open={importOpen} projectPath={projectPath} projectLabel={projectLabel} + allowCodexRootKind={codexSkillOverlayAvailable} onClose={() => setImportOpen(false)} onImported={(skillId) => { setImportOpen(false); diff --git a/src/shared/utils/skillRoots.ts b/src/shared/utils/skillRoots.ts index e2f84a42..4a0e3ae6 100644 --- a/src/shared/utils/skillRoots.ts +++ b/src/shared/utils/skillRoots.ts @@ -1,4 +1,10 @@ +import { + getCliProviderExtensionCapability, + isCliExtensionCapabilityAvailable, +} from './providerExtensionCapabilities'; + import type { TeamProviderId } from '@shared/types'; +import type { CliInstallationStatus } from '@shared/types'; import type { SkillRootKind } from '@shared/types/extensions'; export type SkillAudience = 'shared' | 'codex'; @@ -59,3 +65,20 @@ export function isSkillAvailableForProvider( ): boolean { return getSkillAudience(rootKind) === 'shared' || providerId === 'codex'; } + +export function isCodexSkillOverlayAvailable( + cliStatus: Pick | null | undefined +): boolean { + if (cliStatus?.flavor !== 'agent_teams_orchestrator') { + return false; + } + + const codexProvider = cliStatus.providers.find((provider) => provider.providerId === 'codex'); + if (!codexProvider?.supported) { + return false; + } + + return isCliExtensionCapabilityAvailable( + getCliProviderExtensionCapability(codexProvider, 'skills') + ); +} diff --git a/test/renderer/components/extensions/skills/SkillEditorDialog.test.ts b/test/renderer/components/extensions/skills/SkillEditorDialog.test.ts index fdf1f0a0..60cc722d 100644 --- a/test/renderer/components/extensions/skills/SkillEditorDialog.test.ts +++ b/test/renderer/components/extensions/skills/SkillEditorDialog.test.ts @@ -181,6 +181,20 @@ function makeDetail(rawContent: string): SkillDetail { }; } +function makeCodexDetail(rawContent: string): SkillDetail { + const detail = makeDetail(rawContent); + return { + ...detail, + item: { + ...detail.item, + rootKind: 'codex', + discoveryRoot: '/tmp/project/.codex/skills', + skillDir: '/tmp/project/.codex/skills/custom-skill', + skillFile: '/tmp/project/.codex/skills/custom-skill/SKILL.md', + }, + }; +} + describe('SkillEditorDialog', () => { beforeEach(() => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); @@ -214,6 +228,7 @@ This file uses a freeform layout without generated sections. mode: 'edit', projectPath: '/tmp/project', projectLabel: 'Project', + allowCodexRootKind: true, detail, onClose: vi.fn(), onSaved: vi.fn(), @@ -272,6 +287,7 @@ This file uses a freeform layout without generated sections. mode: 'create', projectPath: '/tmp/project', projectLabel: 'Project', + allowCodexRootKind: true, detail: null, onClose: vi.fn(), onSaved: vi.fn(), @@ -298,6 +314,7 @@ This file uses a freeform layout without generated sections. mode: 'create', projectPath: '/tmp/project', projectLabel: 'Project', + allowCodexRootKind: true, detail: null, onClose: vi.fn(), onSaved: vi.fn(), @@ -326,6 +343,7 @@ This file uses a freeform layout without generated sections. mode: 'create', projectPath: '/tmp/project', projectLabel: 'Project', + allowCodexRootKind: true, detail: null, onClose: vi.fn(), onSaved: vi.fn(), @@ -360,4 +378,74 @@ This file uses a freeform layout without generated sections. await Promise.resolve(); }); }); + + it('hides the codex root option in create mode when codex runtime is unavailable', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(SkillEditorDialog, { + open: true, + mode: 'create', + projectPath: '/tmp/project', + projectLabel: 'Project', + allowCodexRootKind: false, + detail: null, + onClose: vi.fn(), + onSaved: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + const selects = host.querySelectorAll('select'); + const rootSelect = selects[1] as HTMLSelectElement; + expect(Array.from(rootSelect.options).some((option) => option.value === 'codex')).toBe(false); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('keeps the codex root visible when editing an existing codex-only skill', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const detail = makeCodexDetail(`--- +name: Codex Skill +description: Codex markdown skill +--- + +# Codex Skill +`); + + await act(async () => { + root.render( + React.createElement(SkillEditorDialog, { + open: true, + mode: 'edit', + projectPath: '/tmp/project', + projectLabel: 'Project', + allowCodexRootKind: false, + detail, + onClose: vi.fn(), + onSaved: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + const selects = host.querySelectorAll('select'); + const rootSelect = selects[1] as HTMLSelectElement; + expect(rootSelect.value).toBe('codex'); + expect(Array.from(rootSelect.options).some((option) => option.value === 'codex')).toBe(true); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/components/extensions/skills/SkillImportDialog.test.ts b/test/renderer/components/extensions/skills/SkillImportDialog.test.ts index 46a43035..5d572d23 100644 --- a/test/renderer/components/extensions/skills/SkillImportDialog.test.ts +++ b/test/renderer/components/extensions/skills/SkillImportDialog.test.ts @@ -132,6 +132,7 @@ describe('SkillImportDialog', () => { open: true, projectPath: null, projectLabel: null, + allowCodexRootKind: true, onClose: vi.fn(), onImported: vi.fn(), }) @@ -167,6 +168,7 @@ describe('SkillImportDialog', () => { open: true, projectPath: null, projectLabel: null, + allowCodexRootKind: true, onClose: vi.fn(), onImported: vi.fn(), }) @@ -232,6 +234,7 @@ describe('SkillImportDialog', () => { open: true, projectPath: null, projectLabel: null, + allowCodexRootKind: true, onClose: vi.fn(), onImported: vi.fn(), }) @@ -268,6 +271,7 @@ describe('SkillImportDialog', () => { open: true, projectPath: '/tmp/project-a', projectLabel: 'Project A', + allowCodexRootKind: true, onClose: vi.fn(), onImported: vi.fn(), }) @@ -284,6 +288,7 @@ describe('SkillImportDialog', () => { open: true, projectPath: null, projectLabel: null, + allowCodexRootKind: true, onClose: vi.fn(), onImported: vi.fn(), }) @@ -332,6 +337,7 @@ describe('SkillImportDialog', () => { open: true, projectPath: null, projectLabel: null, + allowCodexRootKind: true, onClose: vi.fn(), onImported: vi.fn(), }) @@ -364,6 +370,7 @@ describe('SkillImportDialog', () => { open: false, projectPath: null, projectLabel: null, + allowCodexRootKind: true, onClose: vi.fn(), onImported: vi.fn(), }) @@ -390,6 +397,7 @@ describe('SkillImportDialog', () => { open: true, projectPath: null, projectLabel: null, + allowCodexRootKind: true, onClose: vi.fn(), onImported: vi.fn(), }) @@ -438,6 +446,7 @@ describe('SkillImportDialog', () => { open: true, projectPath: null, projectLabel: null, + allowCodexRootKind: true, onClose: vi.fn(), onImported: vi.fn(), }) @@ -471,4 +480,33 @@ describe('SkillImportDialog', () => { await Promise.resolve(); }); }); + + it('hides the codex root option when codex runtime is unavailable', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(SkillImportDialog, { + open: true, + projectPath: null, + projectLabel: null, + allowCodexRootKind: false, + onClose: vi.fn(), + onImported: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + const selects = host.querySelectorAll('select'); + const rootSelect = selects[1] as HTMLSelectElement; + expect(Array.from(rootSelect.options).some((option) => option.value === 'codex')).toBe(false); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/components/extensions/skills/SkillsPanel.test.ts b/test/renderer/components/extensions/skills/SkillsPanel.test.ts index b78e559d..d8864a79 100644 --- a/test/renderer/components/extensions/skills/SkillsPanel.test.ts +++ b/test/renderer/components/extensions/skills/SkillsPanel.test.ts @@ -2,6 +2,7 @@ import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { CliInstallationStatus } from '@shared/types'; import type { SkillCatalogItem } from '@shared/types/extensions'; interface StoreState { @@ -12,6 +13,7 @@ interface StoreState { skillsDetailsById: Record; skillsUserCatalog: SkillCatalogItem[]; skillsProjectCatalogByProjectPath: Record; + cliStatus: CliInstallationStatus | null; } const storeState = {} as StoreState; @@ -107,11 +109,19 @@ vi.mock('@renderer/components/extensions/skills/SkillDetailDialog', () => ({ })); vi.mock('@renderer/components/extensions/skills/SkillEditorDialog', () => ({ - SkillEditorDialog: () => null, + SkillEditorDialog: ({ allowCodexRootKind }: { allowCodexRootKind: boolean }) => + React.createElement('div', { + 'data-testid': 'skill-editor-dialog', + 'data-allow-codex-root-kind': String(allowCodexRootKind), + }), })); vi.mock('@renderer/components/extensions/skills/SkillImportDialog', () => ({ - SkillImportDialog: () => null, + SkillImportDialog: ({ allowCodexRootKind }: { allowCodexRootKind: boolean }) => + React.createElement('div', { + 'data-testid': 'skill-import-dialog', + 'data-allow-codex-root-kind': String(allowCodexRootKind), + }), })); vi.mock('lucide-react', () => { @@ -170,6 +180,22 @@ describe('SkillsPanel', () => { storeState.skillsProjectCatalogByProjectPath = { '/tmp/project-a': [], }; + storeState.cliStatus = { + flavor: 'claude', + displayName: 'Claude CLI', + supportsSelfUpdate: true, + showVersionDetails: true, + showBinaryPath: true, + installed: true, + installedVersion: '1.0.0', + binaryPath: '/usr/local/bin/claude', + latestVersion: '1.0.0', + updateAvailable: false, + authLoggedIn: true, + authStatusChecking: false, + authMethod: 'oauth', + providers: [], + }; startWatchingMock.mockReset(); stopWatchingMock.mockReset(); onChangedMock.mockReset(); @@ -232,4 +258,39 @@ describe('SkillsPanel', () => { await Promise.resolve(); }); }); + + it('hides codex-only create and import affordances when codex runtime is unavailable', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(SkillsPanel, { + projectPath: '/tmp/project-a', + projectLabel: 'Project A', + skillsSearchQuery: '', + setSkillsSearchQuery: vi.fn(), + skillsSort: 'name-asc', + setSkillsSort: vi.fn(), + selectedSkillId: null, + setSelectedSkillId: vi.fn(), + }) + ); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(host.textContent).not.toContain('Codex only'); + for (const node of host.querySelectorAll('[data-testid="skill-editor-dialog"]')) { + expect(node.getAttribute('data-allow-codex-root-kind')).toBe('false'); + } + const importDialog = host.querySelector('[data-testid="skill-import-dialog"]'); + expect(importDialog?.getAttribute('data-allow-codex-root-kind')).toBe('false'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); From 8075ed10e7cd6c85068a9dfa93dc74d9440ea9e9 Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 17 Apr 2026 14:58:09 +0300 Subject: [PATCH 097/121] fix(extensions): enforce runtime capability guards for mcp writes --- .../extensions/ExtensionStoreView.tsx | 39 +++-- .../extensions/mcp/CustomMcpServerDialog.tsx | 19 +++ src/renderer/store/slices/extensionsSlice.ts | 138 ++++++++++++++---- .../mcp/CustomMcpServerDialog.test.ts | 75 +++++++++- test/renderer/store/extensionsSlice.test.ts | 131 +++++++++++++++++ 5 files changed, 359 insertions(+), 43 deletions(-) diff --git a/src/renderer/components/extensions/ExtensionStoreView.tsx b/src/renderer/components/extensions/ExtensionStoreView.tsx index 396271ab..4d76908b 100644 --- a/src/renderer/components/extensions/ExtensionStoreView.tsx +++ b/src/renderer/components/extensions/ExtensionStoreView.tsx @@ -21,6 +21,7 @@ import { useTabIdOptional } from '@renderer/contexts/useTabUIContext'; import { useExtensionsTabState } from '@renderer/hooks/useExtensionsTabState'; import { useStore } from '@renderer/store'; import { resolveProjectPathById } from '@renderer/utils/projectLookup'; +import { getExtensionActionDisableReason } from '@shared/utils/extensionNormalizers'; import { AlertTriangle, BookOpen, Info, Key, Plus, Puzzle, RefreshCw, Server } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; @@ -165,6 +166,16 @@ export const ExtensionStoreView = (): React.JSX.Element => { const isRefreshing = cliStatusLoading || apiKeysLoading || pluginCatalogLoading || mcpBrowseLoading || skillsLoading; + const mcpMutationDisableReason = useMemo( + () => + getExtensionActionDisableReason({ + isInstalled: false, + cliStatus, + cliStatusLoading, + section: 'mcp', + }), + [cliStatus, cliStatusLoading] + ); const cliStatusBanner = useMemo(() => { const providers = cliStatus?.providers ?? []; const visibleProviders = providers.filter((provider) => provider.providerId !== 'gemini'); @@ -388,15 +399,25 @@ export const ExtensionStoreView = (): React.JSX.Element => { ))} {tabState.activeSubTab === 'mcp-servers' && ( - + + + + + + + {mcpMutationDisableReason && ( + {mcpMutationDisableReason} + )} + )}
diff --git a/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx b/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx index 66fc266d..4bfa4c0b 100644 --- a/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx +++ b/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx @@ -24,6 +24,7 @@ import { SelectValue, } from '@renderer/components/ui/select'; import { useStore } from '@renderer/store'; +import { getExtensionActionDisableReason } from '@shared/utils/extensionNormalizers'; import { getDefaultMcpSharedScope, getMcpScopeLabel, @@ -67,6 +68,7 @@ export const CustomMcpServerDialog = ({ }: 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) }, @@ -101,6 +103,12 @@ export const CustomMcpServerDialog = ({ const apiKeyLookupProjectPath = isProjectScopedMcpScope(scope) ? (projectPath ?? undefined) : undefined; + const mutationDisableReason = getExtensionActionDisableReason({ + isInstalled: false, + cliStatus, + cliStatusLoading, + section: 'mcp', + }); // Reset on open useEffect(() => { @@ -181,6 +189,11 @@ export const CustomMcpServerDialog = ({ const handleInstall = async () => { setError(null); + if (mutationDisableReason) { + setError(mutationDisableReason); + return; + } + if (!serverName.trim()) { setError('Server name is required'); return; @@ -255,6 +268,7 @@ export const CustomMcpServerDialog = ({ serverName.trim() && (transportMode === 'stdio' ? npmPackage.trim() : httpUrl.trim()) && !(isProjectScopedMcpScope(scope) && !projectPath) && + !mutationDisableReason && !installing; return ( @@ -483,6 +497,11 @@ export const CustomMcpServerDialog = ({
{/* Error */} + {mutationDisableReason && ( +
+ {mutationDisableReason} +
+ )} {error && (
{error} diff --git a/src/renderer/store/slices/extensionsSlice.ts b/src/renderer/store/slices/extensionsSlice.ts index 4ae2855a..a0301d00 100644 --- a/src/renderer/store/slices/extensionsSlice.ts +++ b/src/renderer/store/slices/extensionsSlice.ts @@ -4,9 +4,9 @@ */ import { api } from '@renderer/api'; -import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli'; import { isProjectScopedMcpScope } from '@shared/utils/mcpScopes'; import { + getExtensionActionDisableReason, getMcpDiagnosticKey, getMcpProjectStateKey, getMcpOperationKey, @@ -338,12 +338,6 @@ function getSkillsCatalogKey(projectPath?: string): string { /** Duration to show "success" state before returning to idle */ const SUCCESS_DISPLAY_MS = 2_000; -const CLI_AUTH_REQUIRED_MESSAGE = - 'Claude CLI is installed but not signed in. Go to the Dashboard and sign in to enable plugin installs.'; -const CLI_HEALTHCHECK_FAILED_MESSAGE = - 'Claude CLI was found but failed its startup health check. Open the Dashboard to repair or reinstall it before retrying.'; -const CLI_STATUS_UNKNOWN_MESSAGE = - 'Unable to verify Claude CLI status. Open the Dashboard and check the CLI before retrying.'; const PROJECT_SCOPE_REQUIRED_MESSAGE = 'Project- and local-scoped plugins require an active project in the Extensions tab.'; export const createExtensionsSlice: StateCreator = ( @@ -898,15 +892,12 @@ export const createExtensionsSlice: StateCreator ({ + pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'error' }, + installErrors: { ...prev.installErrors, [operationKey]: uninstallDisableReason }, + })); + return; + } + clearPluginSuccessResetTimer(operationKey); set((prev) => ({ pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'pending' }, @@ -1033,6 +1048,30 @@ export const createExtensionsSlice: StateCreator ({ + mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'error' }, + installErrors: { ...prev.installErrors, [operationKey]: installDisableReason }, + })); + return; + } + clearMcpSuccessResetTimer(operationKey); set((prev) => ({ mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'pending' }, @@ -1079,28 +1118,38 @@ export const createExtensionsSlice: StateCreator ({ - mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'error' }, - installErrors: { ...prev.installErrors, [progressKey]: 'MCP Registry not available' }, + mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'pending' }, })); - return; - } - clearMcpSuccessResetTimer(progressKey); - set((prev) => ({ - mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'pending' }, - })); - - try { const result = await api.mcpRegistry.installCustom(request); if (result.state === 'error') { - set((prev) => ({ - mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'error' }, - installErrors: { ...prev.installErrors, [progressKey]: result.error ?? 'Install failed' }, - })); - return; + throw new Error(result.error ?? 'Install failed'); } await Promise.all([ @@ -1120,6 +1169,7 @@ export const createExtensionsSlice: StateCreator ({ + mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'error' }, + installErrors: { ...prev.installErrors, [operationKey]: uninstallDisableReason }, + })); + return; + } + clearMcpSuccessResetTimer(operationKey); set((prev) => ({ mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'pending' }, diff --git a/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts b/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts index 177ffa11..c8adaada 100644 --- a/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts +++ b/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts @@ -4,7 +4,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; interface StoreState { installCustomMcpServer: ReturnType; - cliStatus?: { flavor: 'claude' | 'agent_teams_orchestrator' } | null; + cliStatus?: Record | null; + cliStatusLoading?: boolean; } const storeState = {} as StoreState; @@ -117,7 +118,15 @@ describe('CustomMcpServerDialog project scope', () => { beforeEach(() => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.installCustomMcpServer = vi.fn().mockResolvedValue(undefined); - storeState.cliStatus = null; + storeState.cliStatus = { + flavor: 'claude', + installed: true, + authLoggedIn: true, + binaryPath: '/usr/local/bin/claude', + launchError: null, + providers: [], + }; + storeState.cliStatusLoading = false; lookupMock.mockReset(); lookupMock.mockResolvedValue([]); }); @@ -180,6 +189,68 @@ describe('CustomMcpServerDialog project scope', () => { }); }); + it('disables installation when the runtime declares MCP writes unavailable', async () => { + storeState.cliStatus = { + flavor: 'agent_teams_orchestrator', + installed: true, + authLoggedIn: true, + binaryPath: '/usr/local/bin/claude-multimodel', + launchError: null, + providers: [ + { + providerId: 'anthropic', + displayName: 'Anthropic', + supported: true, + authenticated: true, + authMethod: 'oauth_token', + verificationState: 'verified', + models: [], + canLoginFromUi: true, + capabilities: { + teamLaunch: true, + oneShot: true, + extensions: { + plugins: { status: 'supported', ownership: 'shared', reason: null }, + mcp: { + status: 'read-only', + ownership: 'shared', + reason: 'MCP writes unavailable', + }, + skills: { status: 'supported', ownership: 'shared', reason: null }, + apiKeys: { status: 'supported', ownership: 'shared', reason: null }, + }, + }, + }, + ], + }; + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(CustomMcpServerDialog, { + open: true, + onClose: vi.fn(), + projectPath: null, + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('MCP writes unavailable'); + const installButton = Array.from(host.querySelectorAll('button')).find( + (button) => button.textContent?.includes('Install') + ) as HTMLButtonElement | undefined; + expect(installButton).toBeDefined(); + expect(installButton?.disabled).toBe(true); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('looks up project-scoped API keys only when project scope is selected', async () => { const host = document.createElement('div'); document.body.appendChild(host); diff --git a/test/renderer/store/extensionsSlice.test.ts b/test/renderer/store/extensionsSlice.test.ts index 065b6555..27790d32 100644 --- a/test/renderer/store/extensionsSlice.test.ts +++ b/test/renderer/store/extensionsSlice.test.ts @@ -22,6 +22,7 @@ vi.mock('../../../src/renderer/api', () => ({ getInstalled: vi.fn(), diagnose: vi.fn(), install: vi.fn(), + installCustom: vi.fn(), uninstall: vi.fn(), }, skills: { @@ -153,6 +154,52 @@ const makeReadyCliStatus = () => ({ providers: [], }); +const makeLimitedMultimodelCliStatus = (section: 'plugins' | 'mcp', reason: string) => ({ + flavor: 'agent_teams_orchestrator' as const, + displayName: 'Claude Multimodel', + supportsSelfUpdate: false, + showVersionDetails: true, + showBinaryPath: true, + installed: true, + installedVersion: '1.0.0', + binaryPath: '/usr/local/bin/claude-multimodel', + latestVersion: '1.0.0', + updateAvailable: false, + authLoggedIn: true, + authStatusChecking: false, + authMethod: null, + providers: [ + { + providerId: 'anthropic' as const, + displayName: 'Anthropic', + supported: true, + authenticated: true, + authMethod: 'oauth_token', + verificationState: 'verified' as const, + models: [], + canLoginFromUi: true, + capabilities: { + teamLaunch: true, + oneShot: true, + extensions: { + plugins: { + status: section === 'plugins' ? 'unsupported' : 'supported', + ownership: 'shared' as const, + reason: section === 'plugins' ? reason : null, + }, + mcp: { + status: section === 'mcp' ? 'read-only' : 'supported', + ownership: 'shared' as const, + reason: section === 'mcp' ? reason : null, + }, + skills: { status: 'supported', ownership: 'shared' as const, reason: null }, + apiKeys: { status: 'supported', ownership: 'shared' as const, reason: null }, + }, + }, + }, + ], +}); + const pluginOperationKey = ( pluginId: string, scope: 'user' | 'project' | 'local' = 'user', @@ -618,6 +665,22 @@ describe('extensionsSlice', () => { ); }); + it('fails fast when multimodel runtime declares plugin installs unsupported', async () => { + store.setState({ + cliStatus: makeLimitedMultimodelCliStatus('plugins', 'Plugin writes unavailable'), + }); + + await store.getState().installPlugin({ pluginId: 'unsupported@m', scope: 'user' }); + + expect(api.plugins!.install).not.toHaveBeenCalled(); + expect(store.getState().pluginInstallProgress[pluginOperationKey('unsupported@m')]).toBe( + 'error', + ); + expect(store.getState().installErrors[pluginOperationKey('unsupported@m')]).toContain( + 'Plugin writes unavailable', + ); + }); + it('fills missing projectPath for local scope from the active Extensions project context', async () => { store.setState({ cliStatus: makeReadyCliStatus(), @@ -682,6 +745,7 @@ describe('extensionsSlice', () => { describe('uninstallPlugin', () => { it('sets progress to pending then success', async () => { + store.setState({ cliStatus: makeReadyCliStatus() }); const plugins = [makePlugin({ pluginId: 'a@m', isInstalled: false })]; (api.plugins!.getAll as ReturnType).mockResolvedValue(plugins); (api.plugins!.uninstall as ReturnType).mockResolvedValue({ state: 'success' }); @@ -785,6 +849,7 @@ describe('extensionsSlice', () => { describe('installMcpServer', () => { it('sets progress to pending then success', async () => { + store.setState({ cliStatus: makeReadyCliStatus() }); (api.mcpRegistry!.install as ReturnType).mockResolvedValue({ state: 'success' }); (api.mcpRegistry!.getInstalled as ReturnType).mockResolvedValue([]); (api.mcpRegistry!.diagnose as ReturnType).mockResolvedValue([]); @@ -840,10 +905,60 @@ describe('extensionsSlice', () => { store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'project', '/tmp/project-a')] ).toBeUndefined(); }); + + it('fails fast when multimodel runtime exposes MCP as read-only', async () => { + store.setState({ + cliStatus: makeLimitedMultimodelCliStatus('mcp', 'MCP writes unavailable'), + }); + + await store.getState().installMcpServer({ + registryId: 'test-id', + serverName: 'test-server', + scope: 'global', + envValues: {}, + headers: [], + }); + + expect(api.mcpRegistry!.install).not.toHaveBeenCalled(); + expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'global')]).toBe( + 'error', + ); + expect(store.getState().installErrors[mcpOperationKey('test-id', 'global')]).toContain( + 'MCP writes unavailable', + ); + }); + }); + + describe('installCustomMcpServer', () => { + it('rejects and records an error when MCP writes are unavailable', async () => { + store.setState({ + cliStatus: makeLimitedMultimodelCliStatus('mcp', 'MCP writes unavailable'), + }); + + await expect( + store.getState().installCustomMcpServer({ + serverName: 'custom-server', + scope: 'global', + installSpec: { + type: 'stdio', + npmPackage: '@example/custom-mcp', + }, + envValues: {}, + headers: [], + }), + ).rejects.toThrow('MCP writes unavailable'); + + expect(api.mcpRegistry!.installCustom).not.toHaveBeenCalled(); + expect(store.getState().mcpInstallProgress['mcp-custom:custom-server:global']).toBe('error'); + expect(store.getState().installErrors['mcp-custom:custom-server:global']).toContain( + 'MCP writes unavailable', + ); + }); }); describe('uninstallMcpServer', () => { it('sets progress to pending then success', async () => { + store.setState({ cliStatus: makeReadyCliStatus() }); (api.mcpRegistry!.uninstall as ReturnType).mockResolvedValue({ state: 'success' }); (api.mcpRegistry!.getInstalled as ReturnType).mockResolvedValue([]); (api.mcpRegistry!.diagnose as ReturnType).mockResolvedValue([]); @@ -859,6 +974,22 @@ describe('extensionsSlice', () => { 'success', ); }); + + it('fails fast when multimodel runtime exposes MCP as read-only', async () => { + store.setState({ + cliStatus: makeLimitedMultimodelCliStatus('mcp', 'MCP writes unavailable'), + }); + + await store.getState().uninstallMcpServer('test-id', 'test-server', 'global'); + + expect(api.mcpRegistry!.uninstall).not.toHaveBeenCalled(); + expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'global')]).toBe( + 'error', + ); + expect(store.getState().installErrors[mcpOperationKey('test-id', 'global')]).toContain( + 'MCP writes unavailable', + ); + }); }); describe('provider-aware runtime refresh', () => { From 1b1c27e9c6ea0eb965ce41ac2559a037ced34d77 Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 17 Apr 2026 15:03:34 +0300 Subject: [PATCH 098/121] fix(extensions): preserve api key writes on refresh failures --- src/renderer/store/slices/extensionsSlice.ts | 40 +++++++-- test/renderer/store/extensionsSlice.test.ts | 91 ++++++++++++++++++++ 2 files changed, 126 insertions(+), 5 deletions(-) diff --git a/src/renderer/store/slices/extensionsSlice.ts b/src/renderer/store/slices/extensionsSlice.ts index a0301d00..ca766aaf 100644 --- a/src/renderer/store/slices/extensionsSlice.ts +++ b/src/renderer/store/slices/extensionsSlice.ts @@ -336,6 +336,11 @@ function getSkillsCatalogKey(projectPath?: string): string { return projectPath ?? USER_SKILLS_CATALOG_KEY; } +function upsertApiKeyEntry(entries: ApiKeyEntry[], entry: ApiKeyEntry): ApiKeyEntry[] { + const nextEntries = entries.filter((candidate) => candidate.id !== entry.id); + return [entry, ...nextEntries]; +} + /** Duration to show "success" state before returning to idle */ const SUCCESS_DISPLAY_MS = 2_000; const PROJECT_SCOPE_REQUIRED_MESSAGE = @@ -1286,10 +1291,29 @@ export const createExtensionsSlice: StateCreator ({ + apiKeys: upsertApiKeyEntry(prev.apiKeys, savedKey), + })); + } + + await get().fetchCliStatus(); + const refreshError = get().cliStatusError; + if (refreshError) { + warnings.push(`API key saved, but failed to refresh provider status. ${refreshError}`); + } + set({ apiKeySaving: false, apiKeysError: warnings.length > 0 ? warnings.join(' ') : null }); } catch (err) { set({ apiKeySaving: false, @@ -1305,10 +1329,16 @@ export const createExtensionsSlice: StateCreator ({ apiKeys: prev.apiKeys.filter((k) => k.id !== id), })); + await get().fetchCliStatus(); + const refreshError = get().cliStatusError; + set({ + apiKeysError: refreshError + ? `API key deleted, but failed to refresh provider status. ${refreshError}` + : null, + }); } catch (err) { set({ apiKeysError: err instanceof Error ? err.message : 'Failed to delete API key', diff --git a/test/renderer/store/extensionsSlice.test.ts b/test/renderer/store/extensionsSlice.test.ts index 27790d32..e994b3b9 100644 --- a/test/renderer/store/extensionsSlice.test.ts +++ b/test/renderer/store/extensionsSlice.test.ts @@ -1025,6 +1025,71 @@ describe('extensionsSlice', () => { expect(store.getState().apiKeys).toHaveLength(1); }); + it('keeps saved API keys updated when provider status refresh fails', async () => { + (api.apiKeys!.save as ReturnType).mockResolvedValue({ + id: 'k1', + name: 'Codex key', + envVarName: 'OPENAI_API_KEY', + maskedValue: '***', + scope: 'user', + createdAt: '2026-04-17T10:00:00.000Z', + }); + (api.apiKeys!.list as ReturnType).mockResolvedValue([ + { + id: 'k1', + name: 'Codex key', + envVarName: 'OPENAI_API_KEY', + maskedValue: '***', + scope: 'user', + createdAt: '2026-04-17T10:00:00.000Z', + }, + ]); + (api.cliInstaller!.getStatus as ReturnType).mockRejectedValue( + new Error('refresh boom') + ); + + await store.getState().saveApiKey({ + name: 'Codex key', + envVarName: 'OPENAI_API_KEY', + value: 'secret', + scope: 'user', + }); + + expect(store.getState().apiKeys).toHaveLength(1); + expect(store.getState().apiKeysError).toContain('API key saved, but failed to refresh provider status.'); + }); + + it('keeps local API key state updated when key list refresh fails after save', async () => { + (api.apiKeys!.save as ReturnType).mockResolvedValue({ + id: 'k1', + name: 'Codex key', + envVarName: 'OPENAI_API_KEY', + maskedValue: '***', + scope: 'user', + createdAt: '2026-04-17T10:00:00.000Z', + }); + (api.apiKeys!.list as ReturnType).mockRejectedValue(new Error('list boom')); + + await store.getState().saveApiKey({ + name: 'Codex key', + envVarName: 'OPENAI_API_KEY', + value: 'secret', + scope: 'user', + }); + + expect(store.getState().apiKeys).toEqual([ + { + id: 'k1', + name: 'Codex key', + envVarName: 'OPENAI_API_KEY', + maskedValue: '***', + scope: 'user', + createdAt: '2026-04-17T10:00:00.000Z', + }, + ]); + expect(store.getState().apiKeysError).toContain('API key saved, but failed to refresh key list.'); + }); + it('refreshes CLI status after deleting an API key', async () => { store.setState({ apiKeys: [ @@ -1046,6 +1111,32 @@ describe('extensionsSlice', () => { expect(store.getState().apiKeys).toEqual([]); }); + it('keeps local API key state updated when provider status refresh fails after delete', async () => { + store.setState({ + apiKeys: [ + { + id: 'k1', + name: 'Codex key', + envVarName: 'OPENAI_API_KEY', + maskedValue: '***', + scope: 'user', + createdAt: '2026-04-17T10:00:00.000Z', + }, + ], + }); + (api.apiKeys!.delete as ReturnType).mockResolvedValue(undefined); + (api.cliInstaller!.getStatus as ReturnType).mockRejectedValue( + new Error('refresh boom') + ); + + await store.getState().deleteApiKey('k1'); + + expect(store.getState().apiKeys).toEqual([]); + expect(store.getState().apiKeysError).toContain( + 'API key deleted, but failed to refresh provider status.' + ); + }); + it('keys MCP diagnostics by scope when the same server exists in multiple scopes', async () => { (api.mcpRegistry!.diagnose as ReturnType).mockResolvedValue([ { From 88ac0acdf82369cce8c1839dcd733ccbfe170c15 Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 17 Apr 2026 15:06:36 +0300 Subject: [PATCH 099/121] fix(extensions): preserve mcp dialog state on runtime hydration --- .../extensions/mcp/CustomMcpServerDialog.tsx | 28 ++++++- .../extensions/mcp/McpServerDetailDialog.tsx | 35 +++++--- src/shared/utils/mcpScopes.ts | 4 + .../mcp/CustomMcpServerDialog.test.ts | 57 +++++++++++++ .../mcp/McpServerDetailDialog.test.ts | 82 +++++++++++++++++++ 5 files changed, 195 insertions(+), 11 deletions(-) diff --git a/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx b/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx index 4bfa4c0b..727d2603 100644 --- a/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx +++ b/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx @@ -29,6 +29,7 @@ import { getDefaultMcpSharedScope, getMcpScopeLabel, isProjectScopedMcpScope, + isSharedMcpScope, } from '@shared/utils/mcpScopes'; import { Plus, Server, Trash2 } from 'lucide-react'; @@ -95,6 +96,8 @@ export const CustomMcpServerDialog = ({ 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) @@ -112,7 +115,8 @@ export const CustomMcpServerDialog = ({ // Reset on open useEffect(() => { - if (open) { + const justOpened = open && !wasOpenRef.current; + if (justOpened) { setServerName(''); setTransportMode('stdio'); setScope(defaultSharedScope); @@ -126,8 +130,30 @@ export const CustomMcpServerDialog = ({ setInstalling(false); autoFilledValuesRef.current = {}; } + 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); diff --git a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx index 10fb5579..54d61b32 100644 --- a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx +++ b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx @@ -29,6 +29,7 @@ import { getDefaultMcpSharedScope, getMcpScopeLabel, isProjectScopedMcpScope, + isSharedMcpScope, } from '@shared/utils/mcpScopes'; import { getMcpInstallationSummaryLabel, @@ -93,6 +94,7 @@ export const McpServerDetailDialog = ({ const [imgError, setImgError] = useState(false); const [autoFilledFields, setAutoFilledFields] = useState>(new Set()); const autoFilledValuesRef = useRef>({}); + const previousDefaultSharedScopeRef = useRef(defaultSharedScope); const normalizedInstalledEntries = installedEntries.length ? installedEntries : installedEntry @@ -143,21 +145,34 @@ export const McpServerDetailDialog = ({ setImgError(false); setAutoFilledFields(new Set()); autoFilledValuesRef.current = {}; - }, [ - defaultSharedScope, - open, - preferredInstalledEntry?.name, - preferredInstalledEntry?.scope, - server?.id, - ]); + }, [open, preferredInstalledEntry?.name, preferredInstalledEntry?.scope, server?.id]); useEffect(() => { - if (!server || !open) { + if (!open) { + previousDefaultSharedScopeRef.current = defaultSharedScope; return; } - setServerName(selectedInstalledEntry?.name ?? sanitizeMcpServerName(server.name)); - }, [open, scope, selectedInstalledEntry?.name, server]); + const previousDefaultSharedScope = previousDefaultSharedScopeRef.current; + if ( + previousDefaultSharedScope !== defaultSharedScope && + !preferredInstalledEntry && + scope === previousDefaultSharedScope && + isSharedMcpScope(scope) + ) { + setScope(defaultSharedScope); + } + + previousDefaultSharedScopeRef.current = defaultSharedScope; + }, [defaultSharedScope, open, preferredInstalledEntry, scope]); + + useEffect(() => { + if (!server || !open || !selectedInstalledEntry) { + return; + } + + setServerName(selectedInstalledEntry.name); + }, [open, selectedInstalledEntry, server]); useEffect(() => { if (open && isProjectScopedMcpScope(scope) && !projectPath) { diff --git a/src/shared/utils/mcpScopes.ts b/src/shared/utils/mcpScopes.ts index 44903816..967357f3 100644 --- a/src/shared/utils/mcpScopes.ts +++ b/src/shared/utils/mcpScopes.ts @@ -8,6 +8,10 @@ export function getDefaultMcpSharedScope(flavor?: CliFlavor | null): McpSharedSc return flavor === 'agent_teams_orchestrator' ? 'global' : 'user'; } +export function isSharedMcpScope(scope?: string): scope is McpSharedScope { + return scope === 'user' || scope === 'global'; +} + export function isProjectScopedMcpScope(scope?: string): scope is 'project' | 'local' { return scope === 'project' || scope === 'local'; } diff --git a/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts b/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts index c8adaada..be0331ae 100644 --- a/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts +++ b/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts @@ -189,6 +189,63 @@ describe('CustomMcpServerDialog project scope', () => { }); }); + it('preserves entered values when multimodel scope metadata loads after open', async () => { + storeState.cliStatus = null; + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(CustomMcpServerDialog, { + open: true, + onClose: vi.fn(), + projectPath: null, + }) + ); + await Promise.resolve(); + }); + + const nameInput = host.querySelector('#custom-name') as HTMLInputElement; + const packageInput = host.querySelector('#custom-npm') as HTMLInputElement; + const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; + + await act(async () => { + setNativeValue(nameInput, 'late-hydration-server', 'input'); + setNativeValue(packageInput, '@example/late-hydration', 'input'); + await Promise.resolve(); + }); + + expect(scopeSelect.value).toBe('user'); + + storeState.cliStatus = { flavor: 'agent_teams_orchestrator' }; + await act(async () => { + root.render( + React.createElement(CustomMcpServerDialog, { + open: true, + onClose: vi.fn(), + projectPath: null, + }) + ); + await Promise.resolve(); + }); + + expect((host.querySelector('#custom-name') as HTMLInputElement).value).toBe( + 'late-hydration-server' + ); + expect((host.querySelector('#custom-npm') as HTMLInputElement).value).toBe( + '@example/late-hydration' + ); + expect((host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement).value).toBe( + 'global' + ); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('disables installation when the runtime declares MCP writes unavailable', async () => { storeState.cliStatus = { flavor: 'agent_teams_orchestrator', diff --git a/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts b/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts index 14fe2b8c..d8fc52c0 100644 --- a/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts +++ b/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts @@ -165,6 +165,15 @@ function makeServer(): McpCatalogItem { }; } +function setInputValue(element: HTMLInputElement | HTMLSelectElement, value: string): void { + const prototype = + element instanceof HTMLSelectElement ? HTMLSelectElement.prototype : HTMLInputElement.prototype; + const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value'); + descriptor?.set?.call(element, value); + element.dispatchEvent(new Event('input', { bubbles: true })); + element.dispatchEvent(new Event('change', { bubbles: true })); +} + describe('McpServerDetailDialog installed entry handling', () => { beforeEach(() => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); @@ -416,6 +425,79 @@ describe('McpServerDetailDialog installed entry handling', () => { }); }); + it('preserves edited fields when multimodel scope metadata loads after open', async () => { + storeState.cliStatus = null; + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const server = makeServer(); + server.envVars = [{ name: 'CONTEXT7_API_KEY', isSecret: true }]; + + await act(async () => { + root.render( + React.createElement(McpServerDetailDialog, { + server, + isInstalled: false, + installedEntry: null, + installedEntries: [], + diagnostic: null, + diagnosticsLoading: false, + projectPath: null, + open: true, + onClose: vi.fn(), + }) + ); + await Promise.resolve(); + await Promise.resolve(); + }); + + const serverNameInput = host.querySelector('#server-name') as HTMLInputElement; + const envValueInput = host.querySelector('input[type="password"]') as HTMLInputElement; + const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; + + await act(async () => { + setInputValue(serverNameInput, 'late-hydration-context7'); + setInputValue(envValueInput, 'secret'); + await Promise.resolve(); + }); + + expect(scopeSelect.value).toBe('user'); + + storeState.cliStatus = { flavor: 'agent_teams_orchestrator' }; + await act(async () => { + root.render( + React.createElement(McpServerDetailDialog, { + server, + isInstalled: false, + installedEntry: null, + installedEntries: [], + diagnostic: null, + diagnosticsLoading: false, + projectPath: null, + open: true, + onClose: vi.fn(), + }) + ); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect((host.querySelector('#server-name') as HTMLInputElement).value).toBe( + 'late-hydration-context7' + ); + expect((host.querySelector('input[type="password"]') as HTMLInputElement).value).toBe( + 'secret' + ); + expect((host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement).value).toBe( + 'global' + ); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('passes project path for project-scoped installs and uninstalls', async () => { const host = document.createElement('div'); document.body.appendChild(host); From 27a54f7f32483bd2be5dfe82db4c03bd1f505a22 Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 17 Apr 2026 19:47:41 +0300 Subject: [PATCH 100/121] fix(extensions): tighten plugin applicability copy --- src/renderer/components/extensions/plugins/PluginsPanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/components/extensions/plugins/PluginsPanel.tsx b/src/renderer/components/extensions/plugins/PluginsPanel.tsx index 0f7cf218..10859fde 100644 --- a/src/renderer/components/extensions/plugins/PluginsPanel.tsx +++ b/src/renderer/components/extensions/plugins/PluginsPanel.tsx @@ -192,7 +192,7 @@ export const PluginsPanel = ({ return (
- Plugins currently apply to Anthropic sessions in the multimodel runtime. + In the multimodel runtime, plugins currently apply only to Anthropic sessions. {capability.reason ? ` ${capability.reason}` : ''}
); From 12f6f907015de6dbab66681d236822f7d174a76f Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 17 Apr 2026 20:07:35 +0300 Subject: [PATCH 101/121] fix(extensions): hide runtime-injected mcp diagnostics --- .../runtime/mcpDiagnosticsParser.ts | 44 +++++++++++++------ .../McpHealthDiagnosticsService.test.ts | 18 +++++--- 2 files changed, 44 insertions(+), 18 deletions(-) diff --git a/src/main/services/extensions/runtime/mcpDiagnosticsParser.ts b/src/main/services/extensions/runtime/mcpDiagnosticsParser.ts index 911b054f..ebc95bf2 100644 --- a/src/main/services/extensions/runtime/mcpDiagnosticsParser.ts +++ b/src/main/services/extensions/runtime/mcpDiagnosticsParser.ts @@ -1,3 +1,5 @@ +import { isInstalledMcpScope } from '@shared/utils/mcpScopes'; + import type { McpServerDiagnostic, McpServerHealthStatus } from '@shared/types/extensions'; interface McpDiagnoseJsonEntry { @@ -18,6 +20,21 @@ const EMBEDDED_HTTP_URL_PATTERN = /https?:\/\/[^\s"'`]+/gi; const SENSITIVE_FLAG_VALUE_PATTERN = /(--(?:api[-_]?key|access[-_]?token|auth[-_]?token|token|secret|password|client[-_]?secret))(?:=([^\s]+)|\s+([^\s]+))/gi; +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 extractJsonObject(raw: string): T { const trimmed = raw.trim(); try { @@ -127,7 +144,8 @@ export function parseMcpDiagnosticsOutput(output: string): McpServerDiagnostic[] .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): entry is McpServerDiagnostic => entry !== null) + .filter((entry) => isExtensionsManagedDiagnosticEntry(entry)); } export function parseMcpDiagnosticsJsonOutput(output: string): McpServerDiagnostic[] { @@ -155,17 +173,17 @@ export function parseMcpDiagnosticsJsonOutput(output: string): McpServerDiagnost : 'unknown'; const rawLine = `${entry.name}: ${redactedTarget} - ${entry.statusLabel}`; - return [ - { - name: entry.name, - target: redactedTarget, - scope: entry.scope, - transport: entry.transport, - status: normalizedStatus, - statusLabel: entry.statusLabel, - rawLine, - checkedAt, - }, - ]; + 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/test/main/services/extensions/McpHealthDiagnosticsService.test.ts b/test/main/services/extensions/McpHealthDiagnosticsService.test.ts index c3db915f..706d508f 100644 --- a/test/main/services/extensions/McpHealthDiagnosticsService.test.ts +++ b/test/main/services/extensions/McpHealthDiagnosticsService.test.ts @@ -16,20 +16,20 @@ browsermcp: npx @browsermcp/mcp@latest - ✓ Connected tavily-remote-mcp: npx -y mcp-remote https://mcp.tavily.com/mcp/?tavilyApiKey=test - ✗ Failed to connect alpic: https://mcp.alpic.ai (HTTP) - ! Needs authentication`); - expect(diagnostics).toHaveLength(5); + expect(diagnostics).toHaveLength(3); expect(diagnostics[0]).toMatchObject({ - name: 'plugin:context7:context7', - target: 'npx -y @upstash/context7-mcp', + name: 'browsermcp', + target: 'npx @browsermcp/mcp@latest', status: 'connected', statusLabel: 'Connected', }); - expect(diagnostics[3]).toMatchObject({ + expect(diagnostics[1]).toMatchObject({ name: 'tavily-remote-mcp', target: 'npx -y mcp-remote https://mcp.tavily.com/mcp/?tavilyApiKey=REDACTED', status: 'failed', statusLabel: 'Failed to connect', }); - expect(diagnostics[4]).toMatchObject({ + expect(diagnostics[2]).toMatchObject({ name: 'alpic', target: 'https://mcp.alpic.ai (HTTP)', status: 'needs-authentication', @@ -64,6 +64,14 @@ another log line`); status: 'timeout', statusLabel: 'Timed out', }, + { + name: 'plugin:context7:context7', + target: 'npx -y @upstash/context7-mcp', + scope: 'dynamic', + transport: 'stdio', + status: 'connected', + statusLabel: 'Connected', + }, ], }) ); From 3446ef01004a52d1bff6eb9705860f81afee7fb6 Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 17 Apr 2026 20:20:27 +0300 Subject: [PATCH 102/121] fix(extensions): harden hidden provider runtime handling --- .../components/dashboard/CliStatusBanner.tsx | 9 +- .../extensions/ExtensionStoreView.tsx | 113 ++++++++++-------- .../utils/multimodelProviderVisibility.ts | 32 +++++ .../cli/CliStatusVisibility.test.ts | 66 +++++++--- .../multimodelProviderVisibility.test.ts | 65 ++++++++++ 5 files changed, 218 insertions(+), 67 deletions(-) create mode 100644 src/renderer/utils/multimodelProviderVisibility.ts create mode 100644 test/renderer/utils/multimodelProviderVisibility.test.ts diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx index be2ffe20..ff41320d 100644 --- a/src/renderer/components/dashboard/CliStatusBanner.tsx +++ b/src/renderer/components/dashboard/CliStatusBanner.tsx @@ -33,6 +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 { isMultimodelRuntimeStatus } from '@renderer/utils/multimodelProviderVisibility'; import { AlertTriangle, CheckCircle, @@ -321,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 @@ -351,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 diff --git a/src/renderer/components/extensions/ExtensionStoreView.tsx b/src/renderer/components/extensions/ExtensionStoreView.tsx index 4d76908b..ffc9ff4c 100644 --- a/src/renderer/components/extensions/ExtensionStoreView.tsx +++ b/src/renderer/components/extensions/ExtensionStoreView.tsx @@ -20,6 +20,11 @@ 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 { getExtensionActionDisableReason } from '@shared/utils/extensionNormalizers'; import { AlertTriangle, BookOpen, Info, Key, Plus, Puzzle, RefreshCw, Server } from 'lucide-react'; @@ -178,9 +183,8 @@ export const ExtensionStoreView = (): React.JSX.Element => { ); const cliStatusBanner = useMemo(() => { const providers = cliStatus?.providers ?? []; - const visibleProviders = providers.filter((provider) => provider.providerId !== 'gemini'); - const isMultimodel = - cliStatus?.flavor === 'agent_teams_orchestrator' && visibleProviders.length > 0; + const visibleProviders = getVisibleMultimodelProviders(providers); + const isMultimodel = isMultimodelRuntimeStatus(cliStatus); if (cliStatusLoading || cliStatus === null) { return ( @@ -260,57 +264,64 @@ export const ExtensionStoreView = (): React.JSX.Element => {

-
- {visibleProviders.map((provider) => { - 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 pluginStatus = provider.capabilities.extensions.plugins.status; + {visibleProviders.length > 0 && ( +
+ {visibleProviders.map((provider) => { + 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 pluginStatus = provider.capabilities.extensions.plugins.status; - return ( -
-
-
-

- - {provider.displayName} -

-

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

+ return ( +
+
+
+

+ + {provider.displayName} +

+

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

+
+ + {statusLabel} + +
+
+ + Plugins: {formatCliExtensionCapabilityStatus(pluginStatus)} + + + MCP:{' '} + {formatCliExtensionCapabilityStatus( + provider.capabilities.extensions.mcp.status + )} + + + Skills: {provider.capabilities.extensions.skills.ownership} +
- - {statusLabel} -
-
- - Plugins: {pluginStatus === 'supported' ? 'supported' : 'limited'} - - - MCP: {provider.capabilities.extensions.mcp.status} - - - Skills: {provider.capabilities.extensions.skills.ownership} - -
-
- ); - })} -
+ ); + })} +
+ )}
); } diff --git a/src/renderer/utils/multimodelProviderVisibility.ts b/src/renderer/utils/multimodelProviderVisibility.ts new file mode 100644 index 00000000..8fa4d753 --- /dev/null +++ b/src/renderer/utils/multimodelProviderVisibility.ts @@ -0,0 +1,32 @@ +import { filterMainScreenCliProviders } from './geminiUiFreeze'; + +import type { + CliExtensionCapability, + CliInstallationStatus, + CliProviderStatus, +} from '@shared/types'; + +export function getVisibleMultimodelProviders( + providers: readonly CliProviderStatus[] +): CliProviderStatus[] { + return filterMainScreenCliProviders(providers); +} + +export function isMultimodelRuntimeStatus( + cliStatus: Pick | null | undefined +): boolean { + return cliStatus?.flavor === 'agent_teams_orchestrator' && (cliStatus.providers?.length ?? 0) > 0; +} + +export function formatCliExtensionCapabilityStatus( + status: CliExtensionCapability['status'] +): string { + switch (status) { + case 'supported': + return 'supported'; + case 'read-only': + return 'read-only'; + default: + return 'unsupported'; + } +} diff --git a/test/renderer/components/cli/CliStatusVisibility.test.ts b/test/renderer/components/cli/CliStatusVisibility.test.ts index 96527c46..d383257d 100644 --- a/test/renderer/components/cli/CliStatusVisibility.test.ts +++ b/test/renderer/components/cli/CliStatusVisibility.test.ts @@ -170,7 +170,8 @@ function createApiKeyMisconfiguredProvider( connection: { supportsOAuth: true, supportsApiKey: true, - configurableAuthModes: providerId === 'anthropic' ? ['auto', 'oauth', 'api_key'] : ['oauth', 'api_key'], + configurableAuthModes: + providerId === 'anthropic' ? ['auto', 'oauth', 'api_key'] : ['oauth', 'api_key'], configuredAuthMode: 'api_key', apiKeyBetaAvailable: providerId === 'codex' ? true : undefined, apiKeyBetaEnabled: providerId === 'codex' ? true : undefined, @@ -181,9 +182,7 @@ function createApiKeyMisconfiguredProvider( }; } -function createApiKeyModeProviderIssue( - providerId: 'anthropic' | 'codex' -): Record { +function createApiKeyModeProviderIssue(providerId: 'anthropic' | 'codex'): Record { return { ...createApiKeyMisconfiguredProvider(providerId), statusMessage: @@ -191,8 +190,8 @@ function createApiKeyModeProviderIssue( ? 'Anthropic API key was rejected by the runtime.' : 'OpenAI API key was rejected by the runtime.', connection: { - ...((createApiKeyMisconfiguredProvider(providerId) as { connection: Record }) - .connection), + ...(createApiKeyMisconfiguredProvider(providerId) as { connection: Record }) + .connection, apiKeyConfigured: true, apiKeySource: 'stored', apiKeySourceLabel: @@ -287,9 +286,7 @@ describe('CLI status visibility during completed install state', () => { it('preserves dashboard runtime backend refresh errors for the manage dialog', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.cliInstallerState = 'idle'; - storeState.fetchCliProviderStatus = vi.fn(() => - Promise.reject(new Error('refresh failed')) - ); + storeState.fetchCliProviderStatus = vi.fn(() => Promise.reject(new Error('refresh failed'))); const host = document.createElement('div'); document.body.appendChild(host); @@ -345,6 +342,49 @@ describe('CLI status visibility during completed install state', () => { }); }); + it('does not fall back to direct-Claude auth copy when only hidden multimodel providers are available', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliInstallerState = 'idle'; + storeState.cliStatus = createInstalledCliStatus({ + flavor: 'agent_teams_orchestrator', + authLoggedIn: true, + providers: [ + { + providerId: 'gemini', + displayName: 'Gemini', + supported: true, + authenticated: true, + authMethod: 'cli_oauth_personal', + verificationState: 'verified', + statusMessage: 'Resolved to CLI SDK', + models: [], + canLoginFromUi: true, + capabilities: { + teamLaunch: true, + oneShot: true, + }, + }, + ], + }); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(CliStatusBanner)); + await Promise.resolve(); + }); + + expect(host.textContent).not.toContain('Authenticated'); + expect(host.textContent).not.toContain('Providers:'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('shows a degraded runtime warning when a binary is found but the health check fails', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.cliInstallerState = 'idle'; @@ -413,9 +453,7 @@ describe('CLI status visibility during completed install state', () => { it('preserves settings runtime backend refresh errors for the manage dialog', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.cliInstallerState = 'idle'; - storeState.fetchCliProviderStatus = vi.fn(() => - Promise.reject(new Error('refresh failed')) - ); + storeState.fetchCliProviderStatus = vi.fn(() => Promise.reject(new Error('refresh failed'))); const host = document.createElement('div'); document.body.appendChild(host); @@ -490,8 +528,8 @@ describe('CLI status visibility during completed install state', () => { expect(host.textContent).not.toContain('Already logged in?'); expect(host.textContent).not.toContain('Login'); - const manageButton = Array.from(host.querySelectorAll('button')).find( - (button) => button.textContent?.includes('Manage Providers') + const manageButton = Array.from(host.querySelectorAll('button')).find((button) => + button.textContent?.includes('Manage Providers') ); expect(manageButton).not.toBeUndefined(); diff --git a/test/renderer/utils/multimodelProviderVisibility.test.ts b/test/renderer/utils/multimodelProviderVisibility.test.ts new file mode 100644 index 00000000..47cac0f4 --- /dev/null +++ b/test/renderer/utils/multimodelProviderVisibility.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; + +import { + formatCliExtensionCapabilityStatus, + getVisibleMultimodelProviders, + isMultimodelRuntimeStatus, +} from '@renderer/utils/multimodelProviderVisibility'; + +import type { CliInstallationStatus, CliProviderStatus } from '@shared/types'; + +function createProvider(providerId: CliProviderStatus['providerId']): CliProviderStatus { + return { + providerId, + displayName: + providerId === 'anthropic' ? 'Anthropic' : providerId === 'codex' ? 'Codex' : 'Gemini', + supported: true, + authenticated: true, + authMethod: 'oauth_token', + verificationState: 'verified', + canLoginFromUi: true, + capabilities: { + teamLaunch: true, + oneShot: true, + }, + statusMessage: null, + detailMessage: null, + selectedBackendId: null, + resolvedBackendId: null, + availableBackends: [], + externalRuntimeDiagnostics: [], + models: [], + backend: null, + connection: null, + }; +} + +describe('multimodelProviderVisibility', () => { + it('keeps multimodel runtime detection true even when all visible provider cards are hidden', () => { + const cliStatus = { + flavor: 'agent_teams_orchestrator', + providers: [createProvider('gemini')], + } satisfies Pick; + + expect(isMultimodelRuntimeStatus(cliStatus)).toBe(true); + expect(getVisibleMultimodelProviders(cliStatus.providers)).toHaveLength(0); + }); + + it('filters Gemini from the visible provider cards while keeping supported providers', () => { + const providers = [ + createProvider('anthropic'), + createProvider('codex'), + createProvider('gemini'), + ]; + + expect(getVisibleMultimodelProviders(providers).map((provider) => provider.providerId)).toEqual( + ['anthropic', 'codex'] + ); + }); + + it('formats capability statuses without collapsing read-only into a vague limited label', () => { + expect(formatCliExtensionCapabilityStatus('supported')).toBe('supported'); + expect(formatCliExtensionCapabilityStatus('read-only')).toBe('read-only'); + expect(formatCliExtensionCapabilityStatus('unsupported')).toBe('unsupported'); + }); +}); From 7b16cfe73b6cdff7dcfc91fd5b76d78ec2537e7e Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 17 Apr 2026 20:22:29 +0300 Subject: [PATCH 103/121] fix(extensions): treat multimodel flavor as runtime-aware before hydration --- .../utils/multimodelProviderVisibility.ts | 2 +- src/shared/utils/extensionNormalizers.ts | 2 +- .../multimodelProviderVisibility.test.ts | 9 ++ .../shared/utils/extensionNormalizers.test.ts | 109 ++++++++++-------- 4 files changed, 73 insertions(+), 49 deletions(-) diff --git a/src/renderer/utils/multimodelProviderVisibility.ts b/src/renderer/utils/multimodelProviderVisibility.ts index 8fa4d753..68e153f4 100644 --- a/src/renderer/utils/multimodelProviderVisibility.ts +++ b/src/renderer/utils/multimodelProviderVisibility.ts @@ -15,7 +15,7 @@ export function getVisibleMultimodelProviders( export function isMultimodelRuntimeStatus( cliStatus: Pick | null | undefined ): boolean { - return cliStatus?.flavor === 'agent_teams_orchestrator' && (cliStatus.providers?.length ?? 0) > 0; + return cliStatus?.flavor === 'agent_teams_orchestrator'; } export function formatCliExtensionCapabilityStatus( diff --git a/src/shared/utils/extensionNormalizers.ts b/src/shared/utils/extensionNormalizers.ts index e289030d..e3da4d7d 100644 --- a/src/shared/utils/extensionNormalizers.ts +++ b/src/shared/utils/extensionNormalizers.ts @@ -266,7 +266,7 @@ export function getExtensionActionDisableReason(options: { } const providers = cliStatus.providers ?? []; - const isMultimodel = cliStatus.flavor === 'agent_teams_orchestrator' && providers.length > 0; + const isMultimodel = cliStatus.flavor === 'agent_teams_orchestrator'; if (section === 'mcp') { if (!isMultimodel) { diff --git a/test/renderer/utils/multimodelProviderVisibility.test.ts b/test/renderer/utils/multimodelProviderVisibility.test.ts index 47cac0f4..84ab2872 100644 --- a/test/renderer/utils/multimodelProviderVisibility.test.ts +++ b/test/renderer/utils/multimodelProviderVisibility.test.ts @@ -45,6 +45,15 @@ describe('multimodelProviderVisibility', () => { expect(getVisibleMultimodelProviders(cliStatus.providers)).toHaveLength(0); }); + it('keeps multimodel runtime detection true even before provider metadata arrives', () => { + const cliStatus = { + flavor: 'agent_teams_orchestrator', + providers: [], + } satisfies Pick; + + expect(isMultimodelRuntimeStatus(cliStatus)).toBe(true); + }); + it('filters Gemini from the visible provider cards while keeping supported providers', () => { const providers = [ createProvider('anthropic'), diff --git a/test/shared/utils/extensionNormalizers.test.ts b/test/shared/utils/extensionNormalizers.test.ts index 7e37218e..6b54cb09 100644 --- a/test/shared/utils/extensionNormalizers.test.ts +++ b/test/shared/utils/extensionNormalizers.test.ts @@ -23,21 +23,15 @@ import { describe('normalizeRepoUrl', () => { it('lowercases and strips .git', () => { - expect(normalizeRepoUrl('https://GitHub.com/Org/Repo.git')).toBe( - 'https://github.com/org/repo', - ); + expect(normalizeRepoUrl('https://GitHub.com/Org/Repo.git')).toBe('https://github.com/org/repo'); }); it('strips trailing slashes', () => { - expect(normalizeRepoUrl('https://github.com/org/repo/')).toBe( - 'https://github.com/org/repo', - ); + expect(normalizeRepoUrl('https://github.com/org/repo/')).toBe('https://github.com/org/repo'); }); it('handles already clean URLs', () => { - expect(normalizeRepoUrl('https://github.com/org/repo')).toBe( - 'https://github.com/org/repo', - ); + expect(normalizeRepoUrl('https://github.com/org/repo')).toBe('https://github.com/org/repo'); }); }); @@ -68,9 +62,10 @@ describe('inferCapabilities', () => { }); it('detects multiple capabilities', () => { - expect( - inferCapabilities(makePlugin({ hasLspServers: true, hasMcpServers: true })), - ).toEqual(['lsp', 'mcp']); + expect(inferCapabilities(makePlugin({ hasLspServers: true, hasMcpServers: true }))).toEqual([ + 'lsp', + 'mcp', + ]); }); it('preserves capability order', () => { @@ -80,8 +75,8 @@ describe('inferCapabilities', () => { hasHooks: true, hasAgents: true, hasLspServers: true, - }), - ), + }) + ) ).toEqual(['lsp', 'agent', 'hook']); }); }); @@ -151,7 +146,7 @@ describe('normalizeCategory', () => { describe('buildPluginId', () => { it('creates qualifiedName format', () => { expect(buildPluginId('context7', 'claude-plugins-official')).toBe( - 'context7@claude-plugins-official', + 'context7@claude-plugins-official' ); }); }); @@ -159,36 +154,28 @@ describe('buildPluginId', () => { describe('getPluginOperationKey', () => { it('namespaces user-scope plugin operation keys without a project suffix', () => { expect(getPluginOperationKey('context7@claude-plugins-official', 'user')).toBe( - 'plugin:context7@claude-plugins-official:user', + 'plugin:context7@claude-plugins-official:user' ); }); it('namespaces repo-scoped plugin operation keys by project path', () => { - expect( - getPluginOperationKey('context7@claude-plugins-official', 'local', '/tmp/project'), - ).toBe('plugin:context7@claude-plugins-official:local:/tmp/project'); + expect(getPluginOperationKey('context7@claude-plugins-official', 'local', '/tmp/project')).toBe( + 'plugin:context7@claude-plugins-official:local:/tmp/project' + ); }); }); describe('getMcpOperationKey', () => { it('namespaces MCP operation keys by scope', () => { expect(getMcpOperationKey('io.github.upstash/context7', 'project', '/tmp/project')).toBe( - 'mcp:io.github.upstash/context7:project:/tmp/project', + 'mcp:io.github.upstash/context7:project:/tmp/project' ); }); }); describe('hasInstallationInScope', () => { it('returns true when the selected scope exists', () => { - expect( - hasInstallationInScope( - [ - { scope: 'user' }, - { scope: 'project' }, - ], - 'project', - ), - ).toBe(true); + expect(hasInstallationInScope([{ scope: 'user' }, { scope: 'project' }], 'project')).toBe(true); }); it('returns false when the selected scope is missing', () => { @@ -210,12 +197,9 @@ describe('getInstallationSummaryLabel', () => { }); it('summarizes multiple scopes without pretending they are global', () => { - expect( - getInstallationSummaryLabel([ - { scope: 'project' }, - { scope: 'user' }, - ]), - ).toBe('Installed in 2 scopes'); + expect(getInstallationSummaryLabel([{ scope: 'project' }, { scope: 'user' }])).toBe( + 'Installed in 2 scopes' + ); }); }); @@ -252,12 +236,9 @@ describe('getMcpInstallationSummaryLabel', () => { }); it('summarizes multiple MCP scopes', () => { - expect( - getMcpInstallationSummaryLabel([ - { scope: 'user' }, - { scope: 'project' }, - ]) - ).toBe('Installed in 2 scopes'); + expect(getMcpInstallationSummaryLabel([{ scope: 'user' }, { scope: 'project' }])).toBe( + 'Installed in 2 scopes' + ); }); }); @@ -285,7 +266,7 @@ describe('getExtensionActionDisableReason', () => { isInstalled: false, cliStatus: createDirectCliStatus({ authLoggedIn: false }), cliStatusLoading: false, - }), + }) ).toContain('not signed in'); }); @@ -295,7 +276,7 @@ describe('getExtensionActionDisableReason', () => { isInstalled: true, cliStatus: createDirectCliStatus({ authLoggedIn: false }), cliStatusLoading: false, - }), + }) ).toBeNull(); }); @@ -305,7 +286,7 @@ describe('getExtensionActionDisableReason', () => { isInstalled: true, cliStatus: createDirectCliStatus({ installed: false, authLoggedIn: false }), cliStatusLoading: false, - }), + }) ).toContain('configured runtime'); }); @@ -322,7 +303,7 @@ describe('getExtensionActionDisableReason', () => { }), }, cliStatusLoading: false, - }), + }) ).toContain('failed to start'); }); @@ -365,7 +346,7 @@ describe('getExtensionActionDisableReason', () => { ], }, cliStatusLoading: false, - }), + }) ).toContain('Anthropic plugins unavailable'); }); @@ -404,9 +385,43 @@ describe('getExtensionActionDisableReason', () => { ], }, cliStatusLoading: false, - }), + }) ).toBeNull(); }); + + it('uses conservative multimodel fallback when provider metadata is not available yet', () => { + expect( + getExtensionActionDisableReason({ + isInstalled: false, + section: 'plugins', + cliStatus: { + installed: true, + authLoggedIn: false, + binaryPath: '/usr/local/bin/claude-multimodel', + launchError: null, + flavor: 'agent_teams_orchestrator', + providers: [], + }, + cliStatusLoading: false, + }) + ).toContain('not supported by the current runtime'); + + expect( + getExtensionActionDisableReason({ + isInstalled: false, + section: 'mcp', + cliStatus: { + installed: true, + authLoggedIn: false, + binaryPath: '/usr/local/bin/claude-multimodel', + launchError: null, + flavor: 'agent_teams_orchestrator', + providers: [], + }, + cliStatusLoading: false, + }) + ).toContain('not supported by the current runtime'); + }); }); describe('sanitizeMcpServerName', () => { From 1ee139b66ab5faeadf51a76d8a6eccad026d32b4 Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 17 Apr 2026 20:24:45 +0300 Subject: [PATCH 104/121] fix(extensions): keep extensions entry points available before auth --- .../components/dashboard/CliStatusBanner.tsx | 5 +- .../settings/sections/CliStatusSection.tsx | 3 +- .../cli/CliStatusVisibility.test.ts | 47 ++++++++++++++++++- 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx index ff41320d..26118973 100644 --- a/src/renderer/components/dashboard/CliStatusBanner.tsx +++ b/src/renderer/components/dashboard/CliStatusBanner.tsx @@ -387,6 +387,7 @@ const InstalledBanner = ({ () => filterMainScreenCliProviders(cliStatus.providers), [cliStatus.providers] ); + const canOpenExtensions = cliStatus.installed; const runtimeLabel = formatRuntimeLabel(cliStatus); const runtimeAuthSummary = formatRuntimeAuthSummary(cliStatus, visibleProviders); @@ -471,8 +472,8 @@ const InstalledBanner = ({ disabled={isBusy || cliStatusLoading || multimodelBusy} />
- {/* Extensions button — only when installed + authenticated */} - {cliStatus.authLoggedIn && ( + {/* Extensions button — available whenever the runtime is installed */} + {canOpenExtensions && ( ) : null} {/* Extensions button — right-aligned */} - {effectiveCliStatus.authLoggedIn && ( + {canOpenExtensions && (
diff --git a/test/renderer/components/extensions/mcp/McpServersPanel.test.ts b/test/renderer/components/extensions/mcp/McpServersPanel.test.ts index 85a2281d..7a31088d 100644 --- a/test/renderer/components/extensions/mcp/McpServersPanel.test.ts +++ b/test/renderer/components/extensions/mcp/McpServersPanel.test.ts @@ -75,19 +75,14 @@ vi.mock('@renderer/components/ui/select', () => ({ SelectTrigger: ({ children }: React.PropsWithChildren) => React.createElement('button', { type: 'button' }, children), SelectValue: () => React.createElement('span', null, 'select-value'), - SelectContent: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children), + SelectContent: ({ children }: React.PropsWithChildren) => + React.createElement('div', null, children), SelectItem: ({ children }: React.PropsWithChildren<{ value: string }>) => React.createElement('button', { type: 'button' }, children), })); vi.mock('@renderer/components/extensions/common/SearchInput', () => ({ - SearchInput: ({ - value, - onChange, - }: { - value: string; - onChange: (value: string) => void; - }) => + SearchInput: ({ value, onChange }: { value: string; onChange: (value: string) => void }) => React.createElement('input', { value, onChange: (event: React.ChangeEvent) => onChange(event.target.value), @@ -96,7 +91,11 @@ vi.mock('@renderer/components/extensions/common/SearchInput', () => ({ vi.mock('@renderer/components/extensions/mcp/McpServerCard', () => ({ McpServerCard: ({ server }: { server: { id: string; name: string } }) => - React.createElement('div', { 'data-testid': 'mcp-card', 'data-server-id': server.id }, server.name), + React.createElement( + 'div', + { 'data-testid': 'mcp-card', 'data-server-id': server.id }, + server.name + ), })); vi.mock('@renderer/components/extensions/mcp/McpServerDetailDialog', () => ({ @@ -224,4 +223,48 @@ describe('McpServersPanel initial browse loading', () => { await Promise.resolve(); }); }); + + it('uses truthful diagnostics copy instead of suggesting a hard-coded CLI command', async () => { + storeState.mcpBrowseCatalog = [ + { + id: 'context7', + name: 'Context7', + description: 'Docs MCP', + source: 'official', + installSpec: null, + envVars: [], + tools: [], + requiresAuth: false, + }, + ]; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(McpServersPanel, { + projectPath: null, + mcpSearchQuery: '', + mcpSearch: vi.fn(), + mcpSearchResults: [], + mcpSearchLoading: false, + mcpSearchWarnings: [], + selectedMcpServerId: null, + setSelectedMcpServerId: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Run diagnostics from this page'); + expect(host.textContent).not.toContain('claude-multimodel mcp diagnose'); + expect(host.textContent).not.toContain('claude mcp list'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); From 767dfde4cb6fac388840c28d5431db568088028c Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 17 Apr 2026 20:51:25 +0300 Subject: [PATCH 108/121] fix(extensions): use provider-aware runtime copy --- .../extensions/ExtensionStoreView.tsx | 6 +-- .../extensions/mcp/McpServersPanel.tsx | 11 +++-- .../extensions/skills/SkillsPanel.tsx | 10 ++--- .../extensions/mcp/McpServersPanel.test.ts | 42 +++++++++++++++++++ 4 files changed, 58 insertions(+), 11 deletions(-) diff --git a/src/renderer/components/extensions/ExtensionStoreView.tsx b/src/renderer/components/extensions/ExtensionStoreView.tsx index ffc9ff4c..dfb2355d 100644 --- a/src/renderer/components/extensions/ExtensionStoreView.tsx +++ b/src/renderer/components/extensions/ExtensionStoreView.tsx @@ -100,21 +100,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.', }, { 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, diff --git a/src/renderer/components/extensions/mcp/McpServersPanel.tsx b/src/renderer/components/extensions/mcp/McpServersPanel.tsx index 657dc359..46249a10 100644 --- a/src/renderer/components/extensions/mcp/McpServersPanel.tsx +++ b/src/renderer/components/extensions/mcp/McpServersPanel.tsx @@ -385,10 +385,15 @@ export const McpServersPanel = ({
-

Claude CLI not installed

+

+ {cliStatus?.flavor === 'agent_teams_orchestrator' + ? 'Configured runtime not available' + : 'Claude CLI not installed'} +

- MCP health checks require Claude CLI. Go to the Dashboard to install it - automatically. + {cliStatus?.flavor === 'agent_teams_orchestrator' + ? 'MCP health checks require the configured runtime. Go to the Dashboard to install or repair it.' + : 'MCP health checks require Claude CLI. Go to the Dashboard to install or repair it.'}

diff --git a/src/renderer/components/extensions/skills/SkillsPanel.tsx b/src/renderer/components/extensions/skills/SkillsPanel.tsx index cd9fabe5..ecdeed73 100644 --- a/src/renderer/components/extensions/skills/SkillsPanel.tsx +++ b/src/renderer/components/extensions/skills/SkillsPanel.tsx @@ -261,11 +261,11 @@ export const SkillsPanel = ({
-

Teach Claude repeatable work

+

Teach repeatable work

- Skills are reusable instructions that help Claude handle the same kind of task more - consistently.{' '} + Skills are reusable instructions that help the runtime handle the same kind of task + more consistently.{' '} {projectPath ? `You are seeing skills for ${projectLabel ?? projectPath} plus your personal skills.` : 'You are seeing only your personal skills right now.'} @@ -428,7 +428,7 @@ export const SkillsPanel = ({

{skillsSearchQuery ? 'Try a different search term or switch filters.' - : 'Create your first skill to teach Claude a repeatable workflow, or import one you already use.'} + : 'Create your first skill to teach a repeatable workflow, or import one you already use.'}

)} @@ -527,7 +527,7 @@ export const SkillsPanel = ({

Personal skills

- Habits and instructions you want Claude to remember everywhere. + Habits and instructions you want available everywhere.

diff --git a/test/renderer/components/extensions/mcp/McpServersPanel.test.ts b/test/renderer/components/extensions/mcp/McpServersPanel.test.ts index 7a31088d..8b86b574 100644 --- a/test/renderer/components/extensions/mcp/McpServersPanel.test.ts +++ b/test/renderer/components/extensions/mcp/McpServersPanel.test.ts @@ -2,6 +2,8 @@ import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { CLI_NOT_FOUND_MARKER } from '@shared/constants/cli'; + interface StoreState { mcpBrowseCatalog: Array<{ id: string; @@ -32,6 +34,9 @@ interface StoreState { mcpDiagnosticsLastCheckedAt: number | null; mcpDiagnosticsLastCheckedAtByProjectPath?: Record; runMcpDiagnostics: ReturnType; + cliStatus?: { + flavor?: 'claude' | 'agent_teams_orchestrator'; + }; } const storeState = {} as StoreState; @@ -139,6 +144,7 @@ describe('McpServersPanel initial browse loading', () => { storeState.mcpDiagnosticsLastCheckedAt = null; storeState.mcpDiagnosticsLastCheckedAtByProjectPath = undefined; storeState.runMcpDiagnostics = vi.fn(); + storeState.cliStatus = undefined; }); afterEach(() => { @@ -267,4 +273,40 @@ describe('McpServersPanel initial browse loading', () => { await Promise.resolve(); }); }); + + it('uses runtime-aware missing-runtime copy for multimodel diagnostics failures', async () => { + storeState.mcpDiagnosticsError = `${CLI_NOT_FOUND_MARKER}: missing runtime`; + storeState.cliStatus = { + flavor: 'agent_teams_orchestrator', + }; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(McpServersPanel, { + projectPath: null, + mcpSearchQuery: '', + mcpSearch: vi.fn(), + mcpSearchResults: [], + mcpSearchLoading: false, + mcpSearchWarnings: [], + selectedMcpServerId: null, + setSelectedMcpServerId: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Configured runtime not available'); + expect(host.textContent).toContain('MCP health checks require the configured runtime'); + expect(host.textContent).not.toContain('Claude CLI not installed'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); From 4775d4bc457214a682e30d8b48971db223ceaaa8 Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 17 Apr 2026 20:55:22 +0300 Subject: [PATCH 109/121] fix(extensions): use runtime-aware detail copy --- .../extensions/mcp/McpServerDetailDialog.tsx | 4 ++- .../extensions/skills/SkillDetailDialog.tsx | 2 +- .../extensions/skills/SkillEditorDialog.tsx | 10 +++--- .../mcp/McpServerDetailDialog.test.ts | 36 +++++++++++++++++++ 4 files changed, 45 insertions(+), 7 deletions(-) diff --git a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx index 54d61b32..c96d428d 100644 --- a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx +++ b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx @@ -118,6 +118,8 @@ export const McpServerDetailDialog = ({ .map((entry) => entry.name) .sort() .join('\0') ?? ''; + const statusSectionLabel = + cliStatus?.flavor === 'agent_teams_orchestrator' ? 'Runtime Status' : 'Claude Status'; const apiKeyLookupProjectPath = isProjectScopedMcpScope(scope) ? (projectPath ?? undefined) : undefined; @@ -409,7 +411,7 @@ export const McpServerDetailDialog = ({ {isInstalledForScope && (
- Claude Status + {statusSectionLabel} {diagnosticsLoading && !diagnostic ? (

- How Claude uses it + How it is used

{formatInvocationLabel(item.invocationMode)}

diff --git a/src/renderer/components/extensions/skills/SkillEditorDialog.tsx b/src/renderer/components/extensions/skills/SkillEditorDialog.tsx index b377ac16..8af5f748 100644 --- a/src/renderer/components/extensions/skills/SkillEditorDialog.tsx +++ b/src/renderer/components/extensions/skills/SkillEditorDialog.tsx @@ -482,7 +482,7 @@ export const SkillEditorDialog = ({
- + @@ -575,7 +575,7 @@ export const SkillEditorDialog = ({
- +