commit
d14e06473a
223 changed files with 29177 additions and 2881 deletions
13
README.md
13
README.md
|
|
@ -23,7 +23,7 @@
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<sub>100% free, open source. Auto-detects Claude/Codex. Use the provider access you already have - subscriptions or API keys. Not just coding agents.</sub>
|
<sub>Free desktop app for AI agent teams. Auto-detects Claude/Codex. Use the provider access you already have - subscriptions or API keys. Not just coding agents.</sub>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<img width="1500" height="1065" alt="demo" src="https://github.com/user-attachments/assets/9d502887-7e28-4a11-aedd-3bd45fdfb0d2" />
|
<img width="1500" height="1065" alt="demo" src="https://github.com/user-attachments/assets/9d502887-7e28-4a11-aedd-3bd45fdfb0d2" />
|
||||||
|
|
@ -49,6 +49,7 @@ https://github.com/user-attachments/assets/35e27989-726d-4059-8662-bae610e46b42
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
No prerequisites - the app can detect supported runtimes/providers and guide setup from the UI.
|
No prerequisites - the app can detect supported runtimes/providers and guide setup from the UI.
|
||||||
|
If you want the freshest version, clone the repo and run it from the `dev` branch.
|
||||||
|
|
||||||
<table align="center">
|
<table align="center">
|
||||||
<tr>
|
<tr>
|
||||||
|
|
@ -106,7 +107,7 @@ No prerequisites - the app can detect supported runtimes/providers and guide set
|
||||||
|
|
||||||
## What is this
|
## What is this
|
||||||
|
|
||||||
A local orchestration layer for AI agent teams across Claude and Codex.
|
An 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 or API keys
|
- **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
|
- **Assemble your team** — create agent teams with different roles that work autonomously in parallel
|
||||||
|
|
@ -126,6 +127,8 @@ A local orchestration layer for AI agent teams across Claude and Codex.
|
||||||
|
|
||||||
- **Task creation with attachments** — send a message to the team lead with any attached images. The lead will automatically create a fully described task and attach your files directly to the task for complete context.
|
- **Task creation with attachments** — send a message to the team lead with any attached images. The lead will automatically create a fully described task and attach your files directly to the task for complete context.
|
||||||
|
|
||||||
|
- **Auto-resume after rate limits** — when the lead hits a Claude rate limit and the reset time is known, the app can automatically nudge the lead to continue once the cooldown has passed
|
||||||
|
|
||||||
- **Deep session analysis** — detailed breakdown of what happened in each agent session: bash commands, reasoning, subprocesses
|
- **Deep session analysis** — detailed breakdown of what happened in each agent session: bash commands, reasoning, subprocesses
|
||||||
|
|
||||||
- **Smart task-to-log/changes matching** — automatically links session logs/changes to specific tasks
|
- **Smart task-to-log/changes matching** — automatically links session logs/changes to specific tasks
|
||||||
|
|
@ -212,7 +215,7 @@ No. The app guides runtime detection/setup and provider authentication from the
|
||||||
<details>
|
<details>
|
||||||
<summary><strong>Does it read or upload my code?</strong></summary>
|
<summary><strong>Does it read or upload my code?</strong></summary>
|
||||||
<br />
|
<br />
|
||||||
No. Everything runs locally. The app reads local runtime/session data to power the UI - your source code is never sent anywhere.
|
The app is not a cloud code-sync service. It reads local runtime/session data to power the UI, and your project stays on your machine unless you choose a provider/runtime path that sends data to that provider. In `multimodel` mode, startup may also perform runtime access and capability checks before launch.
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
|
|
@ -224,7 +227,7 @@ Yes. Agents send direct messages, create shared tasks, and leave comments - all
|
||||||
<details>
|
<details>
|
||||||
<summary><strong>Is it free?</strong></summary>
|
<summary><strong>Is it free?</strong></summary>
|
||||||
<br />
|
<br />
|
||||||
Yes, completely free and open source. The app has no paid tier of its own. To run agents, you only need access to a supported provider/runtime, such as Anthropic or Codex.
|
Yes, free and open source. The app has no paid tier of its own. To run agents, you only need access to a supported provider/runtime, such as Anthropic or Codex.
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
|
|
@ -251,7 +254,7 @@ Yes. Run multiple teams in one project or across different projects, even simult
|
||||||
|
|
||||||
## Tech stack
|
## Tech stack
|
||||||
|
|
||||||
Electron 40, React 19, TypeScript 5, Tailwind CSS 3, Zustand 4. Data from `~/.claude/` (session logs, todos, tasks). No cloud backend — everything runs locally.
|
Electron 40, React 19, TypeScript 5, Tailwind CSS 3, Zustand 4. Data from `~/.claude/` (session logs, todos, tasks). The desktop app works with local runtime/session state, while some runtime modes may also use provider or startup capability services when required.
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><strong>Build from source</strong></summary>
|
<summary><strong>Build from source</strong></summary>
|
||||||
|
|
|
||||||
4092
docs/extensions/plugin-kit-ai-integration-plan.md
Normal file
4092
docs/extensions/plugin-kit-ai-integration-plan.md
Normal file
File diff suppressed because it is too large
Load diff
496
docs/research/context-usage-audit.md
Normal file
496
docs/research/context-usage-audit.md
Normal file
|
|
@ -0,0 +1,496 @@
|
||||||
|
# Context Usage Audit
|
||||||
|
|
||||||
|
**Дата**: 2026-04-18
|
||||||
|
**Статус**: Research
|
||||||
|
**Goal**: проверить, как в проекте сейчас считается usage контекста, сверить это с official docs и с реальными логами, и зафиксировать, что нужно менять для понятного и точного UI
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
Главный вывод:
|
||||||
|
|
||||||
|
- ✅ Для **Anthropic prompt-side input** текущая базовая формула `input_tokens + cache_creation_input_tokens + cache_read_input_tokens` корректна.
|
||||||
|
- ❌ Для **"процент занятого контекста"** текущий UI смешивает несколько разных сущностей:
|
||||||
|
- total prompt input
|
||||||
|
- visible/debuggable context
|
||||||
|
- full context used in the turn
|
||||||
|
- guessed context window
|
||||||
|
- ❌ Кнопка открытия context panel на team screen сейчас показывает **не процент занятого контекста**, а смесь `visible context / total tokens`, при этом подписывает это как `of input`.
|
||||||
|
- ❌ Live lead context usage в team runtime **не учитывает `output_tokens`**, хотя Anthropic docs явно пишут, что input и output components count toward the context window.
|
||||||
|
- ⚠️ Для **Codex** текущие локальные session logs часто вообще не содержат usable input-side token telemetry: в `.jsonl` виден `output_tokens`, а `input_tokens/cache_*` остаются нулями. То есть "точный процент" для Codex из текущего источника правды пока получить нельзя.
|
||||||
|
- ⚠️ Для **Anthropic context window size** нельзя опираться только на `"[1m]"` suffix. По актуальным docs/релиз-ноутам окно зависит от конкретной модели: native `1M` уже есть у новых raw model ids вроде `claude-opus-4-7`, `claude-opus-4-6`, `claude-sonnet-4-6`, тогда как часть legacy путей остаётся на `200k` или временном beta-path.
|
||||||
|
|
||||||
|
## 1. Что сейчас считается в коде
|
||||||
|
|
||||||
|
### 1.1 Live lead context в team runtime
|
||||||
|
|
||||||
|
Источник:
|
||||||
|
|
||||||
|
- `src/main/services/team/TeamProvisioningService.ts`
|
||||||
|
|
||||||
|
Текущая формула:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
currentTokens = input_tokens + cache_creation_input_tokens + cache_read_input_tokens
|
||||||
|
percent = currentTokens / contextWindow
|
||||||
|
```
|
||||||
|
|
||||||
|
Это значение эмитится как `lead-context`.
|
||||||
|
|
||||||
|
Что важно:
|
||||||
|
|
||||||
|
- это **total prompt input**
|
||||||
|
- это **не full context used for the completed turn**
|
||||||
|
- `output_tokens` сейчас исключены
|
||||||
|
|
||||||
|
### 1.2 Context button на экране команды
|
||||||
|
|
||||||
|
Источник:
|
||||||
|
|
||||||
|
- `src/renderer/components/team/TeamDetailView.tsx`
|
||||||
|
|
||||||
|
Текущее поведение:
|
||||||
|
|
||||||
|
- собирается `visibleContextTokens = sumContextInjectionTokens(allContextInjections)`
|
||||||
|
- затем считается `visibleContextPercentLabel = formatPercentOfTotal(visibleContextTokens, lastAiGroupTotalTokens)`
|
||||||
|
- при этом `lastAiGroupTotalTokens` сейчас = `input + cache_read + cache_creation + output`
|
||||||
|
- но helper `formatPercentOfTotal()` возвращает строку вида `"X% of input"`
|
||||||
|
|
||||||
|
Итог:
|
||||||
|
|
||||||
|
- знаменатель уже **не input**
|
||||||
|
- числитель это вообще **visible subset**
|
||||||
|
- label говорит **of input**
|
||||||
|
- кнопка выглядит как будто это **общий context usage**
|
||||||
|
|
||||||
|
То есть тут сразу 3 semantic mismatch.
|
||||||
|
|
||||||
|
### 1.3 Session Context Panel / Token popover
|
||||||
|
|
||||||
|
Источники:
|
||||||
|
|
||||||
|
- `src/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsx`
|
||||||
|
- `src/renderer/components/common/TokenUsageDisplay.tsx`
|
||||||
|
|
||||||
|
Сейчас в проекте одновременно существуют 3 разных процента:
|
||||||
|
|
||||||
|
1. `visible_estimated / total_input`
|
||||||
|
2. `visible_estimated / (input + output + cache)`
|
||||||
|
3. `prompt_input / context_window`
|
||||||
|
|
||||||
|
Но в UI они местами называются почти одинаково.
|
||||||
|
|
||||||
|
## 2. Что говорят official docs
|
||||||
|
|
||||||
|
### 2.1 Anthropic: что такое `input_tokens` при caching
|
||||||
|
|
||||||
|
Official docs:
|
||||||
|
|
||||||
|
- [Anthropic prompt caching](https://docs.anthropic.com/ru/docs/build-with-claude/prompt-caching)
|
||||||
|
|
||||||
|
Ключевые факты:
|
||||||
|
|
||||||
|
- `input_tokens` - это только токены **после последней cache breakpoint**
|
||||||
|
- total prompt input считается как:
|
||||||
|
|
||||||
|
```text
|
||||||
|
total_input_tokens = cache_read_input_tokens + cache_creation_input_tokens + input_tokens
|
||||||
|
```
|
||||||
|
|
||||||
|
Источник:
|
||||||
|
|
||||||
|
- docs lines 491-500, 493-500, 495:
|
||||||
|
- `input_tokens` представляет только токены после последней точки разрыва кэша
|
||||||
|
- `total_input_tokens = cache_read_input_tokens + cache_creation_input_tokens + input_tokens`
|
||||||
|
|
||||||
|
Вывод:
|
||||||
|
|
||||||
|
- текущая базовая формула runtime для **Anthropic prompt input** правильная
|
||||||
|
- жалоба пользователя на "input percent" логична, потому что **`input_tokens` alone действительно не равен общему prompt input**
|
||||||
|
|
||||||
|
### 2.2 Anthropic: что вообще считается context window
|
||||||
|
|
||||||
|
Official docs:
|
||||||
|
|
||||||
|
- [Anthropic context windows](https://docs.anthropic.com/en/docs/build-with-claude/context-windows)
|
||||||
|
|
||||||
|
Ключевые факты:
|
||||||
|
|
||||||
|
- context window refers to all text model can reference, **including the response itself**
|
||||||
|
- при tool use docs прямо говорят:
|
||||||
|
- **all input and output components count toward the context window**
|
||||||
|
|
||||||
|
Источник:
|
||||||
|
|
||||||
|
- lines 194-197
|
||||||
|
- lines 215-220
|
||||||
|
- lines 255-262
|
||||||
|
|
||||||
|
Вывод:
|
||||||
|
|
||||||
|
- если UI обещает показать именно **"сколько контекста занято"**, то `output_tokens` игнорировать нельзя
|
||||||
|
- текущий live team formula under-reports occupied context for completed turn
|
||||||
|
|
||||||
|
### 2.3 Anthropic: thinking blocks
|
||||||
|
|
||||||
|
Official docs:
|
||||||
|
|
||||||
|
- [Anthropic context windows](https://docs.anthropic.com/en/docs/build-with-claude/context-windows)
|
||||||
|
|
||||||
|
Ключевой факт:
|
||||||
|
|
||||||
|
- previous thinking blocks are automatically stripped from future context
|
||||||
|
|
||||||
|
Источник:
|
||||||
|
|
||||||
|
- lines 225-239, especially 228 and 237
|
||||||
|
|
||||||
|
Вывод:
|
||||||
|
|
||||||
|
- есть важная разница между:
|
||||||
|
- **full context used during current turn**
|
||||||
|
- **context that will carry into future prompt**
|
||||||
|
- usage fields alone не дают perfectly exact "future carried context" без доп. нормализации thinking
|
||||||
|
|
||||||
|
### 2.4 Anthropic: какие модели сейчас имеют 1M context window
|
||||||
|
|
||||||
|
Official docs:
|
||||||
|
|
||||||
|
- [Anthropic models overview](https://platform.claude.com/docs/en/about-claude/models/overview)
|
||||||
|
- [Anthropic release notes](https://platform.claude.com/docs/en/release-notes/overview)
|
||||||
|
- [Anthropic context windows](https://platform.claude.com/docs/en/build-with-claude/context-windows)
|
||||||
|
|
||||||
|
Ключевые факты на дату проверки:
|
||||||
|
|
||||||
|
- current models overview показывает:
|
||||||
|
- `claude-opus-4-7` - `1M`
|
||||||
|
- `claude-sonnet-4-6` - `1M`
|
||||||
|
- `claude-haiku-4-5` - `200k`
|
||||||
|
- release notes отдельно фиксируют:
|
||||||
|
- с `2026-03-13` `1M` GA для `Claude Opus 4.6` и `Claude Sonnet 4.6`
|
||||||
|
- `2026-03-30` объявлен retirement beta-path для `Claude Sonnet 4.5` и `Claude Sonnet 4` на `2026-04-30`
|
||||||
|
- context windows page также указывает, что native long-context matrix уже не сводится к одному beta-header сценарию
|
||||||
|
|
||||||
|
Вывод:
|
||||||
|
|
||||||
|
- inference размера окна для Anthropic надо делать по **model matrix**, а не только по `"[1m]"` suffix
|
||||||
|
- internal app-alias `"[1m]"` всё ещё полезен как явный сигнал team UX, но для raw session model ids этого уже недостаточно
|
||||||
|
|
||||||
|
## 3. Что показывают реальные локальные логи
|
||||||
|
|
||||||
|
Проверены реальные `~/.claude/projects/*.jsonl`.
|
||||||
|
|
||||||
|
### 3.1 Claude / Anthropic
|
||||||
|
|
||||||
|
Типичный реальный кейс:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"usage": {
|
||||||
|
"input_tokens": 3,
|
||||||
|
"cache_creation_input_tokens": 9284,
|
||||||
|
"cache_read_input_tokens": 63347,
|
||||||
|
"output_tokens": 8
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Это значит:
|
||||||
|
|
||||||
|
- `input_tokens = 3` совсем не означает "в prompt было 3 токена"
|
||||||
|
- реальный total prompt input здесь:
|
||||||
|
|
||||||
|
```text
|
||||||
|
3 + 9284 + 63347 = 72634
|
||||||
|
```
|
||||||
|
|
||||||
|
То есть UI, который визуально намекает на "input %" без явного объяснения caching breakdown, будет выглядеть багованным даже если арифметика частично правильная.
|
||||||
|
|
||||||
|
### 3.2 Codex / OpenAI path в локальных session logs
|
||||||
|
|
||||||
|
Проверены реальные Codex entries в `~/.claude/projects/-Users-belief-dev-projects-claude-claude-team/**/*.jsonl`.
|
||||||
|
|
||||||
|
Типичный кейс:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"usage": {
|
||||||
|
"input_tokens": 0,
|
||||||
|
"cache_creation_input_tokens": 0,
|
||||||
|
"cache_read_input_tokens": 0,
|
||||||
|
"output_tokens": 650
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Повторяется много раз на `msg_codex_*`.
|
||||||
|
|
||||||
|
Вывод:
|
||||||
|
|
||||||
|
- текущий `.jsonl` source для Codex у нас часто не даёт usable prompt-side usage
|
||||||
|
- значит из **текущих session logs** нельзя честно строить accurate Codex context percent
|
||||||
|
- сначала нужен новый telemetry source или нормализация raw usage
|
||||||
|
|
||||||
|
## 4. Codex: что говорят official OpenAI docs
|
||||||
|
|
||||||
|
### 4.1 Context windows
|
||||||
|
|
||||||
|
Official docs:
|
||||||
|
|
||||||
|
- [GPT-5-Codex model](https://developers.openai.com/api/docs/models/gpt-5-codex)
|
||||||
|
- [codex-mini-latest model](https://developers.openai.com/api/docs/models/codex-mini-latest)
|
||||||
|
|
||||||
|
Ключевые факты на дату проверки:
|
||||||
|
|
||||||
|
- `GPT-5-Codex` - `400,000 context window`
|
||||||
|
- `codex-mini-latest` - `200,000 context window`
|
||||||
|
|
||||||
|
### 4.2 Cached prompt accounting
|
||||||
|
|
||||||
|
Official docs:
|
||||||
|
|
||||||
|
- [OpenAI prompt caching](https://developers.openai.com/api/docs/guides/prompt-caching)
|
||||||
|
|
||||||
|
Ключевой факт:
|
||||||
|
|
||||||
|
- usage exposes `prompt_tokens_details.cached_tokens`
|
||||||
|
|
||||||
|
Это означает:
|
||||||
|
|
||||||
|
- на уровне OpenAI API нужная prompt-side telemetry в принципе существует
|
||||||
|
- но наш текущий local session source её, похоже, не сохраняет/не нормализует
|
||||||
|
|
||||||
|
## 5. Конкретные проблемы в текущем проекте
|
||||||
|
|
||||||
|
### 5.1 Semantic mismatch: "visible context" vs "context used"
|
||||||
|
|
||||||
|
Сейчас рядом живут две разные сущности:
|
||||||
|
|
||||||
|
- **Visible Context** - то, что мы можем debug/reduce
|
||||||
|
- **Context Used** - сколько окна реально занято
|
||||||
|
|
||||||
|
Это не одно и то же.
|
||||||
|
|
||||||
|
Visible Context:
|
||||||
|
|
||||||
|
- это subset prompt-side content
|
||||||
|
- может сравниваться с total prompt input
|
||||||
|
|
||||||
|
Context Used:
|
||||||
|
|
||||||
|
- это usage against context window
|
||||||
|
- для Anthropic completed turn это ближе к `total_input + output`
|
||||||
|
|
||||||
|
### 5.2 Неправильный label на context button
|
||||||
|
|
||||||
|
Текущая button label на team screen:
|
||||||
|
|
||||||
|
- выглядит как общий context usage
|
||||||
|
- но фактически это visible subset percent
|
||||||
|
|
||||||
|
Это и есть один из главных user-facing bugs.
|
||||||
|
|
||||||
|
### 5.3 Inconsistent denominators
|
||||||
|
|
||||||
|
Сейчас по коду используются разные denominators:
|
||||||
|
|
||||||
|
- `totalInputTokens`
|
||||||
|
- `input + output + cache`
|
||||||
|
- `contextWindow`
|
||||||
|
|
||||||
|
Без явного переименования метрик UI всегда будет путать.
|
||||||
|
|
||||||
|
### 5.4 Early-run guessed context window
|
||||||
|
|
||||||
|
В `TeamProvisioningService` размер окна сначала может быть guessed:
|
||||||
|
|
||||||
|
- `200K` для `limitContext=true`
|
||||||
|
- иначе по model-specific matrix:
|
||||||
|
- internal Anthropic `"[1m]"` alias -> `1M`
|
||||||
|
- native long-context Anthropic raw ids (`claude-opus-4-7`, `claude-opus-4-6`, `claude-sonnet-4-6`) -> `1M`
|
||||||
|
- `GPT-5.4` / `GPT-5.4 pro` -> `1.05M`
|
||||||
|
- `codex-mini-latest` -> `200K`
|
||||||
|
- остальные текущие GPT-5/Codex team models -> `400K`
|
||||||
|
|
||||||
|
Потом он обновляется из `modelUsage.contextWindow`, если это поле пришло.
|
||||||
|
|
||||||
|
Значит:
|
||||||
|
|
||||||
|
- ранний live percent может быть временно неточным
|
||||||
|
|
||||||
|
### 5.5 Shared default drift
|
||||||
|
|
||||||
|
В shared utils есть:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
DEFAULT_CONTEXT_WINDOW = 200_000
|
||||||
|
```
|
||||||
|
|
||||||
|
Но team Anthropic UX по умолчанию исходит из `1M`.
|
||||||
|
|
||||||
|
Это не обязательно immediate arithmetic bug, но это source of drift для разных экранов и helper'ов.
|
||||||
|
|
||||||
|
## 6. Рекомендованная metric model
|
||||||
|
|
||||||
|
Если делать UI понятным и точным, нужно разделить **минимум 3 разные метрики**.
|
||||||
|
|
||||||
|
### 6.1 Prompt Input Used
|
||||||
|
|
||||||
|
Для Anthropic:
|
||||||
|
|
||||||
|
```text
|
||||||
|
prompt_input_used =
|
||||||
|
input_tokens +
|
||||||
|
cache_creation_input_tokens +
|
||||||
|
cache_read_input_tokens
|
||||||
|
```
|
||||||
|
|
||||||
|
Назначение:
|
||||||
|
|
||||||
|
- честный size текущего prompt
|
||||||
|
- хорошая база для Visible Context %
|
||||||
|
|
||||||
|
### 6.2 Context Window Used
|
||||||
|
|
||||||
|
Для Anthropic completed turn:
|
||||||
|
|
||||||
|
```text
|
||||||
|
context_window_used_approx =
|
||||||
|
prompt_input_used +
|
||||||
|
output_tokens
|
||||||
|
```
|
||||||
|
|
||||||
|
Почему `approx`:
|
||||||
|
|
||||||
|
- previous thinking blocks auto-strip from future turns
|
||||||
|
- exact future carried context нельзя получить из raw usage perfectly
|
||||||
|
|
||||||
|
Но если UI обещает "занятое окно прямо сейчас/на этом ходе", эта формула ближе к docs, чем текущая.
|
||||||
|
|
||||||
|
### 6.3 Visible Context Share
|
||||||
|
|
||||||
|
```text
|
||||||
|
visible_context_share = visible_context_estimated / prompt_input_used
|
||||||
|
```
|
||||||
|
|
||||||
|
Назначение:
|
||||||
|
|
||||||
|
- debug metric
|
||||||
|
- объясняет, какая часть prompt-а понятна и управляемая пользователю
|
||||||
|
|
||||||
|
Это **не** percent occupied context window.
|
||||||
|
|
||||||
|
## 7. Рекомендованный UI language
|
||||||
|
|
||||||
|
Вместо одного размыто слова `Context` лучше использовать разные подписи:
|
||||||
|
|
||||||
|
- `Context Used` - percent of context window
|
||||||
|
- `Prompt Input` - current prompt-side tokens
|
||||||
|
- `Visible Context` - debuggable subset of prompt
|
||||||
|
|
||||||
|
Тогда пользователь сразу видит:
|
||||||
|
|
||||||
|
- сколько занято всего
|
||||||
|
- сколько из этого prompt
|
||||||
|
- сколько из prompt мы реально понимаем по breakdown
|
||||||
|
|
||||||
|
## 8. Top 3 implementation options
|
||||||
|
|
||||||
|
### 1. Развести 3 разные метрики и переименовать UI честно
|
||||||
|
|
||||||
|
`🎯 10 🛡️ 9 🧠 7`
|
||||||
|
Примерно `180-260` строк изменений
|
||||||
|
|
||||||
|
Что сделать:
|
||||||
|
|
||||||
|
- team button показывает только `Context Used`
|
||||||
|
- panel header отдельно показывает:
|
||||||
|
- `Visible Context`
|
||||||
|
- `Prompt Input`
|
||||||
|
- `Context Window Used`
|
||||||
|
- `Visible Context` всегда считается только как доля prompt input
|
||||||
|
|
||||||
|
Плюсы:
|
||||||
|
|
||||||
|
- минимальный semantic debt
|
||||||
|
- почти все пользовательские жалобы закрываются сразу
|
||||||
|
- легче потом добавить Codex
|
||||||
|
|
||||||
|
Минусы:
|
||||||
|
|
||||||
|
- надо аккуратно переподписать UI в нескольких местах
|
||||||
|
|
||||||
|
### 2. Оставить один главный процент, но считать его по docs как `prompt + output`
|
||||||
|
|
||||||
|
`🎯 8 🛡️ 8 🧠 6`
|
||||||
|
Примерно `120-180` строк изменений
|
||||||
|
|
||||||
|
Что сделать:
|
||||||
|
|
||||||
|
- live team percent = `(input + cache_read + cache_creation + output) / contextWindow`
|
||||||
|
- `Visible Context` оставить только внутри sidebar/panel
|
||||||
|
|
||||||
|
Плюсы:
|
||||||
|
|
||||||
|
- очень понятная одна главная цифра
|
||||||
|
- максимально близко к official Anthropic context-window semantics
|
||||||
|
|
||||||
|
Минусы:
|
||||||
|
|
||||||
|
- future carried context всё равно не perfectly exact из-за thinking blocks
|
||||||
|
- нужен fallback wording, когда usage incomplete
|
||||||
|
|
||||||
|
### 3. Минимальный fix только label-ов и знаменателей
|
||||||
|
|
||||||
|
`🎯 6 🛡️ 6 🧠 3`
|
||||||
|
Примерно `40-90` строк изменений
|
||||||
|
|
||||||
|
Что сделать:
|
||||||
|
|
||||||
|
- перестать писать `of input`, если denominator не input
|
||||||
|
- button переименовать в `Visible`
|
||||||
|
- panel header явно разделить `Visible` и `Total`
|
||||||
|
|
||||||
|
Плюсы:
|
||||||
|
|
||||||
|
- быстро
|
||||||
|
- дешево
|
||||||
|
|
||||||
|
Минусы:
|
||||||
|
|
||||||
|
- не решает core semantic debt
|
||||||
|
- live lead percent всё ещё останется under-reported
|
||||||
|
|
||||||
|
## 9. Recommended next step
|
||||||
|
|
||||||
|
Рекомендую идти по **варианту 1**.
|
||||||
|
|
||||||
|
Почему:
|
||||||
|
|
||||||
|
- он закрывает и math, и naming, и UX confusion
|
||||||
|
- он не завязан только на Anthropic
|
||||||
|
- он даёт clean foundation для будущего Codex support
|
||||||
|
|
||||||
|
### Practical plan
|
||||||
|
|
||||||
|
1. Вынести явные type/terms для 3 метрик:
|
||||||
|
- `promptInputTokens`
|
||||||
|
- `contextWindowUsedTokens`
|
||||||
|
- `visibleContextTokens`
|
||||||
|
2. Исправить live Anthropic runtime formula и wording.
|
||||||
|
3. Перестать использовать label `of input` там, где denominator не `prompt input`.
|
||||||
|
4. Для Codex временно показывать:
|
||||||
|
- window size, если модель известна
|
||||||
|
- `context usage unavailable` или `output only`
|
||||||
|
- пока не появится raw prompt telemetry
|
||||||
|
|
||||||
|
## 10. Bottom line
|
||||||
|
|
||||||
|
Главная проблема сейчас не в одной строчке арифметики, а в том, что проект смешал:
|
||||||
|
|
||||||
|
- **prompt input**
|
||||||
|
- **visible debuggable context**
|
||||||
|
- **full context window usage**
|
||||||
|
|
||||||
|
В Anthropic path базовая input formula уже в целом нормальная, но UI поверх неё даёт неправильный смысл.
|
||||||
|
|
||||||
|
В Codex path проблема глубже:
|
||||||
|
|
||||||
|
- official API supports cached prompt accounting
|
||||||
|
- но наш текущий local session telemetry этого не доносит
|
||||||
|
- поэтому "точный % занятого контекста" для Codex пока нельзя обещать без нового data source
|
||||||
3221
docs/research/team-detail-snapshot-messages-activity-plan.md
Normal file
3221
docs/research/team-detail-snapshot-messages-activity-plan.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -55,7 +55,7 @@
|
||||||
import Anthropic from '@anthropic-ai/sdk';
|
import Anthropic from '@anthropic-ai/sdk';
|
||||||
const client = new Anthropic();
|
const client = new Anthropic();
|
||||||
const response = await client.messages.create({
|
const response = await client.messages.create({
|
||||||
model: 'claude-opus-4-6',
|
model: 'claude-opus-4-7',
|
||||||
messages: [{ role: 'user', content: 'Send message to teammate...' }],
|
messages: [{ role: 'user', content: 'Send message to teammate...' }],
|
||||||
tools: [/* SendMessage, TaskUpdate, etc. */]
|
tools: [/* SendMessage, TaskUpdate, etc. */]
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -459,6 +459,14 @@ export default defineConfig([
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'team-transcript-project-resolver-sonar-override',
|
||||||
|
files: ['src/main/services/team/TeamTranscriptProjectResolver.ts'],
|
||||||
|
rules: {
|
||||||
|
'sonarjs/no-identical-functions': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
// Preload script (Electron bridge)
|
// Preload script (Electron bridge)
|
||||||
{
|
{
|
||||||
name: 'electron-preload',
|
name: 'electron-preload',
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "claude-agent-teams-ui",
|
"name": "claude-agent-teams-ui",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "1.1.0",
|
"version": "1.3.0",
|
||||||
"description": "Desktop app that visualizes Claude Code session execution — explore conversations, track context usage, and analyze tool calls",
|
"description": "Desktop app that visualizes Claude Code session execution — explore conversations, track context usage, and analyze tool calls",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"author": {
|
"author": {
|
||||||
|
|
@ -143,6 +143,7 @@
|
||||||
"motion": "12.38.0",
|
"motion": "12.38.0",
|
||||||
"node-diff3": "^3.2.0",
|
"node-diff3": "^3.2.0",
|
||||||
"node-pty": "^1.1.0",
|
"node-pty": "^1.1.0",
|
||||||
|
"pidusage": "4.0.1",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-grid-layout": "^2.2.2",
|
"react-grid-layout": "^2.2.2",
|
||||||
|
|
@ -174,6 +175,7 @@
|
||||||
"@types/hast": "^3.0.4",
|
"@types/hast": "^3.0.4",
|
||||||
"@types/mdast": "^4.0.4",
|
"@types/mdast": "^4.0.4",
|
||||||
"@types/node": "^25.0.7",
|
"@types/node": "^25.0.7",
|
||||||
|
"@types/pidusage": "2.0.5",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@types/ssh2": "^1.15.5",
|
"@types/ssh2": "^1.15.5",
|
||||||
|
|
@ -213,7 +215,7 @@
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"appId": "com.agent-teams.app",
|
"appId": "com.agent-teams.app",
|
||||||
"productName": "Claude Agent Teams UI",
|
"productName": "Agent Teams UI",
|
||||||
"directories": {
|
"directories": {
|
||||||
"output": "release"
|
"output": "release"
|
||||||
},
|
},
|
||||||
|
|
@ -303,7 +305,8 @@
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
"electron",
|
"electron",
|
||||||
"node-pty"
|
"node-pty",
|
||||||
|
"cpu-features"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"knip": {
|
"knip": {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,10 @@ import { HANDOFF_CARD, NODE, TASK_PILL, MIN_VISIBLE_OPACITY } from '../constants
|
||||||
import type { CameraTransform } from '../hooks/useGraphCamera';
|
import type { CameraTransform } from '../hooks/useGraphCamera';
|
||||||
import { getHandoffAnchorTarget } from '../layout/launchAnchor';
|
import { getHandoffAnchorTarget } from '../layout/launchAnchor';
|
||||||
import type { GraphNode } from '../ports/types';
|
import type { GraphNode } from '../ports/types';
|
||||||
import type { TransientHandoffCard } from '../ui/transientHandoffs';
|
import {
|
||||||
|
getTransientHandoffCardAlpha,
|
||||||
|
type TransientHandoffCard,
|
||||||
|
} from '../ui/transientHandoffs';
|
||||||
import { truncateText } from './draw-misc';
|
import { truncateText } from './draw-misc';
|
||||||
import { hexWithAlpha, measureTextCached } from './render-cache';
|
import { hexWithAlpha, measureTextCached } from './render-cache';
|
||||||
|
|
||||||
|
|
@ -20,24 +23,24 @@ export function drawHandoffCards(
|
||||||
const { cards, nodeMap, time, camera, viewport } = params;
|
const { cards, nodeMap, time, camera, viewport } = params;
|
||||||
if (cards.length === 0) return;
|
if (cards.length === 0) return;
|
||||||
|
|
||||||
const stackIndexByDestination = new Map<string, number>();
|
const stackIndexByAnchor = new Map<string, number>();
|
||||||
let drawnCount = 0;
|
let drawnCount = 0;
|
||||||
|
|
||||||
for (const card of cards) {
|
for (const card of cards) {
|
||||||
if (drawnCount >= HANDOFF_CARD.maxVisible) break;
|
if (drawnCount >= HANDOFF_CARD.maxVisible) break;
|
||||||
const destinationNode = nodeMap.get(card.destinationNodeId);
|
const anchorNode = nodeMap.get(card.anchorNodeId);
|
||||||
if (!destinationNode || destinationNode.x == null || destinationNode.y == null) continue;
|
if (!anchorNode || anchorNode.x == null || anchorNode.y == null) continue;
|
||||||
|
|
||||||
const alpha = getCardAlpha(card, time);
|
const alpha = getTransientHandoffCardAlpha(card, time);
|
||||||
if (alpha <= MIN_VISIBLE_OPACITY) continue;
|
if (alpha <= MIN_VISIBLE_OPACITY) continue;
|
||||||
|
|
||||||
const previewLines = buildPreviewLines(ctx, card.preview);
|
const previewLines = buildPreviewLines(ctx, card.preview);
|
||||||
const height = HANDOFF_CARD.baseHeight + previewLines.length * HANDOFF_CARD.previewLineHeight;
|
const height = HANDOFF_CARD.baseHeight + previewLines.length * HANDOFF_CARD.previewLineHeight;
|
||||||
const stackIndex = stackIndexByDestination.get(card.destinationNodeId) ?? 0;
|
const stackIndex = stackIndexByAnchor.get(card.anchorNodeId) ?? 0;
|
||||||
stackIndexByDestination.set(card.destinationNodeId, stackIndex + 1);
|
stackIndexByAnchor.set(card.anchorNodeId, stackIndex + 1);
|
||||||
|
|
||||||
const position = getCardPosition({
|
const position = getCardPosition({
|
||||||
node: destinationNode,
|
node: anchorNode,
|
||||||
camera,
|
camera,
|
||||||
viewport,
|
viewport,
|
||||||
height,
|
height,
|
||||||
|
|
@ -59,15 +62,6 @@ export function drawHandoffCards(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCardAlpha(card: TransientHandoffCard, time: number): number {
|
|
||||||
const fadeIn = Math.min(1, (time - card.activatedAt) / HANDOFF_CARD.fadeInSeconds);
|
|
||||||
const fadeOutRemaining = card.expiresAt - time;
|
|
||||||
const fadeOut = fadeOutRemaining <= HANDOFF_CARD.fadeOutSeconds
|
|
||||||
? Math.max(0, fadeOutRemaining / HANDOFF_CARD.fadeOutSeconds)
|
|
||||||
: 1;
|
|
||||||
return Math.max(0, Math.min(1, fadeIn * fadeOut));
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCardPosition(params: {
|
function getCardPosition(params: {
|
||||||
node: GraphNode;
|
node: GraphNode;
|
||||||
camera: CameraTransform;
|
camera: CameraTransform;
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
* All state in refs — no React re-renders.
|
* All state in refs — no React re-renders.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useRef, useCallback } from 'react';
|
import { useRef, useCallback, useMemo } from 'react';
|
||||||
import type { GraphNode } from '../ports/types';
|
import type { GraphNode } from '../ports/types';
|
||||||
import { CAMERA, ANIM, NODE, TASK_PILL } from '../constants/canvas-constants';
|
import { CAMERA, ANIM, NODE, TASK_PILL } from '../constants/canvas-constants';
|
||||||
import type { WorldBounds } from '../layout/launchAnchor';
|
import type { WorldBounds } from '../layout/launchAnchor';
|
||||||
|
|
@ -170,17 +170,31 @@ export function useGraphCamera(): UseGraphCameraResult {
|
||||||
t.zoom = Math.max(CAMERA.minZoom, t.zoom / 1.2);
|
t.zoom = Math.max(CAMERA.minZoom, t.zoom / 1.2);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return useMemo(
|
||||||
transformRef,
|
() => ({
|
||||||
screenToWorld,
|
transformRef,
|
||||||
worldToScreen,
|
screenToWorld,
|
||||||
handleWheel,
|
worldToScreen,
|
||||||
handlePanStart,
|
handleWheel,
|
||||||
handlePanMove,
|
handlePanStart,
|
||||||
handlePanEnd,
|
handlePanMove,
|
||||||
zoomToFit,
|
handlePanEnd,
|
||||||
zoomIn,
|
zoomToFit,
|
||||||
zoomOut,
|
zoomIn,
|
||||||
updateInertia,
|
zoomOut,
|
||||||
};
|
updateInertia,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
screenToWorld,
|
||||||
|
worldToScreen,
|
||||||
|
handleWheel,
|
||||||
|
handlePanStart,
|
||||||
|
handlePanMove,
|
||||||
|
handlePanEnd,
|
||||||
|
zoomToFit,
|
||||||
|
zoomIn,
|
||||||
|
zoomOut,
|
||||||
|
updateInertia,
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
* Delegates hit testing to strategy pattern.
|
* Delegates hit testing to strategy pattern.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useRef, useCallback } from 'react';
|
import { useRef, useCallback, useMemo } from 'react';
|
||||||
import type { GraphNode } from '../ports/types';
|
import type { GraphNode } from '../ports/types';
|
||||||
import { ANIM } from '../constants/canvas-constants';
|
import { ANIM } from '../constants/canvas-constants';
|
||||||
import { findNodeAt } from '../canvas/hit-detection';
|
import { findNodeAt } from '../canvas/hit-detection';
|
||||||
|
|
@ -81,13 +81,16 @@ export function useGraphInteraction(
|
||||||
return findNodeAt(wx, wy, nodes);
|
return findNodeAt(wx, wy, nodes);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return useMemo(
|
||||||
hoveredNodeId,
|
() => ({
|
||||||
dragNodeId,
|
hoveredNodeId,
|
||||||
isDragging,
|
dragNodeId,
|
||||||
handleMouseDown,
|
isDragging,
|
||||||
handleMouseMove,
|
handleMouseDown,
|
||||||
handleMouseUp,
|
handleMouseMove,
|
||||||
handleDoubleClick,
|
handleMouseUp,
|
||||||
};
|
handleDoubleClick,
|
||||||
|
}),
|
||||||
|
[handleDoubleClick, handleMouseDown, handleMouseMove, handleMouseUp]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCallback, useEffect, useRef } from 'react';
|
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
import { ANIM_SPEED, NODE } from '../constants/canvas-constants';
|
import { ANIM_SPEED, NODE } from '../constants/canvas-constants';
|
||||||
import { getStateColor } from '../constants/colors';
|
import { getStateColor } from '../constants/colors';
|
||||||
|
|
@ -239,19 +239,29 @@ export function useGraphSimulation(): UseGraphSimulationResult {
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return useMemo(
|
||||||
stateRef,
|
() => ({
|
||||||
updateData,
|
stateRef,
|
||||||
tick,
|
updateData,
|
||||||
setNodePosition,
|
tick,
|
||||||
clearNodePosition,
|
setNodePosition,
|
||||||
clearTransientOwnerPositions,
|
clearNodePosition,
|
||||||
resolveNearestOwnerSlot,
|
clearTransientOwnerPositions,
|
||||||
getLaunchAnchorWorldPosition: (leadNodeId: string) =>
|
resolveNearestOwnerSlot,
|
||||||
launchAnchorPositionsRef.current.get(leadNodeId) ?? null,
|
getLaunchAnchorWorldPosition: (leadNodeId: string) =>
|
||||||
getActivityWorldRect: (nodeId: string) => activityRectByNodeIdRef.current.get(nodeId) ?? null,
|
launchAnchorPositionsRef.current.get(leadNodeId) ?? null,
|
||||||
getExtraWorldBounds: () => extraWorldBoundsRef.current,
|
getActivityWorldRect: (nodeId: string) => activityRectByNodeIdRef.current.get(nodeId) ?? null,
|
||||||
};
|
getExtraWorldBounds: () => extraWorldBoundsRef.current,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
updateData,
|
||||||
|
tick,
|
||||||
|
setNodePosition,
|
||||||
|
clearNodePosition,
|
||||||
|
clearTransientOwnerPositions,
|
||||||
|
resolveNearestOwnerSlot,
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySnapshotToNodes(
|
function applySnapshotToNodes(
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@
|
||||||
export { GraphView } from './ui/GraphView';
|
export { GraphView } from './ui/GraphView';
|
||||||
export type { GraphViewProps } from './ui/GraphView';
|
export type { GraphViewProps } from './ui/GraphView';
|
||||||
export { ACTIVITY_ANCHOR_LAYOUT, ACTIVITY_LANE } from './layout/activityLane';
|
export { ACTIVITY_ANCHOR_LAYOUT, ACTIVITY_LANE } from './layout/activityLane';
|
||||||
|
export { getTransientHandoffCardAlpha } from './ui/transientHandoffs';
|
||||||
|
export type { TransientHandoffCard } from './ui/transientHandoffs';
|
||||||
|
|
||||||
// ─── Port Interfaces (for adapters in host project) ─────────────────────────
|
// ─── Port Interfaces (for adapters in host project) ─────────────────────────
|
||||||
export type { GraphDataPort } from './ports/GraphDataPort';
|
export type { GraphDataPort } from './ports/GraphDataPort';
|
||||||
|
|
|
||||||
|
|
@ -173,12 +173,16 @@ export function buildStableSlotLayoutSnapshot({
|
||||||
);
|
);
|
||||||
const leadActivityRect = leadSlotFrame.activityColumnRect;
|
const leadActivityRect = leadSlotFrame.activityColumnRect;
|
||||||
const launchHudRect = createRect(leadCoreRect.right, leadCoreRect.top, 0, 0);
|
const launchHudRect = createRect(leadCoreRect.right, leadCoreRect.top, 0, 0);
|
||||||
const leadCentralReservedBlock = leadSlotFrame.bounds;
|
const leadCentralReservedBlock = buildLeadCentralReservedBlock({
|
||||||
|
leadCoreRect,
|
||||||
|
leadSlotFrame,
|
||||||
|
});
|
||||||
|
|
||||||
const ownerFootprints = computeOwnerFootprints(nodes, layout);
|
const ownerFootprints = computeOwnerFootprints(nodes, layout);
|
||||||
const unassignedTaskRect = buildUnassignedTaskRect(nodes, leadCentralReservedBlock);
|
const unassignedTaskRect = buildUnassignedTaskRect(nodes, leadCentralReservedBlock);
|
||||||
const centralCollisionRects = buildCentralCollisionRects({
|
const centralCollisionRects = buildCentralCollisionRects({
|
||||||
leadCentralReservedBlock,
|
leadCoreRect,
|
||||||
|
leadSlotFrame,
|
||||||
unassignedTaskRect,
|
unassignedTaskRect,
|
||||||
});
|
});
|
||||||
const runtimeCentralExclusion = padRect(
|
const runtimeCentralExclusion = padRect(
|
||||||
|
|
@ -222,16 +226,34 @@ export function buildStableSlotLayoutSnapshot({
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCentralCollisionRects(args: {
|
function buildCentralCollisionRects(args: {
|
||||||
leadCentralReservedBlock: StableRect;
|
leadCoreRect: StableRect;
|
||||||
|
leadSlotFrame: SlotFrame;
|
||||||
unassignedTaskRect: StableRect | null;
|
unassignedTaskRect: StableRect | null;
|
||||||
}): StableRect[] {
|
}): StableRect[] {
|
||||||
const rects = [args.leadCentralReservedBlock];
|
const rects = [
|
||||||
|
args.leadCoreRect,
|
||||||
|
args.leadSlotFrame.processBandRect,
|
||||||
|
args.leadSlotFrame.activityColumnRect,
|
||||||
|
args.leadSlotFrame.kanbanBandRect,
|
||||||
|
];
|
||||||
if (args.unassignedTaskRect) {
|
if (args.unassignedTaskRect) {
|
||||||
rects.push(args.unassignedTaskRect);
|
rects.push(args.unassignedTaskRect);
|
||||||
}
|
}
|
||||||
return rects;
|
return rects;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildLeadCentralReservedBlock(args: {
|
||||||
|
leadCoreRect: StableRect;
|
||||||
|
leadSlotFrame: SlotFrame;
|
||||||
|
}): StableRect {
|
||||||
|
return unionRects([
|
||||||
|
args.leadCoreRect,
|
||||||
|
args.leadSlotFrame.processBandRect,
|
||||||
|
args.leadSlotFrame.activityColumnRect,
|
||||||
|
args.leadSlotFrame.kanbanBandRect,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
function padCentralCollisionRects(
|
function padCentralCollisionRects(
|
||||||
rects: readonly StableRect[],
|
rects: readonly StableRect[],
|
||||||
padding: number
|
padding: number
|
||||||
|
|
@ -648,6 +670,12 @@ function validateLeadSnapshotRects(
|
||||||
if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadActivityRect)) {
|
if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadActivityRect)) {
|
||||||
return { valid: false, reason: 'leadActivityRect must fit inside leadCentralReservedBlock' };
|
return { valid: false, reason: 'leadActivityRect must fit inside leadCentralReservedBlock' };
|
||||||
}
|
}
|
||||||
|
if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadSlotFrame.processBandRect)) {
|
||||||
|
return { valid: false, reason: 'lead processBandRect must fit inside leadCentralReservedBlock' };
|
||||||
|
}
|
||||||
|
if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadSlotFrame.kanbanBandRect)) {
|
||||||
|
return { valid: false, reason: 'lead kanbanBandRect must fit inside leadCentralReservedBlock' };
|
||||||
|
}
|
||||||
if (snapshot.leadActivityRect.left !== snapshot.leadSlotFrame.activityColumnRect.left) {
|
if (snapshot.leadActivityRect.left !== snapshot.leadSlotFrame.activityColumnRect.left) {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
|
|
@ -660,9 +688,6 @@ function validateLeadSnapshotRects(
|
||||||
reason: 'leadActivityRect must mirror leadSlotFrame.activityColumnRect',
|
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)) {
|
if (!rectContainsRect(snapshot.runtimeCentralExclusion, snapshot.leadCentralReservedBlock)) {
|
||||||
return { valid: false, reason: 'runtimeCentralExclusion must contain leadCentralReservedBlock' };
|
return { valid: false, reason: 'runtimeCentralExclusion must contain leadCentralReservedBlock' };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ import {
|
||||||
import {
|
import {
|
||||||
createTransientHandoffState,
|
createTransientHandoffState,
|
||||||
selectRenderableTransientHandoffCards,
|
selectRenderableTransientHandoffCards,
|
||||||
|
type TransientHandoffCard,
|
||||||
updateTransientHandoffState,
|
updateTransientHandoffState,
|
||||||
} from './transientHandoffs';
|
} from './transientHandoffs';
|
||||||
import type { CameraTransform } from '../hooks/useGraphCamera';
|
import type { CameraTransform } from '../hooks/useGraphCamera';
|
||||||
|
|
@ -70,6 +71,14 @@ export interface GraphCanvasHandle {
|
||||||
draw: (state: GraphDrawState) => void;
|
draw: (state: GraphDrawState) => void;
|
||||||
/** Get the canvas element for coordinate transforms */
|
/** Get the canvas element for coordinate transforms */
|
||||||
getCanvas: () => HTMLCanvasElement | null;
|
getCanvas: () => HTMLCanvasElement | null;
|
||||||
|
/** Read current transient handoff cards for DOM HUD rendering */
|
||||||
|
getTransientHandoffSnapshot: (options?: {
|
||||||
|
focusNodeIds?: ReadonlySet<string> | null;
|
||||||
|
focusEdgeIds?: ReadonlySet<string> | null;
|
||||||
|
}) => {
|
||||||
|
cards: TransientHandoffCard[];
|
||||||
|
time: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GraphCanvasProps {
|
export interface GraphCanvasProps {
|
||||||
|
|
@ -163,6 +172,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(funct
|
||||||
const activeParticleEdgesCache = useRef(new Set<string>());
|
const activeParticleEdgesCache = useRef(new Set<string>());
|
||||||
const handoffStateRef = useRef(createTransientHandoffState());
|
const handoffStateRef = useRef(createTransientHandoffState());
|
||||||
const lastTeamNameRef = useRef<string | null>(null);
|
const lastTeamNameRef = useRef<string | null>(null);
|
||||||
|
const lastDrawTimeRef = useRef(0);
|
||||||
|
|
||||||
// Imperative draw function — called from RAF, NOT from React render
|
// Imperative draw function — called from RAF, NOT from React render
|
||||||
useImperativeHandle(
|
useImperativeHandle(
|
||||||
|
|
@ -181,6 +191,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(funct
|
||||||
if (w === 0 || h === 0) return;
|
if (w === 0 || h === 0) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
lastDrawTimeRef.current = state.time;
|
||||||
if (lastTeamNameRef.current !== state.teamName) {
|
if (lastTeamNameRef.current !== state.teamName) {
|
||||||
handoffStateRef.current = createTransientHandoffState();
|
handoffStateRef.current = createTransientHandoffState();
|
||||||
lastTeamNameRef.current = state.teamName;
|
lastTeamNameRef.current = state.teamName;
|
||||||
|
|
@ -309,9 +320,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(funct
|
||||||
focusNodeIds: state.focusNodeIds,
|
focusNodeIds: state.focusNodeIds,
|
||||||
focusEdgeIds: prioritizedEdgeIds ?? state.focusEdgeIds,
|
focusEdgeIds: prioritizedEdgeIds ?? state.focusEdgeIds,
|
||||||
}
|
}
|
||||||
).filter(
|
).filter((card) => card.anchorKind !== 'lead' && card.anchorKind !== 'member');
|
||||||
(card) => card.destinationKind !== 'lead' && card.destinationKind !== 'member'
|
|
||||||
);
|
|
||||||
drawParticles(ctx, renderableParticles, edgeMap, nodeMap, state.time, prioritizedEdgeIds);
|
drawParticles(ctx, renderableParticles, edgeMap, nodeMap, state.time, prioritizedEdgeIds);
|
||||||
|
|
||||||
// 2c. Visible nodes only (back to front: process → task → member/lead)
|
// 2c. Visible nodes only (back to front: process → task → member/lead)
|
||||||
|
|
@ -419,6 +428,10 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(funct
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
getCanvas: () => canvasRef.current,
|
getCanvas: () => canvasRef.current,
|
||||||
|
getTransientHandoffSnapshot: (options) => ({
|
||||||
|
cards: selectRenderableTransientHandoffCards(handoffStateRef.current, options),
|
||||||
|
time: lastDrawTimeRef.current,
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
[showHexGrid, showStarField, bloomIntensity]
|
[showHexGrid, showStarField, bloomIntensity]
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import { GraphControls, type GraphFilterState } from './GraphControls';
|
||||||
import { GraphOverlay } from './GraphOverlay';
|
import { GraphOverlay } from './GraphOverlay';
|
||||||
import { GraphEdgeOverlay } from './GraphEdgeOverlay';
|
import { GraphEdgeOverlay } from './GraphEdgeOverlay';
|
||||||
import { buildFocusState } from './buildFocusState';
|
import { buildFocusState } from './buildFocusState';
|
||||||
|
import type { TransientHandoffCard } from './transientHandoffs';
|
||||||
import { useGraphSimulation } from '../hooks/useGraphSimulation';
|
import { useGraphSimulation } from '../hooks/useGraphSimulation';
|
||||||
import { useGraphCamera } from '../hooks/useGraphCamera';
|
import { useGraphCamera } from '../hooks/useGraphCamera';
|
||||||
import { useGraphInteraction } from '../hooks/useGraphInteraction';
|
import { useGraphInteraction } from '../hooks/useGraphInteraction';
|
||||||
|
|
@ -31,7 +32,7 @@ import {
|
||||||
findNodeAt,
|
findNodeAt,
|
||||||
getEdgeMidpoint,
|
getEdgeMidpoint,
|
||||||
} from '../canvas/hit-detection';
|
} from '../canvas/hit-detection';
|
||||||
import { ANIM_SPEED } from '../constants/canvas-constants';
|
import { ANIM, ANIM_SPEED } from '../constants/canvas-constants';
|
||||||
import { getLaunchAnchorScreenPlacement as buildLaunchAnchorScreenPlacement } from '../layout/launchAnchor';
|
import { getLaunchAnchorScreenPlacement as buildLaunchAnchorScreenPlacement } from '../layout/launchAnchor';
|
||||||
|
|
||||||
export interface GraphViewProps {
|
export interface GraphViewProps {
|
||||||
|
|
@ -73,11 +74,16 @@ export interface GraphViewProps {
|
||||||
leadNodeId: string,
|
leadNodeId: string,
|
||||||
) => { x: number; y: number; scale: number; visible: boolean } | null;
|
) => { x: number; y: number; scale: number; visible: boolean } | null;
|
||||||
getActivityWorldRect: (ownerNodeId: string) => StableRect | null;
|
getActivityWorldRect: (ownerNodeId: string) => StableRect | null;
|
||||||
|
getTransientHandoffSnapshot: (options?: {
|
||||||
|
focusNodeIds?: ReadonlySet<string> | null;
|
||||||
|
focusEdgeIds?: ReadonlySet<string> | null;
|
||||||
|
}) => { cards: TransientHandoffCard[]; time: number };
|
||||||
getCameraZoom: () => number;
|
getCameraZoom: () => number;
|
||||||
worldToScreen: (x: number, y: number) => { x: number; y: number };
|
worldToScreen: (x: number, y: number) => { x: number; y: number };
|
||||||
getNodeWorldPosition: (nodeId: string) => { x: number; y: number } | null;
|
getNodeWorldPosition: (nodeId: string) => { x: number; y: number } | null;
|
||||||
getViewportSize: () => { width: number; height: number };
|
getViewportSize: () => { width: number; height: number };
|
||||||
focusNodeIds: ReadonlySet<string> | null;
|
focusNodeIds: ReadonlySet<string> | null;
|
||||||
|
focusEdgeIds: ReadonlySet<string> | null;
|
||||||
}) => React.ReactNode;
|
}) => React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -142,13 +148,6 @@ export function GraphView({
|
||||||
// ─── Hooks ──────────────────────────────────────────────────────────────
|
// ─── Hooks ──────────────────────────────────────────────────────────────
|
||||||
const simulation = useGraphSimulation();
|
const simulation = useGraphSimulation();
|
||||||
const camera = useGraphCamera();
|
const camera = useGraphCamera();
|
||||||
|
|
||||||
// Stable refs for RAF loop (avoid recreating animate on hook identity change)
|
|
||||||
const simulationRef = useRef(simulation);
|
|
||||||
simulationRef.current = simulation;
|
|
||||||
const cameraRef = useRef(camera);
|
|
||||||
cameraRef.current = camera;
|
|
||||||
|
|
||||||
const interaction = useGraphInteraction(
|
const interaction = useGraphInteraction(
|
||||||
useCallback(
|
useCallback(
|
||||||
(nodeId: string, x: number, y: number) => {
|
(nodeId: string, x: number, y: number) => {
|
||||||
|
|
@ -158,6 +157,20 @@ export function GraphView({
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Stable refs for RAF loop (avoid recreating animate on hook identity change)
|
||||||
|
const simulationRef = useRef(simulation);
|
||||||
|
simulationRef.current = simulation;
|
||||||
|
const cameraRef = useRef(camera);
|
||||||
|
cameraRef.current = camera;
|
||||||
|
const interactionRef = useRef(interaction);
|
||||||
|
interactionRef.current = interaction;
|
||||||
|
const processActivePointerMoveRef = useRef<((clientX: number, clientY: number) => boolean) | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const completePointerInteractionRef = useRef<((clientX: number, clientY: number) => void) | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
const getVisibleNodes = useCallback(
|
const getVisibleNodes = useCallback(
|
||||||
(nodes: GraphNode[]): GraphNode[] =>
|
(nodes: GraphNode[]): GraphNode[] =>
|
||||||
nodes.filter((node) => {
|
nodes.filter((node) => {
|
||||||
|
|
@ -250,6 +263,17 @@ export function GraphView({
|
||||||
(ownerNodeId: string) => simulationRef.current.getActivityWorldRect(ownerNodeId),
|
(ownerNodeId: string) => simulationRef.current.getActivityWorldRect(ownerNodeId),
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
const getTransientHandoffSnapshot = useCallback(
|
||||||
|
(options?: {
|
||||||
|
focusNodeIds?: ReadonlySet<string> | null;
|
||||||
|
focusEdgeIds?: ReadonlySet<string> | null;
|
||||||
|
}) =>
|
||||||
|
canvasHandle.current?.getTransientHandoffSnapshot(options) ?? {
|
||||||
|
cards: [],
|
||||||
|
time: 0,
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
const getNodeWorldPosition = useCallback((nodeId: string) => {
|
const getNodeWorldPosition = useCallback((nodeId: string) => {
|
||||||
const node = simulationRef.current.stateRef.current.nodes.find((candidate) => candidate.id === nodeId);
|
const node = simulationRef.current.stateRef.current.nodes.find((candidate) => candidate.id === nodeId);
|
||||||
if (node?.x == null || node?.y == null) {
|
if (node?.x == null || node?.y == null) {
|
||||||
|
|
@ -416,16 +440,16 @@ export function GraphView({
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!isSurfaceActive) {
|
if (isSurfaceActive) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
interaction.handleMouseUp();
|
interactionRef.current.handleMouseUp();
|
||||||
simulation.clearTransientOwnerPositions();
|
simulationRef.current.clearTransientOwnerPositions();
|
||||||
dragPreviewRef.current = null;
|
dragPreviewRef.current = null;
|
||||||
isPanningRef.current = false;
|
isPanningRef.current = false;
|
||||||
edgeMouseDownRef.current = null;
|
edgeMouseDownRef.current = null;
|
||||||
setInteractionGuards(false);
|
setInteractionGuards(false);
|
||||||
}, [interaction, isSurfaceActive, simulation]);
|
}, [isSurfaceActive, setInteractionGuards]);
|
||||||
|
|
||||||
const handleWheel = useCallback(
|
const handleWheel = useCallback(
|
||||||
(e: WheelEvent) => {
|
(e: WheelEvent) => {
|
||||||
|
|
@ -437,7 +461,13 @@ export function GraphView({
|
||||||
|
|
||||||
// ─── Mouse handlers (Figma-style: drag empty space = pan, drag node = move) ─
|
// ─── Mouse handlers (Figma-style: drag empty space = pan, drag node = move) ─
|
||||||
const isPanningRef = useRef(false);
|
const isPanningRef = useRef(false);
|
||||||
const edgeMouseDownRef = useRef<{ id: string; x: number; y: number } | null>(null);
|
const edgeMouseDownRef = useRef<{
|
||||||
|
id: string;
|
||||||
|
worldX: number;
|
||||||
|
worldY: number;
|
||||||
|
clientX: number;
|
||||||
|
clientY: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const handleMouseDown = useCallback(
|
const handleMouseDown = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
|
|
@ -474,7 +504,13 @@ export function GraphView({
|
||||||
if (hitEdge) {
|
if (hitEdge) {
|
||||||
markUserInteracted();
|
markUserInteracted();
|
||||||
isPanningRef.current = false;
|
isPanningRef.current = false;
|
||||||
edgeMouseDownRef.current = { id: hitEdge, x: world.x, y: world.y };
|
edgeMouseDownRef.current = {
|
||||||
|
id: hitEdge,
|
||||||
|
worldX: world.x,
|
||||||
|
worldY: world.y,
|
||||||
|
clientX: e.clientX,
|
||||||
|
clientY: e.clientY,
|
||||||
|
};
|
||||||
hoveredEdgeIdRef.current = hitEdge;
|
hoveredEdgeIdRef.current = hitEdge;
|
||||||
} else {
|
} else {
|
||||||
// Hit empty space → pan
|
// Hit empty space → pan
|
||||||
|
|
@ -501,11 +537,6 @@ export function GraphView({
|
||||||
|
|
||||||
const processActivePointerMove = useCallback(
|
const processActivePointerMove = useCallback(
|
||||||
(clientX: number, clientY: number) => {
|
(clientX: number, clientY: number) => {
|
||||||
if (!activePrimaryInteractionRef.current) {
|
|
||||||
dragPreviewRef.current = null;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isPanningRef.current) {
|
if (isPanningRef.current) {
|
||||||
if (typeof document !== 'undefined') {
|
if (typeof document !== 'undefined') {
|
||||||
document.getSelection()?.removeAllRanges();
|
document.getSelection()?.removeAllRanges();
|
||||||
|
|
@ -514,6 +545,36 @@ export function GraphView({
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const edgeMouseDown = edgeMouseDownRef.current;
|
||||||
|
if (
|
||||||
|
edgeMouseDown &&
|
||||||
|
!interaction.dragNodeId.current &&
|
||||||
|
!interaction.isDragging.current
|
||||||
|
) {
|
||||||
|
const dx = clientX - edgeMouseDown.clientX;
|
||||||
|
const dy = clientY - edgeMouseDown.clientY;
|
||||||
|
if (dx * dx + dy * dy > ANIM.dragThresholdPx * ANIM.dragThresholdPx) {
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.getSelection()?.removeAllRanges();
|
||||||
|
}
|
||||||
|
hoveredEdgeIdRef.current = null;
|
||||||
|
edgeMouseDownRef.current = null;
|
||||||
|
isPanningRef.current = true;
|
||||||
|
camera.handlePanStart(edgeMouseDown.clientX, edgeMouseDown.clientY);
|
||||||
|
camera.handlePanMove(clientX, clientY);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!activePrimaryInteractionRef.current &&
|
||||||
|
!interaction.dragNodeId.current &&
|
||||||
|
!interaction.isDragging.current
|
||||||
|
) {
|
||||||
|
dragPreviewRef.current = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const canvas = canvasHandle.current?.getCanvas();
|
const canvas = canvasHandle.current?.getCanvas();
|
||||||
if (!canvas) {
|
if (!canvas) {
|
||||||
dragPreviewRef.current = null;
|
dragPreviewRef.current = null;
|
||||||
|
|
@ -610,8 +671,8 @@ export function GraphView({
|
||||||
if (canvas && edgeMouseDownRef.current && !interaction.isDragging.current) {
|
if (canvas && edgeMouseDownRef.current && !interaction.isDragging.current) {
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
const world = camera.screenToWorld(clientX - rect.left, clientY - rect.top);
|
const world = camera.screenToWorld(clientX - rect.left, clientY - rect.top);
|
||||||
const dx = world.x - edgeMouseDownRef.current.x;
|
const dx = world.x - edgeMouseDownRef.current.worldX;
|
||||||
const dy = world.y - edgeMouseDownRef.current.y;
|
const dy = world.y - edgeMouseDownRef.current.worldY;
|
||||||
if (dx * dx + dy * dy <= 25) {
|
if (dx * dx + dy * dy <= 25) {
|
||||||
clickedEdgeId = edgeMouseDownRef.current.id;
|
clickedEdgeId = edgeMouseDownRef.current.id;
|
||||||
}
|
}
|
||||||
|
|
@ -639,6 +700,8 @@ export function GraphView({
|
||||||
},
|
},
|
||||||
[camera, events, interaction, onOwnerSlotDrop, setInteractionGuards, simulation]
|
[camera, events, interaction, onOwnerSlotDrop, setInteractionGuards, simulation]
|
||||||
);
|
);
|
||||||
|
processActivePointerMoveRef.current = processActivePointerMove;
|
||||||
|
completePointerInteractionRef.current = completePointerInteraction;
|
||||||
|
|
||||||
const handleMouseMove = useCallback(
|
const handleMouseMove = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
|
|
@ -694,36 +757,40 @@ export function GraphView({
|
||||||
if (
|
if (
|
||||||
!activePrimaryInteractionRef.current &&
|
!activePrimaryInteractionRef.current &&
|
||||||
!isPanningRef.current &&
|
!isPanningRef.current &&
|
||||||
!interaction.dragNodeId.current &&
|
!interactionRef.current.dragNodeId.current &&
|
||||||
!interaction.isDragging.current &&
|
!interactionRef.current.isDragging.current &&
|
||||||
!edgeMouseDownRef.current
|
!edgeMouseDownRef.current
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
processActivePointerMove(event.clientX, event.clientY);
|
processActivePointerMoveRef.current?.(event.clientX, event.clientY);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleWindowMouseUp = (event: MouseEvent): void => {
|
const handleWindowMouseUp = (event: MouseEvent): void => {
|
||||||
if (
|
if (
|
||||||
!activePrimaryInteractionRef.current &&
|
!activePrimaryInteractionRef.current &&
|
||||||
!isPanningRef.current &&
|
!isPanningRef.current &&
|
||||||
!interaction.dragNodeId.current &&
|
!interactionRef.current.dragNodeId.current &&
|
||||||
!interaction.isDragging.current &&
|
!interactionRef.current.isDragging.current &&
|
||||||
!edgeMouseDownRef.current
|
!edgeMouseDownRef.current
|
||||||
) {
|
) {
|
||||||
setInteractionGuards(false);
|
setInteractionGuards(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
completePointerInteraction(event.clientX, event.clientY);
|
completePointerInteractionRef.current?.(event.clientX, event.clientY);
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearInteraction = (): void => {
|
const clearInteraction = (): void => {
|
||||||
if (!activePrimaryInteractionRef.current && !isPanningRef.current && !interaction.isDragging.current) {
|
if (
|
||||||
|
!activePrimaryInteractionRef.current &&
|
||||||
|
!isPanningRef.current &&
|
||||||
|
!interactionRef.current.isDragging.current
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
interaction.handleMouseUp();
|
interactionRef.current.handleMouseUp();
|
||||||
camera.handlePanEnd();
|
cameraRef.current.handlePanEnd();
|
||||||
isPanningRef.current = false;
|
isPanningRef.current = false;
|
||||||
edgeMouseDownRef.current = null;
|
edgeMouseDownRef.current = null;
|
||||||
dragPreviewRef.current = null;
|
dragPreviewRef.current = null;
|
||||||
|
|
@ -739,9 +806,14 @@ export function GraphView({
|
||||||
window.removeEventListener('mouseup', handleWindowMouseUp);
|
window.removeEventListener('mouseup', handleWindowMouseUp);
|
||||||
window.removeEventListener('blur', clearInteraction);
|
window.removeEventListener('blur', clearInteraction);
|
||||||
window.removeEventListener('dragstart', clearInteraction);
|
window.removeEventListener('dragstart', clearInteraction);
|
||||||
|
};
|
||||||
|
}, [setInteractionGuards]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
setInteractionGuards(false);
|
setInteractionGuards(false);
|
||||||
};
|
};
|
||||||
}, [camera, completePointerInteraction, interaction, processActivePointerMove, setInteractionGuards]);
|
}, [setInteractionGuards]);
|
||||||
|
|
||||||
const handleDoubleClick = useCallback(
|
const handleDoubleClick = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
|
|
@ -946,11 +1018,13 @@ export function GraphView({
|
||||||
{renderHud({
|
{renderHud({
|
||||||
getLaunchAnchorScreenPlacement,
|
getLaunchAnchorScreenPlacement,
|
||||||
getActivityWorldRect,
|
getActivityWorldRect,
|
||||||
|
getTransientHandoffSnapshot,
|
||||||
getCameraZoom,
|
getCameraZoom,
|
||||||
worldToScreen: camera.worldToScreen,
|
worldToScreen: camera.worldToScreen,
|
||||||
getNodeWorldPosition,
|
getNodeWorldPosition,
|
||||||
getViewportSize,
|
getViewportSize,
|
||||||
focusNodeIds: focusState.focusNodeIds,
|
focusNodeIds: focusState.focusNodeIds,
|
||||||
|
focusEdgeIds: focusState.focusEdgeIds,
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,16 @@ export interface TransientHandoffCard {
|
||||||
edgeId: string;
|
edgeId: string;
|
||||||
sourceNodeId: string;
|
sourceNodeId: string;
|
||||||
destinationNodeId: string;
|
destinationNodeId: string;
|
||||||
|
anchorNodeId: string;
|
||||||
|
anchorKind: GraphNode['kind'];
|
||||||
sourceLabel: string;
|
sourceLabel: string;
|
||||||
destinationLabel: string;
|
destinationLabel: string;
|
||||||
destinationKind: GraphNode['kind'];
|
destinationKind: GraphNode['kind'];
|
||||||
kind: HandoffParticleKind;
|
kind: HandoffParticleKind;
|
||||||
color: string;
|
color: string;
|
||||||
preview?: string;
|
preview?: string;
|
||||||
|
relatedTaskId?: string;
|
||||||
|
relatedTaskDisplayId?: string;
|
||||||
count: number;
|
count: number;
|
||||||
activatedAt: number;
|
activatedAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
|
|
@ -70,6 +74,12 @@ export function updateTransientHandoffState(
|
||||||
const sourceNode = nodeMap.get(sourceNodeId);
|
const sourceNode = nodeMap.get(sourceNodeId);
|
||||||
const destinationNode = nodeMap.get(destinationNodeId);
|
const destinationNode = nodeMap.get(destinationNodeId);
|
||||||
if (!sourceNode || !destinationNode) continue;
|
if (!sourceNode || !destinationNode) continue;
|
||||||
|
const anchorNode =
|
||||||
|
destinationNode.kind === 'lead' || destinationNode.kind === 'member'
|
||||||
|
? destinationNode
|
||||||
|
: sourceNode.kind === 'lead' || sourceNode.kind === 'member'
|
||||||
|
? sourceNode
|
||||||
|
: destinationNode;
|
||||||
|
|
||||||
const previewText = normalizePreviewText(particle.preview ?? particle.label);
|
const previewText = normalizePreviewText(particle.preview ?? particle.label);
|
||||||
if (particle.kind === 'inbox_message' && isLowSignalInboxPreview(previewText)) {
|
if (particle.kind === 'inbox_message' && isLowSignalInboxPreview(previewText)) {
|
||||||
|
|
@ -86,12 +96,16 @@ export function updateTransientHandoffState(
|
||||||
edgeId: edge.id,
|
edgeId: edge.id,
|
||||||
sourceNodeId,
|
sourceNodeId,
|
||||||
destinationNodeId,
|
destinationNodeId,
|
||||||
|
anchorNodeId: anchorNode.id,
|
||||||
|
anchorKind: anchorNode.kind,
|
||||||
sourceLabel: sourceNode.label,
|
sourceLabel: sourceNode.label,
|
||||||
destinationLabel: destinationNode.label,
|
destinationLabel: destinationNode.label,
|
||||||
destinationKind: destinationNode.kind,
|
destinationKind: destinationNode.kind,
|
||||||
kind: particle.kind,
|
kind: particle.kind,
|
||||||
color: particle.color,
|
color: particle.color,
|
||||||
preview: previewText ?? existing?.preview,
|
preview: previewText ?? existing?.preview,
|
||||||
|
relatedTaskId: edge.sourceTaskIds?.[0] ?? edge.targetTaskIds?.[0],
|
||||||
|
relatedTaskDisplayId: buildTaskDisplayId(edge.sourceTaskIds?.[0] ?? edge.targetTaskIds?.[0]),
|
||||||
count: nextCount,
|
count: nextCount,
|
||||||
activatedAt: existing?.activatedAt ?? time,
|
activatedAt: existing?.activatedAt ?? time,
|
||||||
updatedAt: time,
|
updatedAt: time,
|
||||||
|
|
@ -112,19 +126,19 @@ export function selectRenderableTransientHandoffCards(
|
||||||
const focusEdgeIds = options?.focusEdgeIds ?? null;
|
const focusEdgeIds = options?.focusEdgeIds ?? null;
|
||||||
const hasFocus = (focusNodeIds?.size ?? 0) > 0 || (focusEdgeIds?.size ?? 0) > 0;
|
const hasFocus = (focusNodeIds?.size ?? 0) > 0 || (focusEdgeIds?.size ?? 0) > 0;
|
||||||
|
|
||||||
const byDestination = new Map<string, TransientHandoffCard[]>();
|
const byAnchor = new Map<string, TransientHandoffCard[]>();
|
||||||
for (const card of state.cardsByKey.values()) {
|
for (const card of state.cardsByKey.values()) {
|
||||||
if (hasFocus && !isCardInFocus(card, focusNodeIds, focusEdgeIds)) continue;
|
if (hasFocus && !isCardInFocus(card, focusNodeIds, focusEdgeIds)) continue;
|
||||||
const destinationCards = byDestination.get(card.destinationNodeId);
|
const anchorCards = byAnchor.get(card.anchorNodeId);
|
||||||
if (destinationCards) {
|
if (anchorCards) {
|
||||||
destinationCards.push(card);
|
anchorCards.push(card);
|
||||||
} else {
|
} else {
|
||||||
byDestination.set(card.destinationNodeId, [card]);
|
byAnchor.set(card.anchorNodeId, [card]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const selected: TransientHandoffCard[] = [];
|
const selected: TransientHandoffCard[] = [];
|
||||||
for (const cards of byDestination.values()) {
|
for (const cards of byAnchor.values()) {
|
||||||
cards.sort((a, b) => b.updatedAt - a.updatedAt);
|
cards.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||||
selected.push(...cards.slice(0, HANDOFF_CARD.maxPerDestination));
|
selected.push(...cards.slice(0, HANDOFF_CARD.maxPerDestination));
|
||||||
}
|
}
|
||||||
|
|
@ -145,10 +159,21 @@ function isCardInFocus(
|
||||||
return (
|
return (
|
||||||
!!focusEdgeIds?.has(card.edgeId) ||
|
!!focusEdgeIds?.has(card.edgeId) ||
|
||||||
!!focusNodeIds?.has(card.sourceNodeId) ||
|
!!focusNodeIds?.has(card.sourceNodeId) ||
|
||||||
!!focusNodeIds?.has(card.destinationNodeId)
|
!!focusNodeIds?.has(card.destinationNodeId) ||
|
||||||
|
!!focusNodeIds?.has(card.anchorNodeId)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getTransientHandoffCardAlpha(card: TransientHandoffCard, time: number): number {
|
||||||
|
const fadeIn = Math.min(1, (time - card.activatedAt) / HANDOFF_CARD.fadeInSeconds);
|
||||||
|
const fadeOutRemaining = card.expiresAt - time;
|
||||||
|
const fadeOut =
|
||||||
|
fadeOutRemaining <= HANDOFF_CARD.fadeOutSeconds
|
||||||
|
? Math.max(0, fadeOutRemaining / HANDOFF_CARD.fadeOutSeconds)
|
||||||
|
: 1;
|
||||||
|
return Math.max(0, Math.min(1, fadeIn * fadeOut));
|
||||||
|
}
|
||||||
|
|
||||||
function normalizePreviewText(text: string | undefined): string | undefined {
|
function normalizePreviewText(text: string | undefined): string | undefined {
|
||||||
if (!text) return undefined;
|
if (!text) return undefined;
|
||||||
const normalized = text
|
const normalized = text
|
||||||
|
|
@ -161,3 +186,10 @@ function normalizePreviewText(text: string | undefined): string | undefined {
|
||||||
function isLowSignalInboxPreview(preview: string | undefined): boolean {
|
function isLowSignalInboxPreview(preview: string | undefined): boolean {
|
||||||
return preview === 'idle';
|
return preview === 'idle';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildTaskDisplayId(taskId: string | undefined): string | undefined {
|
||||||
|
if (!taskId) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return taskId.slice(0, 8);
|
||||||
|
}
|
||||||
|
|
|
||||||
101
pnpm-lock.yaml
101
pnpm-lock.yaml
|
|
@ -239,6 +239,9 @@ importers:
|
||||||
node-pty:
|
node-pty:
|
||||||
specifier: ^1.1.0
|
specifier: ^1.1.0
|
||||||
version: 1.1.0
|
version: 1.1.0
|
||||||
|
pidusage:
|
||||||
|
specifier: 4.0.1
|
||||||
|
version: 4.0.1
|
||||||
react:
|
react:
|
||||||
specifier: ^19.0.0
|
specifier: ^19.0.0
|
||||||
version: 19.2.4
|
version: 19.2.4
|
||||||
|
|
@ -327,6 +330,9 @@ importers:
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^25.0.7
|
specifier: ^25.0.7
|
||||||
version: 25.0.7
|
version: 25.0.7
|
||||||
|
'@types/pidusage':
|
||||||
|
specifier: 2.0.5
|
||||||
|
version: 2.0.5
|
||||||
'@types/react':
|
'@types/react':
|
||||||
specifier: ^19.0.0
|
specifier: ^19.0.0
|
||||||
version: 19.2.14
|
version: 19.2.14
|
||||||
|
|
@ -451,13 +457,13 @@ importers:
|
||||||
version: 0.11.3(magicast@0.5.2)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3)))
|
version: 0.11.3(magicast@0.5.2)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3)))
|
||||||
'@vueuse/nuxt':
|
'@vueuse/nuxt':
|
||||||
specifier: ^10.11.1
|
specifier: ^10.11.1
|
||||||
version: 10.11.1(magicast@0.5.2)(nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3))
|
version: 10.11.1(magicast@0.5.2)(nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3))
|
||||||
nuxt:
|
nuxt:
|
||||||
specifier: ^3.20.2
|
specifier: ^3.20.2
|
||||||
version: 3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2)
|
version: 3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(yaml@2.8.2)
|
||||||
nuxt-icon:
|
nuxt-icon:
|
||||||
specifier: ^0.6.10
|
specifier: ^0.6.10
|
||||||
version: 0.6.10(magicast@0.5.2)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3))
|
version: 0.6.10(magicast@0.5.2)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(vue@3.5.30(typescript@5.9.3))
|
||||||
pinia:
|
pinia:
|
||||||
specifier: ^3.0.4
|
specifier: ^3.0.4
|
||||||
version: 3.0.4(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3))
|
version: 3.0.4(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3))
|
||||||
|
|
@ -482,7 +488,7 @@ importers:
|
||||||
version: 7.4.47
|
version: 7.4.47
|
||||||
'@nuxt/eslint':
|
'@nuxt/eslint':
|
||||||
specifier: ^1.12.1
|
specifier: ^1.12.1
|
||||||
version: 1.15.2(@typescript-eslint/utils@8.57.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.30)(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1))(magicast@0.5.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
version: 1.15.2(@typescript-eslint/utils@8.57.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.30)(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1))(magicast@0.5.2)(typescript@5.9.3)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))
|
||||||
eslint:
|
eslint:
|
||||||
specifier: ^9.39.2
|
specifier: ^9.39.2
|
||||||
version: 9.39.2(jiti@2.6.1)
|
version: 9.39.2(jiti@2.6.1)
|
||||||
|
|
@ -494,7 +500,7 @@ importers:
|
||||||
version: 1.98.0
|
version: 1.98.0
|
||||||
vite-plugin-vuetify:
|
vite-plugin-vuetify:
|
||||||
specifier: ^2.1.3
|
specifier: ^2.1.3
|
||||||
version: 2.1.3(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3))(vuetify@3.12.3)
|
version: 2.1.3(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(vue@3.5.30(typescript@5.9.3))(vuetify@3.12.3)
|
||||||
|
|
||||||
mcp-server:
|
mcp-server:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
@ -4353,6 +4359,9 @@ packages:
|
||||||
'@types/pg@8.15.6':
|
'@types/pg@8.15.6':
|
||||||
resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==}
|
resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==}
|
||||||
|
|
||||||
|
'@types/pidusage@2.0.5':
|
||||||
|
resolution: {integrity: sha512-MIiyZI4/MK9UGUXWt0jJcCZhVw7YdhBuTOuqP/BjuLDLZ2PmmViMIQgZiWxtaMicQfAz/kMrZ5T7PKxFSkTeUA==}
|
||||||
|
|
||||||
'@types/plist@3.0.5':
|
'@types/plist@3.0.5':
|
||||||
resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==}
|
resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==}
|
||||||
|
|
||||||
|
|
@ -8591,6 +8600,10 @@ packages:
|
||||||
engines: {node: '>=0.10'}
|
engines: {node: '>=0.10'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
pidusage@4.0.1:
|
||||||
|
resolution: {integrity: sha512-yCH2dtLHfEBnzlHUJymR/Z1nN2ePG3m392Mv8TFlTP1B0xkpMQNHAnfkY0n2tAi6ceKO6YWhxYfZ96V4vVkh/g==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
pify@2.3.0:
|
pify@2.3.0:
|
||||||
resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
|
resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
@ -12589,20 +12602,20 @@ snapshots:
|
||||||
|
|
||||||
'@nuxt/devalue@2.0.2': {}
|
'@nuxt/devalue@2.0.2': {}
|
||||||
|
|
||||||
'@nuxt/devtools-kit@1.7.0(magicast@0.5.2)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
|
'@nuxt/devtools-kit@1.7.0(magicast@0.5.2)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nuxt/kit': 3.21.2(magicast@0.5.2)
|
'@nuxt/kit': 3.21.2(magicast@0.5.2)
|
||||||
'@nuxt/schema': 3.21.2
|
'@nuxt/schema': 3.21.2
|
||||||
execa: 7.2.0
|
execa: 7.2.0
|
||||||
vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
vite: 5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- magicast
|
- magicast
|
||||||
|
|
||||||
'@nuxt/devtools-kit@3.2.4(magicast@0.5.2)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
|
'@nuxt/devtools-kit@3.2.4(magicast@0.5.2)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nuxt/kit': 4.4.2(magicast@0.5.2)
|
'@nuxt/kit': 4.4.2(magicast@0.5.2)
|
||||||
execa: 8.0.1
|
execa: 8.0.1
|
||||||
vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
vite: 5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- magicast
|
- magicast
|
||||||
|
|
||||||
|
|
@ -12617,9 +12630,9 @@ snapshots:
|
||||||
pkg-types: 2.3.0
|
pkg-types: 2.3.0
|
||||||
semver: 7.7.4
|
semver: 7.7.4
|
||||||
|
|
||||||
'@nuxt/devtools@3.2.4(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3))':
|
'@nuxt/devtools@3.2.4(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(vue@3.5.30(typescript@5.9.3))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nuxt/devtools-kit': 3.2.4(magicast@0.5.2)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
'@nuxt/devtools-kit': 3.2.4(magicast@0.5.2)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))
|
||||||
'@nuxt/devtools-wizard': 3.2.4
|
'@nuxt/devtools-wizard': 3.2.4
|
||||||
'@nuxt/kit': 4.4.2(magicast@0.5.2)
|
'@nuxt/kit': 4.4.2(magicast@0.5.2)
|
||||||
'@vue/devtools-core': 8.1.0(vue@3.5.30(typescript@5.9.3))
|
'@vue/devtools-core': 8.1.0(vue@3.5.30(typescript@5.9.3))
|
||||||
|
|
@ -12647,9 +12660,9 @@ snapshots:
|
||||||
sirv: 3.0.2
|
sirv: 3.0.2
|
||||||
structured-clone-es: 2.0.0
|
structured-clone-es: 2.0.0
|
||||||
tinyglobby: 0.2.15
|
tinyglobby: 0.2.15
|
||||||
vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
vite: 5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0)
|
||||||
vite-plugin-inspect: 11.3.3(@nuxt/kit@4.4.2(magicast@0.5.2))(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
vite-plugin-inspect: 11.3.3(@nuxt/kit@4.4.2(magicast@0.5.2))(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))
|
||||||
vite-plugin-vue-tracer: 1.3.0(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3))
|
vite-plugin-vue-tracer: 1.3.0(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(vue@3.5.30(typescript@5.9.3))
|
||||||
which: 6.0.1
|
which: 6.0.1
|
||||||
ws: 8.20.0
|
ws: 8.20.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
|
|
@ -12698,10 +12711,10 @@ snapshots:
|
||||||
- supports-color
|
- supports-color
|
||||||
- typescript
|
- typescript
|
||||||
|
|
||||||
'@nuxt/eslint@1.15.2(@typescript-eslint/utils@8.57.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.30)(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1))(magicast@0.5.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
|
'@nuxt/eslint@1.15.2(@typescript-eslint/utils@8.57.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.30)(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1))(magicast@0.5.2)(typescript@5.9.3)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint/config-inspector': 1.5.0(eslint@9.39.2(jiti@2.6.1))
|
'@eslint/config-inspector': 1.5.0(eslint@9.39.2(jiti@2.6.1))
|
||||||
'@nuxt/devtools-kit': 3.2.4(magicast@0.5.2)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
'@nuxt/devtools-kit': 3.2.4(magicast@0.5.2)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))
|
||||||
'@nuxt/eslint-config': 1.15.2(@typescript-eslint/utils@8.57.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.30)(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
'@nuxt/eslint-config': 1.15.2(@typescript-eslint/utils@8.57.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.30)(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||||
'@nuxt/eslint-plugin': 1.15.2(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
'@nuxt/eslint-plugin': 1.15.2(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||||
'@nuxt/kit': 4.4.2(magicast@0.5.2)
|
'@nuxt/kit': 4.4.2(magicast@0.5.2)
|
||||||
|
|
@ -12777,7 +12790,7 @@ snapshots:
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- magicast
|
- magicast
|
||||||
|
|
||||||
'@nuxt/nitro-server@3.21.2(db0@0.3.4)(encoding@0.1.13)(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2))(typescript@5.9.3)':
|
'@nuxt/nitro-server@3.21.2(db0@0.3.4)(encoding@0.1.13)(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(yaml@2.8.2))(typescript@5.9.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nuxt/devalue': 2.0.2
|
'@nuxt/devalue': 2.0.2
|
||||||
'@nuxt/kit': 3.21.2(magicast@0.5.2)
|
'@nuxt/kit': 3.21.2(magicast@0.5.2)
|
||||||
|
|
@ -12795,7 +12808,7 @@ snapshots:
|
||||||
klona: 2.0.6
|
klona: 2.0.6
|
||||||
mocked-exports: 0.1.1
|
mocked-exports: 0.1.1
|
||||||
nitropack: 2.13.2(encoding@0.1.13)(idb-keyval@6.2.2)
|
nitropack: 2.13.2(encoding@0.1.13)(idb-keyval@6.2.2)
|
||||||
nuxt: 3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2)
|
nuxt: 3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(yaml@2.8.2)
|
||||||
ohash: 2.0.11
|
ohash: 2.0.11
|
||||||
pathe: 2.0.3
|
pathe: 2.0.3
|
||||||
pkg-types: 2.3.0
|
pkg-types: 2.3.0
|
||||||
|
|
@ -12860,7 +12873,7 @@ snapshots:
|
||||||
rc9: 3.0.0
|
rc9: 3.0.0
|
||||||
std-env: 3.10.0
|
std-env: 3.10.0
|
||||||
|
|
||||||
'@nuxt/vite-builder@3.21.2(@types/node@25.0.7)(eslint@9.39.2(jiti@2.6.1))(magicast@0.5.2)(nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2))(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3))(yaml@2.8.2)':
|
'@nuxt/vite-builder@3.21.2(@types/node@25.0.7)(eslint@9.39.2(jiti@2.6.1))(magicast@0.5.2)(nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(yaml@2.8.2))(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3))(yaml@2.8.2)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nuxt/kit': 3.21.2(magicast@0.5.2)
|
'@nuxt/kit': 3.21.2(magicast@0.5.2)
|
||||||
'@rollup/plugin-replace': 6.0.3(rollup@4.60.0)
|
'@rollup/plugin-replace': 6.0.3(rollup@4.60.0)
|
||||||
|
|
@ -12879,7 +12892,7 @@ snapshots:
|
||||||
magic-string: 0.30.21
|
magic-string: 0.30.21
|
||||||
mlly: 1.8.2
|
mlly: 1.8.2
|
||||||
mocked-exports: 0.1.1
|
mocked-exports: 0.1.1
|
||||||
nuxt: 3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2)
|
nuxt: 3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(yaml@2.8.2)
|
||||||
nypm: 0.6.5
|
nypm: 0.6.5
|
||||||
ohash: 2.0.11
|
ohash: 2.0.11
|
||||||
pathe: 2.0.3
|
pathe: 2.0.3
|
||||||
|
|
@ -15002,6 +15015,8 @@ snapshots:
|
||||||
pg-protocol: 1.13.0
|
pg-protocol: 1.13.0
|
||||||
pg-types: 2.2.0
|
pg-types: 2.2.0
|
||||||
|
|
||||||
|
'@types/pidusage@2.0.5': {}
|
||||||
|
|
||||||
'@types/plist@3.0.5':
|
'@types/plist@3.0.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 25.0.7
|
'@types/node': 25.0.7
|
||||||
|
|
@ -15595,13 +15610,13 @@ snapshots:
|
||||||
|
|
||||||
'@vueuse/metadata@10.11.1': {}
|
'@vueuse/metadata@10.11.1': {}
|
||||||
|
|
||||||
'@vueuse/nuxt@10.11.1(magicast@0.5.2)(nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3))':
|
'@vueuse/nuxt@10.11.1(magicast@0.5.2)(nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nuxt/kit': 3.21.2(magicast@0.5.2)
|
'@nuxt/kit': 3.21.2(magicast@0.5.2)
|
||||||
'@vueuse/core': 10.11.1(vue@3.5.30(typescript@5.9.3))
|
'@vueuse/core': 10.11.1(vue@3.5.30(typescript@5.9.3))
|
||||||
'@vueuse/metadata': 10.11.1
|
'@vueuse/metadata': 10.11.1
|
||||||
local-pkg: 0.5.1
|
local-pkg: 0.5.1
|
||||||
nuxt: 3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2)
|
nuxt: 3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(yaml@2.8.2)
|
||||||
vue-demi: 0.14.10(vue@3.5.30(typescript@5.9.3))
|
vue-demi: 0.14.10(vue@3.5.30(typescript@5.9.3))
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@vue/composition-api'
|
- '@vue/composition-api'
|
||||||
|
|
@ -20143,27 +20158,27 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
boolbase: 1.0.0
|
boolbase: 1.0.0
|
||||||
|
|
||||||
nuxt-icon@0.6.10(magicast@0.5.2)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3)):
|
nuxt-icon@0.6.10(magicast@0.5.2)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(vue@3.5.30(typescript@5.9.3)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@iconify/collections': 1.0.665
|
'@iconify/collections': 1.0.665
|
||||||
'@iconify/vue': 4.3.0(vue@3.5.30(typescript@5.9.3))
|
'@iconify/vue': 4.3.0(vue@3.5.30(typescript@5.9.3))
|
||||||
'@nuxt/devtools-kit': 1.7.0(magicast@0.5.2)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
'@nuxt/devtools-kit': 1.7.0(magicast@0.5.2)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))
|
||||||
'@nuxt/kit': 3.21.2(magicast@0.5.2)
|
'@nuxt/kit': 3.21.2(magicast@0.5.2)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- magicast
|
- magicast
|
||||||
- vite
|
- vite
|
||||||
- vue
|
- vue
|
||||||
|
|
||||||
nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2):
|
nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(yaml@2.8.2):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@dxup/nuxt': 0.4.0(magicast@0.5.2)(typescript@5.9.3)
|
'@dxup/nuxt': 0.4.0(magicast@0.5.2)(typescript@5.9.3)
|
||||||
'@nuxt/cli': 3.34.0(@nuxt/schema@3.21.2)(cac@6.7.14)(magicast@0.5.2)
|
'@nuxt/cli': 3.34.0(@nuxt/schema@3.21.2)(cac@6.7.14)(magicast@0.5.2)
|
||||||
'@nuxt/devtools': 3.2.4(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3))
|
'@nuxt/devtools': 3.2.4(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(vue@3.5.30(typescript@5.9.3))
|
||||||
'@nuxt/kit': 3.21.2(magicast@0.5.2)
|
'@nuxt/kit': 3.21.2(magicast@0.5.2)
|
||||||
'@nuxt/nitro-server': 3.21.2(db0@0.3.4)(encoding@0.1.13)(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2))(typescript@5.9.3)
|
'@nuxt/nitro-server': 3.21.2(db0@0.3.4)(encoding@0.1.13)(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(yaml@2.8.2))(typescript@5.9.3)
|
||||||
'@nuxt/schema': 3.21.2
|
'@nuxt/schema': 3.21.2
|
||||||
'@nuxt/telemetry': 2.7.0(@nuxt/kit@3.21.2(magicast@0.5.2))
|
'@nuxt/telemetry': 2.7.0(@nuxt/kit@3.21.2(magicast@0.5.2))
|
||||||
'@nuxt/vite-builder': 3.21.2(@types/node@25.0.7)(eslint@9.39.2(jiti@2.6.1))(magicast@0.5.2)(nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2))(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3))(yaml@2.8.2)
|
'@nuxt/vite-builder': 3.21.2(@types/node@25.0.7)(eslint@9.39.2(jiti@2.6.1))(magicast@0.5.2)(nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(yaml@2.8.2))(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3))(yaml@2.8.2)
|
||||||
'@unhead/vue': 2.1.12(vue@3.5.30(typescript@5.9.3))
|
'@unhead/vue': 2.1.12(vue@3.5.30(typescript@5.9.3))
|
||||||
'@vue/shared': 3.5.30
|
'@vue/shared': 3.5.30
|
||||||
c12: 3.3.3(magicast@0.5.2)
|
c12: 3.3.3(magicast@0.5.2)
|
||||||
|
|
@ -20647,6 +20662,10 @@ snapshots:
|
||||||
|
|
||||||
pidtree@0.6.0: {}
|
pidtree@0.6.0: {}
|
||||||
|
|
||||||
|
pidusage@4.0.1:
|
||||||
|
dependencies:
|
||||||
|
safe-buffer: 5.2.1
|
||||||
|
|
||||||
pify@2.3.0: {}
|
pify@2.3.0: {}
|
||||||
|
|
||||||
pinia@3.0.4(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3)):
|
pinia@3.0.4(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3)):
|
||||||
|
|
@ -22710,15 +22729,15 @@ snapshots:
|
||||||
'@types/unist': 3.0.3
|
'@types/unist': 3.0.3
|
||||||
vfile-message: 4.0.3
|
vfile-message: 4.0.3
|
||||||
|
|
||||||
vite-dev-rpc@1.1.0(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)):
|
vite-dev-rpc@1.1.0(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0)):
|
||||||
dependencies:
|
dependencies:
|
||||||
birpc: 2.9.0
|
birpc: 2.9.0
|
||||||
vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
vite: 5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0)
|
||||||
vite-hot-client: 2.1.0(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
vite-hot-client: 2.1.0(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))
|
||||||
|
|
||||||
vite-hot-client@2.1.0(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)):
|
vite-hot-client@2.1.0(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0)):
|
||||||
dependencies:
|
dependencies:
|
||||||
vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
vite: 5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0)
|
||||||
|
|
||||||
vite-node@3.2.4(@types/node@22.19.15)(sass@1.98.0)(terser@5.46.0):
|
vite-node@3.2.4(@types/node@22.19.15)(sass@1.98.0)(terser@5.46.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
@ -22792,7 +22811,7 @@ snapshots:
|
||||||
optionator: 0.9.4
|
optionator: 0.9.4
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
|
|
||||||
vite-plugin-inspect@11.3.3(@nuxt/kit@4.4.2(magicast@0.5.2))(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)):
|
vite-plugin-inspect@11.3.3(@nuxt/kit@4.4.2(magicast@0.5.2))(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0)):
|
||||||
dependencies:
|
dependencies:
|
||||||
ansis: 4.2.0
|
ansis: 4.2.0
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
|
|
@ -22802,29 +22821,29 @@ snapshots:
|
||||||
perfect-debounce: 2.1.0
|
perfect-debounce: 2.1.0
|
||||||
sirv: 3.0.2
|
sirv: 3.0.2
|
||||||
unplugin-utils: 0.3.1
|
unplugin-utils: 0.3.1
|
||||||
vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
vite: 5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0)
|
||||||
vite-dev-rpc: 1.1.0(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
vite-dev-rpc: 1.1.0(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@nuxt/kit': 4.4.2(magicast@0.5.2)
|
'@nuxt/kit': 4.4.2(magicast@0.5.2)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
vite-plugin-vue-tracer@1.3.0(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3)):
|
vite-plugin-vue-tracer@1.3.0(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(vue@3.5.30(typescript@5.9.3)):
|
||||||
dependencies:
|
dependencies:
|
||||||
estree-walker: 3.0.3
|
estree-walker: 3.0.3
|
||||||
exsolve: 1.0.8
|
exsolve: 1.0.8
|
||||||
magic-string: 0.30.21
|
magic-string: 0.30.21
|
||||||
pathe: 2.0.3
|
pathe: 2.0.3
|
||||||
source-map-js: 1.2.1
|
source-map-js: 1.2.1
|
||||||
vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
vite: 5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0)
|
||||||
vue: 3.5.30(typescript@5.9.3)
|
vue: 3.5.30(typescript@5.9.3)
|
||||||
|
|
||||||
vite-plugin-vuetify@2.1.3(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3))(vuetify@3.12.3):
|
vite-plugin-vuetify@2.1.3(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(vue@3.5.30(typescript@5.9.3))(vuetify@3.12.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vuetify/loader-shared': 2.1.2(vue@3.5.30(typescript@5.9.3))(vuetify@3.12.3)
|
'@vuetify/loader-shared': 2.1.2(vue@3.5.30(typescript@5.9.3))(vuetify@3.12.3)
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
upath: 2.0.1
|
upath: 2.0.1
|
||||||
vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
vite: 5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0)
|
||||||
vue: 3.5.30(typescript@5.9.3)
|
vue: 3.5.30(typescript@5.9.3)
|
||||||
vuetify: 3.12.3(typescript@5.9.3)(vite-plugin-vuetify@2.1.3)(vue@3.5.30(typescript@5.9.3))
|
vuetify: 3.12.3(typescript@5.9.3)(vite-plugin-vuetify@2.1.3)(vue@3.5.30(typescript@5.9.3))
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
|
|
@ -23023,7 +23042,7 @@ snapshots:
|
||||||
vue: 3.5.30(typescript@5.9.3)
|
vue: 3.5.30(typescript@5.9.3)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
vite-plugin-vuetify: 2.1.3(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3))(vuetify@3.12.3)
|
vite-plugin-vuetify: 2.1.3(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(vue@3.5.30(typescript@5.9.3))(vuetify@3.12.3)
|
||||||
|
|
||||||
w3c-keyname@2.2.8: {}
|
w3c-keyname@2.2.8: {}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/**
|
/**
|
||||||
* TeamGraphAdapter — transforms Zustand TeamData → GraphDataPort.
|
* TeamGraphAdapter — transforms store-backed team graph input → GraphDataPort.
|
||||||
*
|
*
|
||||||
* This adapter owns the graph projection from team runtime state into the
|
* This adapter owns the graph projection from team runtime state into the
|
||||||
* reusable package port model. Renderer hooks may still read store state, but
|
* reusable package port model. Renderer hooks may still read store state, but
|
||||||
|
|
@ -57,12 +57,18 @@ import type {
|
||||||
LeadActivityState,
|
LeadActivityState,
|
||||||
MemberSpawnStatusEntry,
|
MemberSpawnStatusEntry,
|
||||||
MemberSpawnStatusesSnapshot,
|
MemberSpawnStatusesSnapshot,
|
||||||
TeamData,
|
ResolvedTeamMember,
|
||||||
TeamProcess,
|
TeamProcess,
|
||||||
TeamProvisioningProgress,
|
TeamProvisioningProgress,
|
||||||
|
TeamViewSnapshot,
|
||||||
} from '@shared/types/team';
|
} from '@shared/types/team';
|
||||||
import type { LeadContextUsage } from '@shared/types/team';
|
import type { LeadContextUsage } from '@shared/types/team';
|
||||||
|
|
||||||
|
export interface TeamGraphData extends TeamViewSnapshot {
|
||||||
|
members: ResolvedTeamMember[];
|
||||||
|
messageFeed: InboxMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
export class TeamGraphAdapter {
|
export class TeamGraphAdapter {
|
||||||
// ─── ES #private fields ──────────────────────────────────────────────────
|
// ─── ES #private fields ──────────────────────────────────────────────────
|
||||||
#lastTeamName = '';
|
#lastTeamName = '';
|
||||||
|
|
@ -89,7 +95,7 @@ export class TeamGraphAdapter {
|
||||||
* Adapt team data into a GraphDataPort snapshot.
|
* Adapt team data into a GraphDataPort snapshot.
|
||||||
*/
|
*/
|
||||||
adapt(
|
adapt(
|
||||||
teamData: TeamData | null,
|
teamData: TeamGraphData | null,
|
||||||
teamName: string,
|
teamName: string,
|
||||||
spawnStatuses?: Record<string, MemberSpawnStatusEntry>,
|
spawnStatuses?: Record<string, MemberSpawnStatusEntry>,
|
||||||
leadActivity?: LeadActivityState,
|
leadActivity?: LeadActivityState,
|
||||||
|
|
@ -175,13 +181,22 @@ export class TeamGraphAdapter {
|
||||||
isTeamProvisioning,
|
isTeamProvisioning,
|
||||||
isLaunchSettling
|
isLaunchSettling
|
||||||
);
|
);
|
||||||
this.#buildTaskNodes(nodes, edges, teamData, teamName, commentReadState, memberNodeIdByAlias);
|
this.#buildTaskNodes(
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
teamData,
|
||||||
|
teamName,
|
||||||
|
commentReadState,
|
||||||
|
memberNodeIdByAlias,
|
||||||
|
leadId,
|
||||||
|
leadName
|
||||||
|
);
|
||||||
this.#buildProcessNodes(nodes, edges, teamData, teamName, memberNodeIdByAlias);
|
this.#buildProcessNodes(nodes, edges, teamData, teamName, memberNodeIdByAlias);
|
||||||
this.#attachActivityFeeds(nodes, teamData, teamName, leadId, leadName);
|
this.#attachActivityFeeds(nodes, teamData, teamName, leadId, leadName);
|
||||||
this.#buildMessageParticles(
|
this.#buildMessageParticles(
|
||||||
particles,
|
particles,
|
||||||
nodes,
|
nodes,
|
||||||
teamData.messages,
|
teamData.messageFeed,
|
||||||
teamName,
|
teamName,
|
||||||
leadId,
|
leadId,
|
||||||
leadName,
|
leadName,
|
||||||
|
|
@ -224,11 +239,11 @@ export class TeamGraphAdapter {
|
||||||
|
|
||||||
// ─── Private: node builders ──────────────────────────────────────────────
|
// ─── Private: node builders ──────────────────────────────────────────────
|
||||||
|
|
||||||
static #getLeadMemberName(data: TeamData, teamName: string): string {
|
static #getLeadMemberName(data: TeamGraphData, teamName: string): string {
|
||||||
return getGraphLeadMemberName(data, teamName);
|
return getGraphLeadMemberName(data, teamName);
|
||||||
}
|
}
|
||||||
|
|
||||||
static #buildMemberNodeIdByAlias(data: TeamData, teamName: string): Map<string, string> {
|
static #buildMemberNodeIdByAlias(data: TeamGraphData, teamName: string): Map<string, string> {
|
||||||
return buildGraphMemberNodeIdAliasMap(
|
return buildGraphMemberNodeIdAliasMap(
|
||||||
teamName,
|
teamName,
|
||||||
data.members.filter((member) => !isLeadMember(member))
|
data.members.filter((member) => !isLeadMember(member))
|
||||||
|
|
@ -236,7 +251,7 @@ export class TeamGraphAdapter {
|
||||||
}
|
}
|
||||||
|
|
||||||
static #buildLayoutPort(
|
static #buildLayoutPort(
|
||||||
data: TeamData,
|
data: TeamGraphData,
|
||||||
teamName: string,
|
teamName: string,
|
||||||
slotAssignments?: Record<string, GraphOwnerSlotAssignment>
|
slotAssignments?: Record<string, GraphOwnerSlotAssignment>
|
||||||
): GraphLayoutPort {
|
): GraphLayoutPort {
|
||||||
|
|
@ -254,7 +269,7 @@ export class TeamGraphAdapter {
|
||||||
);
|
);
|
||||||
const assignedStableOwnerIds = new Set(Object.keys(slotAssignments ?? {}));
|
const assignedStableOwnerIds = new Set(Object.keys(slotAssignments ?? {}));
|
||||||
|
|
||||||
const pushMember = (member: TeamData['members'][number] | undefined): void => {
|
const pushMember = (member: TeamGraphData['members'][number] | undefined): void => {
|
||||||
if (!member) {
|
if (!member) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -306,7 +321,7 @@ export class TeamGraphAdapter {
|
||||||
}
|
}
|
||||||
|
|
||||||
static #collectDuplicateStableOwnerIds(
|
static #collectDuplicateStableOwnerIds(
|
||||||
members: readonly TeamData['members'][number][]
|
members: readonly TeamGraphData['members'][number][]
|
||||||
): string[] {
|
): string[] {
|
||||||
const counts = new Map<string, number>();
|
const counts = new Map<string, number>();
|
||||||
for (const member of members) {
|
for (const member of members) {
|
||||||
|
|
@ -328,9 +343,9 @@ export class TeamGraphAdapter {
|
||||||
}
|
}
|
||||||
|
|
||||||
static #getRuntimeLabel(
|
static #getRuntimeLabel(
|
||||||
providerId: TeamData['members'][number]['providerId'],
|
providerId: ResolvedTeamMember['providerId'],
|
||||||
model: TeamData['members'][number]['model'],
|
model: ResolvedTeamMember['model'],
|
||||||
effort: TeamData['members'][number]['effort']
|
effort: ResolvedTeamMember['effort']
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
return formatTeamRuntimeSummary(providerId, model, effort);
|
return formatTeamRuntimeSummary(providerId, model, effort);
|
||||||
}
|
}
|
||||||
|
|
@ -351,7 +366,7 @@ export class TeamGraphAdapter {
|
||||||
#buildLeadNode(
|
#buildLeadNode(
|
||||||
nodes: GraphNode[],
|
nodes: GraphNode[],
|
||||||
leadId: string,
|
leadId: string,
|
||||||
data: TeamData,
|
data: TeamGraphData,
|
||||||
teamName: string,
|
teamName: string,
|
||||||
leadName: string,
|
leadName: string,
|
||||||
pendingApprovalAgents?: Set<string>,
|
pendingApprovalAgents?: Set<string>,
|
||||||
|
|
@ -362,7 +377,7 @@ export class TeamGraphAdapter {
|
||||||
toolHistory?: Record<string, ActiveToolCall[]>,
|
toolHistory?: Record<string, ActiveToolCall[]>,
|
||||||
isTeamProvisioning = false
|
isTeamProvisioning = false
|
||||||
): void {
|
): void {
|
||||||
const percent = leadContext?.percent;
|
const percent = leadContext?.contextUsedPercent;
|
||||||
const leadMember = data.members.find((member) => member.name === leadName);
|
const leadMember = data.members.find((member) => member.name === leadName);
|
||||||
const activeTool = TeamGraphAdapter.#selectVisibleTool(
|
const activeTool = TeamGraphAdapter.#selectVisibleTool(
|
||||||
activeTools?.[leadName],
|
activeTools?.[leadName],
|
||||||
|
|
@ -447,7 +462,7 @@ export class TeamGraphAdapter {
|
||||||
nodes: GraphNode[],
|
nodes: GraphNode[],
|
||||||
edges: GraphEdge[],
|
edges: GraphEdge[],
|
||||||
leadId: string,
|
leadId: string,
|
||||||
data: TeamData,
|
data: TeamGraphData,
|
||||||
teamName: string,
|
teamName: string,
|
||||||
memberNodeIdByAlias: ReadonlyMap<string, string>,
|
memberNodeIdByAlias: ReadonlyMap<string, string>,
|
||||||
spawnStatuses?: Record<string, MemberSpawnStatusEntry>,
|
spawnStatuses?: Record<string, MemberSpawnStatusEntry>,
|
||||||
|
|
@ -551,12 +566,14 @@ export class TeamGraphAdapter {
|
||||||
#buildTaskNodes(
|
#buildTaskNodes(
|
||||||
nodes: GraphNode[],
|
nodes: GraphNode[],
|
||||||
edges: GraphEdge[],
|
edges: GraphEdge[],
|
||||||
data: TeamData,
|
data: TeamGraphData,
|
||||||
teamName: string,
|
teamName: string,
|
||||||
commentReadState?: Record<string, unknown>,
|
commentReadState?: Record<string, unknown>,
|
||||||
memberNodeIdByAlias?: ReadonlyMap<string, string>
|
memberNodeIdByAlias?: ReadonlyMap<string, string>,
|
||||||
|
leadId?: string,
|
||||||
|
leadName?: string
|
||||||
): void {
|
): void {
|
||||||
const taskStateById = new Map<string, Pick<TeamData['tasks'][number], 'status'>>();
|
const taskStateById = new Map<string, Pick<TeamGraphData['tasks'][number], 'status'>>();
|
||||||
const taskDisplayIds = new Map<string, string>();
|
const taskDisplayIds = new Map<string, string>();
|
||||||
const memberColorByName = new Map<string, string>();
|
const memberColorByName = new Map<string, string>();
|
||||||
|
|
||||||
|
|
@ -575,7 +592,12 @@ export class TeamGraphAdapter {
|
||||||
for (const task of data.tasks) {
|
for (const task of data.tasks) {
|
||||||
if (task.status === 'deleted') continue;
|
if (task.status === 'deleted') continue;
|
||||||
const taskId = `task:${teamName}:${task.id}`;
|
const taskId = `task:${teamName}:${task.id}`;
|
||||||
const ownerMemberId = task.owner ? (memberNodeIdByAlias?.get(task.owner) ?? null) : null;
|
const ownerMemberId =
|
||||||
|
leadId && memberNodeIdByAlias
|
||||||
|
? TeamGraphAdapter.#resolveTaskOwnerId(task.owner, leadId, leadName, memberNodeIdByAlias)
|
||||||
|
: task.owner
|
||||||
|
? (memberNodeIdByAlias?.get(task.owner) ?? null)
|
||||||
|
: null;
|
||||||
const kanbanTaskState = data.kanbanState.tasks[task.id];
|
const kanbanTaskState = data.kanbanState.tasks[task.id];
|
||||||
const reviewerName = resolveTaskReviewer(task, kanbanTaskState);
|
const reviewerName = resolveTaskReviewer(task, kanbanTaskState);
|
||||||
const isReviewCycle = isTaskInReviewCycle(task);
|
const isReviewCycle = isTaskInReviewCycle(task);
|
||||||
|
|
@ -736,7 +758,7 @@ export class TeamGraphAdapter {
|
||||||
#buildProcessNodes(
|
#buildProcessNodes(
|
||||||
nodes: GraphNode[],
|
nodes: GraphNode[],
|
||||||
edges: GraphEdge[],
|
edges: GraphEdge[],
|
||||||
data: TeamData,
|
data: TeamGraphData,
|
||||||
teamName: string,
|
teamName: string,
|
||||||
memberNodeIdByAlias?: ReadonlyMap<string, string>
|
memberNodeIdByAlias?: ReadonlyMap<string, string>
|
||||||
): void {
|
): void {
|
||||||
|
|
@ -814,7 +836,7 @@ export class TeamGraphAdapter {
|
||||||
|
|
||||||
#attachActivityFeeds(
|
#attachActivityFeeds(
|
||||||
nodes: GraphNode[],
|
nodes: GraphNode[],
|
||||||
data: TeamData,
|
data: TeamGraphData,
|
||||||
teamName: string,
|
teamName: string,
|
||||||
leadId: string,
|
leadId: string,
|
||||||
leadName: string
|
leadName: string
|
||||||
|
|
@ -831,7 +853,10 @@ export class TeamGraphAdapter {
|
||||||
}
|
}
|
||||||
|
|
||||||
const entriesByOwnerNodeId = buildInlineActivityEntries({
|
const entriesByOwnerNodeId = buildInlineActivityEntries({
|
||||||
data,
|
data: {
|
||||||
|
...data,
|
||||||
|
messages: data.messageFeed,
|
||||||
|
},
|
||||||
teamName,
|
teamName,
|
||||||
leadId,
|
leadId,
|
||||||
leadName,
|
leadName,
|
||||||
|
|
@ -992,7 +1017,7 @@ export class TeamGraphAdapter {
|
||||||
|
|
||||||
#buildCommentParticles(
|
#buildCommentParticles(
|
||||||
particles: GraphParticle[],
|
particles: GraphParticle[],
|
||||||
data: TeamData,
|
data: TeamGraphData,
|
||||||
teamName: string,
|
teamName: string,
|
||||||
leadId: string,
|
leadId: string,
|
||||||
leadName: string,
|
leadName: string,
|
||||||
|
|
@ -1085,8 +1110,8 @@ export class TeamGraphAdapter {
|
||||||
}
|
}
|
||||||
|
|
||||||
static #buildMemberException(
|
static #buildMemberException(
|
||||||
runtimeAdvisory: TeamData['members'][number]['runtimeAdvisory'],
|
runtimeAdvisory: ResolvedTeamMember['runtimeAdvisory'],
|
||||||
providerId: TeamData['members'][number]['providerId'],
|
providerId: ResolvedTeamMember['providerId'],
|
||||||
spawn: MemberSpawnStatusEntry | undefined,
|
spawn: MemberSpawnStatusEntry | undefined,
|
||||||
pendingApproval: boolean
|
pendingApproval: boolean
|
||||||
): Pick<GraphNode, 'exceptionTone' | 'exceptionLabel'> | undefined {
|
): Pick<GraphNode, 'exceptionTone' | 'exceptionLabel'> | undefined {
|
||||||
|
|
@ -1228,6 +1253,25 @@ export class TeamGraphAdapter {
|
||||||
return memberNodeIdByAlias.get(name) ?? leadId;
|
return memberNodeIdByAlias.get(name) ?? leadId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static #resolveTaskOwnerId(
|
||||||
|
ownerName: string | null | undefined,
|
||||||
|
leadId: string,
|
||||||
|
leadName: string | undefined,
|
||||||
|
memberNodeIdByAlias: ReadonlyMap<string, string>
|
||||||
|
): string | null {
|
||||||
|
if (!ownerName?.trim()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const normalized = ownerName.trim().toLowerCase();
|
||||||
|
if (normalized === 'user' || normalized === 'team-lead') {
|
||||||
|
return leadId;
|
||||||
|
}
|
||||||
|
if (normalized === leadName?.trim().toLowerCase()) {
|
||||||
|
return leadId;
|
||||||
|
}
|
||||||
|
return memberNodeIdByAlias.get(ownerName) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Extract external team name from cross-team "from" field like "team-b.alice" */
|
/** Extract external team name from cross-team "from" field like "team-b.alice" */
|
||||||
static #extractExternalTeamName(from: string): string | null {
|
static #extractExternalTeamName(from: string): string | null {
|
||||||
const dotIdx = from.indexOf('.');
|
const dotIdx = from.indexOf('.');
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,34 @@
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { selectTeamDataForName } from '@renderer/store/slices/teamSlice';
|
import {
|
||||||
|
selectResolvedMembersForTeamName,
|
||||||
|
selectTeamDataForName,
|
||||||
|
selectTeamMessages,
|
||||||
|
} from '@renderer/store/slices/teamSlice';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import type { TeamData, TeamSummary } from '@shared/types/team';
|
import type { TeamGraphData } from '../adapters/TeamGraphAdapter';
|
||||||
|
import type { TeamSummary } from '@shared/types/team';
|
||||||
|
|
||||||
export function useGraphActivityContext(teamName: string): {
|
export function useGraphActivityContext(teamName: string): {
|
||||||
teamData: TeamData | null;
|
teamData: TeamGraphData | null;
|
||||||
teams: TeamSummary[];
|
teams: TeamSummary[];
|
||||||
} {
|
} {
|
||||||
return useStore(
|
return useStore(
|
||||||
useShallow((state) => ({
|
useShallow((state) => {
|
||||||
teamData: selectTeamDataForName(state, teamName),
|
const snapshot = selectTeamDataForName(state, teamName);
|
||||||
teams: state.teams,
|
const members = selectResolvedMembersForTeamName(state, teamName);
|
||||||
}))
|
const messages = selectTeamMessages(state, teamName);
|
||||||
|
|
||||||
|
return {
|
||||||
|
teamData: snapshot
|
||||||
|
? {
|
||||||
|
...snapshot,
|
||||||
|
members,
|
||||||
|
messageFeed: messages,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
teams: state.teams,
|
||||||
|
};
|
||||||
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,11 @@ import { useCallback, useMemo, useState } from 'react';
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
import { CreateTaskDialog } from '@renderer/components/team/dialogs/CreateTaskDialog';
|
import { CreateTaskDialog } from '@renderer/components/team/dialogs/CreateTaskDialog';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { isTeamProvisioningActive, selectTeamDataForName } from '@renderer/store/slices/teamSlice';
|
import {
|
||||||
|
isTeamProvisioningActive,
|
||||||
|
selectResolvedMembersForTeamName,
|
||||||
|
selectTeamDataForName,
|
||||||
|
} from '@renderer/store/slices/teamSlice';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import type { TaskRef } from '@shared/types';
|
import type { TaskRef } from '@shared/types';
|
||||||
|
|
@ -25,19 +29,17 @@ export function useGraphCreateTaskDialog(teamName: string): UseGraphCreateTaskDi
|
||||||
});
|
});
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
const { teamData, createTeamTask, isTeamProvisioning } = useStore(
|
const { teamData, activeMembers, createTeamTask, isTeamProvisioning } = useStore(
|
||||||
useShallow((state) => ({
|
useShallow((state) => ({
|
||||||
teamData: selectTeamDataForName(state, teamName),
|
teamData: selectTeamDataForName(state, teamName),
|
||||||
|
activeMembers: selectResolvedMembersForTeamName(state, teamName).filter(
|
||||||
|
(member) => !member.removedAt
|
||||||
|
),
|
||||||
createTeamTask: state.createTeamTask,
|
createTeamTask: state.createTeamTask,
|
||||||
isTeamProvisioning: isTeamProvisioningActive(state, teamName),
|
isTeamProvisioning: isTeamProvisioningActive(state, teamName),
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
const activeMembers = useMemo(
|
|
||||||
() => (teamData?.members ?? []).filter((member) => !member.removedAt),
|
|
||||||
[teamData?.members]
|
|
||||||
);
|
|
||||||
|
|
||||||
const openCreateTaskDialog = useCallback((owner = ''): void => {
|
const openCreateTaskDialog = useCallback((owner = ''): void => {
|
||||||
setDialogState({
|
setDialogState({
|
||||||
open: true,
|
open: true,
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,34 @@
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import {
|
import {
|
||||||
getCurrentProvisioningProgressForTeam,
|
getCurrentProvisioningProgressForTeam,
|
||||||
|
selectResolvedMembersForTeamName,
|
||||||
selectTeamDataForName,
|
selectTeamDataForName,
|
||||||
} from '@renderer/store/slices/teamSlice';
|
} from '@renderer/store/slices/teamSlice';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
|
import type { TeamGraphData } from '../adapters/TeamGraphAdapter';
|
||||||
|
|
||||||
export function useGraphMemberPopoverContext(teamName: string, memberName: string) {
|
export function useGraphMemberPopoverContext(teamName: string, memberName: string) {
|
||||||
return useStore(
|
return useStore(
|
||||||
useShallow((state) => ({
|
useShallow((state) => {
|
||||||
teamData: teamName ? selectTeamDataForName(state, teamName) : null,
|
const snapshot = teamName ? selectTeamDataForName(state, teamName) : null;
|
||||||
spawnEntry: teamName ? state.memberSpawnStatusesByTeam[teamName]?.[memberName] : undefined,
|
const teamMembers = teamName ? selectResolvedMembersForTeamName(state, teamName) : [];
|
||||||
leadActivity: teamName ? state.leadActivityByTeam[teamName] : undefined,
|
|
||||||
progress: teamName ? getCurrentProvisioningProgressForTeam(state, teamName) : null,
|
return {
|
||||||
memberSpawnSnapshot: teamName ? state.memberSpawnSnapshotsByTeam[teamName] : undefined,
|
teamData: snapshot
|
||||||
memberSpawnStatuses: teamName ? state.memberSpawnStatusesByTeam[teamName] : undefined,
|
? {
|
||||||
}))
|
...snapshot,
|
||||||
|
members: teamMembers,
|
||||||
|
messageFeed: [],
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
teamMembers,
|
||||||
|
spawnEntry: teamName ? state.memberSpawnStatusesByTeam[teamName]?.[memberName] : undefined,
|
||||||
|
leadActivity: teamName ? state.leadActivityByTeam[teamName] : undefined,
|
||||||
|
progress: teamName ? getCurrentProvisioningProgressForTeam(state, teamName) : null,
|
||||||
|
memberSpawnSnapshot: teamName ? state.memberSpawnSnapshotsByTeam[teamName] : undefined,
|
||||||
|
memberSpawnStatuses: teamName ? state.memberSpawnStatusesByTeam[teamName] : undefined,
|
||||||
|
};
|
||||||
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,20 +10,25 @@ import { useStore } from '@renderer/store';
|
||||||
import {
|
import {
|
||||||
getCurrentProvisioningProgressForTeam,
|
getCurrentProvisioningProgressForTeam,
|
||||||
isTeamGraphSlotPersistenceDisabled,
|
isTeamGraphSlotPersistenceDisabled,
|
||||||
|
selectResolvedMembersForTeamName,
|
||||||
selectTeamDataForName,
|
selectTeamDataForName,
|
||||||
|
selectTeamMessages,
|
||||||
} from '@renderer/store/slices/teamSlice';
|
} from '@renderer/store/slices/teamSlice';
|
||||||
import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout';
|
import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { TeamGraphAdapter } from '../adapters/TeamGraphAdapter';
|
import { TeamGraphAdapter } from '../adapters/TeamGraphAdapter';
|
||||||
|
|
||||||
|
import type { TeamGraphData } from '../adapters/TeamGraphAdapter';
|
||||||
import type { GraphDataPort } from '@claude-teams/agent-graph';
|
import type { GraphDataPort } from '@claude-teams/agent-graph';
|
||||||
|
|
||||||
export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
||||||
const adapterRef = useRef<TeamGraphAdapter>(TeamGraphAdapter.create());
|
const adapterRef = useRef<TeamGraphAdapter>(TeamGraphAdapter.create());
|
||||||
|
|
||||||
const {
|
const {
|
||||||
teamData,
|
teamSnapshot,
|
||||||
|
members,
|
||||||
|
messages,
|
||||||
spawnStatuses,
|
spawnStatuses,
|
||||||
leadActivity,
|
leadActivity,
|
||||||
leadContext,
|
leadContext,
|
||||||
|
|
@ -38,7 +43,9 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
||||||
ensureTeamGraphSlotAssignments,
|
ensureTeamGraphSlotAssignments,
|
||||||
} = useStore(
|
} = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
teamData: selectTeamDataForName(s, teamName),
|
teamSnapshot: selectTeamDataForName(s, teamName),
|
||||||
|
members: selectResolvedMembersForTeamName(s, teamName),
|
||||||
|
messages: selectTeamMessages(s, teamName),
|
||||||
spawnStatuses: teamName ? s.memberSpawnStatusesByTeam[teamName] : undefined,
|
spawnStatuses: teamName ? s.memberSpawnStatusesByTeam[teamName] : undefined,
|
||||||
leadActivity: teamName ? s.leadActivityByTeam[teamName] : undefined,
|
leadActivity: teamName ? s.leadActivityByTeam[teamName] : undefined,
|
||||||
leadContext: teamName ? s.leadContextByTeam[teamName] : undefined,
|
leadContext: teamName ? s.leadContextByTeam[teamName] : undefined,
|
||||||
|
|
@ -64,6 +71,17 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
||||||
return agents;
|
return agents;
|
||||||
}, [pendingApprovals, teamName]);
|
}, [pendingApprovals, teamName]);
|
||||||
|
|
||||||
|
const teamData = useMemo<TeamGraphData | null>(() => {
|
||||||
|
if (!teamSnapshot) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...teamSnapshot,
|
||||||
|
members,
|
||||||
|
messageFeed: messages,
|
||||||
|
};
|
||||||
|
}, [members, messages, teamSnapshot]);
|
||||||
|
|
||||||
const commentReadState = useSyncExternalStore(subscribe, getSnapshot);
|
const commentReadState = useSyncExternalStore(subscribe, getSnapshot);
|
||||||
|
|
||||||
const effectiveSlotAssignments = useMemo(() => {
|
const effectiveSlotAssignments = useMemo(() => {
|
||||||
|
|
@ -97,9 +115,7 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
||||||
const currentAssignment = slotAssignments[stableOwnerId];
|
const currentAssignment = slotAssignments[stableOwnerId];
|
||||||
const defaultAssignment = defaultSeed.assignments[stableOwnerId];
|
const defaultAssignment = defaultSeed.assignments[stableOwnerId];
|
||||||
return (
|
return (
|
||||||
currentAssignment &&
|
currentAssignment?.ringIndex === defaultAssignment?.ringIndex &&
|
||||||
defaultAssignment &&
|
|
||||||
currentAssignment.ringIndex === defaultAssignment.ringIndex &&
|
|
||||||
currentAssignment.sectorIndex === defaultAssignment.sectorIndex
|
currentAssignment.sectorIndex === defaultAssignment.sectorIndex
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { isTeamGraphSlotPersistenceDisabled } from '@renderer/store/slices/teamSlice';
|
||||||
|
|
||||||
import { parseGraphMemberNodeId } from '../../core/domain/graphOwnerIdentity';
|
import { parseGraphMemberNodeId } from '../../core/domain/graphOwnerIdentity';
|
||||||
|
|
||||||
|
|
@ -8,6 +9,7 @@ import type { GraphOwnerSlotAssignment } from '@claude-teams/agent-graph';
|
||||||
|
|
||||||
export function useTeamGraphSurfaceActions(teamName: string): {
|
export function useTeamGraphSurfaceActions(teamName: string): {
|
||||||
openTeamPage: () => void;
|
openTeamPage: () => void;
|
||||||
|
resetOwnerSlotAssignmentsToDefaults: () => void;
|
||||||
commitOwnerSlotDrop: (payload: {
|
commitOwnerSlotDrop: (payload: {
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
assignment: GraphOwnerSlotAssignment;
|
assignment: GraphOwnerSlotAssignment;
|
||||||
|
|
@ -19,6 +21,13 @@ export function useTeamGraphSurfaceActions(teamName: string): {
|
||||||
useStore.getState().openTeamTab(teamName);
|
useStore.getState().openTeamTab(teamName);
|
||||||
}, [teamName]);
|
}, [teamName]);
|
||||||
|
|
||||||
|
const resetOwnerSlotAssignmentsToDefaults = useCallback(() => {
|
||||||
|
if (!isTeamGraphSlotPersistenceDisabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
useStore.getState().resetTeamGraphSlotAssignmentsToDefaults(teamName);
|
||||||
|
}, [teamName]);
|
||||||
|
|
||||||
const commitOwnerSlotDrop = useCallback(
|
const commitOwnerSlotDrop = useCallback(
|
||||||
(payload: {
|
(payload: {
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
|
|
@ -51,6 +60,7 @@ export function useTeamGraphSurfaceActions(teamName: string): {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
openTeamPage,
|
openTeamPage,
|
||||||
|
resetOwnerSlotAssignmentsToDefaults,
|
||||||
commitOwnerSlotDrop,
|
commitOwnerSlotDrop,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
* into ui/, hooks/, or core/ directly.
|
* into ui/, hooks/, or core/ directly.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export type { InlineActivityEntry } from '../core/domain/buildInlineActivityEntries';
|
||||||
export { buildInlineActivityEntries } from '../core/domain/buildInlineActivityEntries';
|
export { buildInlineActivityEntries } from '../core/domain/buildInlineActivityEntries';
|
||||||
export { buildGraphMemberNodeIdForMember } from '../core/domain/graphOwnerIdentity';
|
export { buildGraphMemberNodeIdForMember } from '../core/domain/graphOwnerIdentity';
|
||||||
export { TeamGraphAdapter } from './adapters/TeamGraphAdapter';
|
export { TeamGraphAdapter } from './adapters/TeamGraphAdapter';
|
||||||
|
|
|
||||||
96
src/features/agent-graph/renderer/ui/GraphActivityCard.tsx
Normal file
96
src/features/agent-graph/renderer/ui/GraphActivityCard.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
import { ActivityItem } from '@renderer/components/team/activity/ActivityItem';
|
||||||
|
import {
|
||||||
|
type MessageContext,
|
||||||
|
resolveMessageRenderProps,
|
||||||
|
} from '@renderer/components/team/activity/activityMessageContext';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
MemberActivityFilter,
|
||||||
|
MemberDetailTab,
|
||||||
|
} from '@renderer/components/team/members/memberDetailTypes';
|
||||||
|
import type { InboxMessage } from '@shared/types';
|
||||||
|
|
||||||
|
interface GraphActivityCardProps {
|
||||||
|
message: InboxMessage;
|
||||||
|
teamName: string;
|
||||||
|
messageContext: MessageContext;
|
||||||
|
teamNames: string[];
|
||||||
|
teamColorByName: ReadonlyMap<string, string>;
|
||||||
|
isUnread?: boolean;
|
||||||
|
zebraShade?: boolean;
|
||||||
|
className?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
onOpenTaskDetail?: (taskId: string) => void;
|
||||||
|
onOpenMemberProfile?: (
|
||||||
|
memberName: string,
|
||||||
|
options?: {
|
||||||
|
initialTab?: MemberDetailTab;
|
||||||
|
initialActivityFilter?: MemberActivityFilter;
|
||||||
|
}
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GraphActivityCard = ({
|
||||||
|
message,
|
||||||
|
teamName,
|
||||||
|
messageContext,
|
||||||
|
teamNames,
|
||||||
|
teamColorByName,
|
||||||
|
isUnread = false,
|
||||||
|
zebraShade = false,
|
||||||
|
className,
|
||||||
|
onClick,
|
||||||
|
onOpenTaskDetail,
|
||||||
|
onOpenMemberProfile,
|
||||||
|
}: GraphActivityCardProps): React.JSX.Element => {
|
||||||
|
const renderProps = resolveMessageRenderProps(message, messageContext);
|
||||||
|
const interactive = Boolean(onClick);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
'h-[72px] min-h-[72px] min-w-0 max-w-full overflow-hidden',
|
||||||
|
interactive ? 'cursor-pointer' : '',
|
||||||
|
className ?? '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
role={interactive ? 'button' : undefined}
|
||||||
|
tabIndex={interactive ? 0 : undefined}
|
||||||
|
onClick={onClick}
|
||||||
|
onKeyDown={
|
||||||
|
interactive
|
||||||
|
? (event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
onClick?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onDragStart={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ActivityItem
|
||||||
|
message={message}
|
||||||
|
teamName={teamName}
|
||||||
|
compactHeader
|
||||||
|
collapseMode="managed"
|
||||||
|
isCollapsed
|
||||||
|
canToggleCollapse={false}
|
||||||
|
isUnread={isUnread}
|
||||||
|
memberRole={renderProps.memberRole}
|
||||||
|
memberColor={renderProps.memberColor}
|
||||||
|
recipientColor={renderProps.recipientColor}
|
||||||
|
memberColorMap={messageContext.colorMap}
|
||||||
|
localMemberNames={messageContext.localMemberNames}
|
||||||
|
onMemberNameClick={(memberName) => onOpenMemberProfile?.(memberName)}
|
||||||
|
onTaskIdClick={onOpenTaskDetail}
|
||||||
|
zebraShade={zebraShade}
|
||||||
|
teamNames={teamNames}
|
||||||
|
teamColorByName={teamColorByName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,11 +1,7 @@
|
||||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { 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 } from '@renderer/components/team/activity/activityMessageContext';
|
||||||
import {
|
|
||||||
buildMessageContext,
|
|
||||||
resolveMessageRenderProps,
|
|
||||||
} from '@renderer/components/team/activity/activityMessageContext';
|
|
||||||
import { MessageExpandDialog } from '@renderer/components/team/activity/MessageExpandDialog';
|
import { MessageExpandDialog } from '@renderer/components/team/activity/MessageExpandDialog';
|
||||||
import { useStableTeamMentionMeta } from '@renderer/hooks/useStableTeamMentionMeta';
|
import { useStableTeamMentionMeta } from '@renderer/hooks/useStableTeamMentionMeta';
|
||||||
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
|
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
|
||||||
|
|
@ -18,6 +14,8 @@ import {
|
||||||
} from '../../core/domain/buildInlineActivityEntries';
|
} from '../../core/domain/buildInlineActivityEntries';
|
||||||
import { useGraphActivityContext } from '../hooks/useGraphActivityContext';
|
import { useGraphActivityContext } from '../hooks/useGraphActivityContext';
|
||||||
|
|
||||||
|
import { GraphActivityCard } from './GraphActivityCard';
|
||||||
|
|
||||||
import type { GraphNode } from '@claude-teams/agent-graph';
|
import type { GraphNode } from '@claude-teams/agent-graph';
|
||||||
import type { TimelineItem } from '@renderer/components/team/activity/LeadThoughtsGroup';
|
import type { TimelineItem } from '@renderer/components/team/activity/LeadThoughtsGroup';
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -77,6 +75,9 @@ export const GraphActivityHud = ({
|
||||||
const connectorPathRefs = useRef(new Map<string, SVGPathElement | null>());
|
const connectorPathRefs = useRef(new Map<string, SVGPathElement | null>());
|
||||||
const [expandedItem, setExpandedItem] = useState<TimelineItem | null>(null);
|
const [expandedItem, setExpandedItem] = useState<TimelineItem | null>(null);
|
||||||
const { teamData, teams } = useGraphActivityContext(teamName);
|
const { teamData, teams } = useGraphActivityContext(teamName);
|
||||||
|
const teamSnapshot = teamData;
|
||||||
|
const members = teamData?.members ?? [];
|
||||||
|
const messages = teamData?.messageFeed ?? [];
|
||||||
|
|
||||||
const ownerNodes = useMemo(
|
const ownerNodes = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -87,21 +88,27 @@ export const GraphActivityHud = ({
|
||||||
[nodes]
|
[nodes]
|
||||||
);
|
);
|
||||||
const leadNodeId = ownerNodes.find((node) => node.kind === 'lead')?.id ?? `lead:${teamName}`;
|
const leadNodeId = ownerNodes.find((node) => node.kind === 'lead')?.id ?? `lead:${teamName}`;
|
||||||
const leadName = teamData ? getGraphLeadMemberName(teamData, teamName) : `${teamName}-lead`;
|
const leadName = teamSnapshot
|
||||||
|
? getGraphLeadMemberName({ members }, teamName)
|
||||||
|
: `${teamName}-lead`;
|
||||||
const ownerNodeIds = useMemo(() => new Set(ownerNodes.map((node) => node.id)), [ownerNodes]);
|
const ownerNodeIds = useMemo(() => new Set(ownerNodes.map((node) => node.id)), [ownerNodes]);
|
||||||
const entryMapByOwnerNodeId = useMemo(() => {
|
const entryMapByOwnerNodeId = useMemo(() => {
|
||||||
if (!teamData) {
|
if (!teamSnapshot) {
|
||||||
return new Map<string, InlineActivityEntry[]>();
|
return new Map<string, InlineActivityEntry[]>();
|
||||||
}
|
}
|
||||||
return buildInlineActivityEntries({
|
return buildInlineActivityEntries({
|
||||||
data: teamData,
|
data: {
|
||||||
|
members,
|
||||||
|
tasks: teamSnapshot.tasks,
|
||||||
|
messages,
|
||||||
|
},
|
||||||
teamName,
|
teamName,
|
||||||
leadId: leadNodeId,
|
leadId: leadNodeId,
|
||||||
leadName,
|
leadName,
|
||||||
ownerNodeIds,
|
ownerNodeIds,
|
||||||
});
|
});
|
||||||
}, [leadName, leadNodeId, ownerNodeIds, teamData, teamName]);
|
}, [leadName, leadNodeId, members, messages, ownerNodeIds, teamName, teamSnapshot]);
|
||||||
const messageContext = useMemo(() => buildMessageContext(teamData?.members), [teamData?.members]);
|
const messageContext = useMemo(() => buildMessageContext(members), [members]);
|
||||||
const { teamNames, teamColorByName } = useStableTeamMentionMeta(teams);
|
const { teamNames, teamColorByName } = useStableTeamMentionMeta(teams);
|
||||||
const { readSet } = useTeamMessagesRead(teamName);
|
const { readSet } = useTeamMessagesRead(teamName);
|
||||||
|
|
||||||
|
|
@ -279,38 +286,10 @@ export const GraphActivityHud = ({
|
||||||
visibleLanes,
|
visibleLanes,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const expandedItemsByKey = useMemo(() => {
|
|
||||||
const items = new Map<string, TimelineItem>();
|
|
||||||
for (const lane of visibleLanes) {
|
|
||||||
for (const entry of lane.entries) {
|
|
||||||
const key = toMessageKey(entry.message);
|
|
||||||
items.set(key, { type: 'message', message: entry.message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return items;
|
|
||||||
}, [visibleLanes]);
|
|
||||||
|
|
||||||
const handleExpandItem = useCallback(
|
|
||||||
(key: string) => {
|
|
||||||
const next = expandedItemsByKey.get(key);
|
|
||||||
if (next) {
|
|
||||||
setExpandedItem(next);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[expandedItemsByKey]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleMessageClick = useCallback((item: TimelineItem) => {
|
const handleMessageClick = useCallback((item: TimelineItem) => {
|
||||||
setExpandedItem(item);
|
setExpandedItem(item);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleMemberNameClick = useCallback(
|
|
||||||
(memberName: string) => {
|
|
||||||
onOpenMemberProfile?.(memberName);
|
|
||||||
},
|
|
||||||
[onOpenMemberProfile]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleMemberClick = useCallback(
|
const handleMemberClick = useCallback(
|
||||||
(member: ResolvedTeamMember) => {
|
(member: ResolvedTeamMember) => {
|
||||||
onOpenMemberProfile?.(member.name);
|
onOpenMemberProfile?.(member.name);
|
||||||
|
|
@ -381,7 +360,7 @@ export const GraphActivityHud = ({
|
||||||
};
|
};
|
||||||
}, [enabled, forwardWheelToGraph, visibleLanes]);
|
}, [enabled, forwardWheelToGraph, visibleLanes]);
|
||||||
|
|
||||||
if (!enabled || !teamData || visibleLanes.length === 0) {
|
if (!enabled || !teamSnapshot || visibleLanes.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -444,10 +423,6 @@ export const GraphActivityHud = ({
|
||||||
) : null}
|
) : null}
|
||||||
{lane.entries.map((entry, index) => {
|
{lane.entries.map((entry, index) => {
|
||||||
const messageKey = toMessageKey(entry.message);
|
const messageKey = toMessageKey(entry.message);
|
||||||
const renderProps = resolveMessageRenderProps(
|
|
||||||
entry.message,
|
|
||||||
messageContext
|
|
||||||
);
|
|
||||||
const timelineItem: TimelineItem = {
|
const timelineItem: TimelineItem = {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
message: entry.message,
|
message: entry.message,
|
||||||
|
|
@ -468,26 +443,17 @@ export const GraphActivityHud = ({
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ActivityItem
|
<GraphActivityCard
|
||||||
message={entry.message}
|
message={entry.message}
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
compactHeader
|
messageContext={messageContext}
|
||||||
collapseMode="managed"
|
|
||||||
isCollapsed
|
|
||||||
canToggleCollapse={false}
|
|
||||||
isUnread={isUnread}
|
|
||||||
expandItemKey={messageKey}
|
|
||||||
onExpand={handleExpandItem}
|
|
||||||
memberRole={renderProps.memberRole}
|
|
||||||
memberColor={renderProps.memberColor}
|
|
||||||
recipientColor={renderProps.recipientColor}
|
|
||||||
memberColorMap={messageContext.colorMap}
|
|
||||||
localMemberNames={messageContext.localMemberNames}
|
|
||||||
onMemberNameClick={handleMemberNameClick}
|
|
||||||
onTaskIdClick={onOpenTaskDetail}
|
|
||||||
zebraShade={index % 2 === 1}
|
|
||||||
teamNames={teamNames}
|
teamNames={teamNames}
|
||||||
teamColorByName={teamColorByName}
|
teamColorByName={teamColorByName}
|
||||||
|
isUnread={isUnread}
|
||||||
|
zebraShade={index % 2 === 1}
|
||||||
|
onClick={() => handleMessageClick(timelineItem)}
|
||||||
|
onOpenTaskDetail={onOpenTaskDetail}
|
||||||
|
onOpenMemberProfile={onOpenMemberProfile}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -521,7 +487,7 @@ export const GraphActivityHud = ({
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
members={teamData.members}
|
members={members}
|
||||||
onMemberClick={handleMemberClick}
|
onMemberClick={handleMemberClick}
|
||||||
onTaskIdClick={onOpenTaskDetail}
|
onTaskIdClick={onOpenTaskDetail}
|
||||||
teamNames={teamNames}
|
teamNames={teamNames}
|
||||||
|
|
|
||||||
|
|
@ -292,14 +292,21 @@ const MemberPopoverContent = ({
|
||||||
? node.domainRef.teamName
|
? node.domainRef.teamName
|
||||||
: '';
|
: '';
|
||||||
const avatarSrc = node.avatarUrl ?? agentAvatarUrl(memberName, 64);
|
const avatarSrc = node.avatarUrl ?? agentAvatarUrl(memberName, 64);
|
||||||
const { teamData, spawnEntry, leadActivity, progress, memberSpawnSnapshot, memberSpawnStatuses } =
|
const {
|
||||||
useGraphMemberPopoverContext(teamName, memberName);
|
teamData,
|
||||||
const member = teamData?.members.find((candidate) => candidate.name === memberName) ?? null;
|
teamMembers,
|
||||||
|
spawnEntry,
|
||||||
|
leadActivity,
|
||||||
|
progress,
|
||||||
|
memberSpawnSnapshot,
|
||||||
|
memberSpawnStatuses,
|
||||||
|
} = useGraphMemberPopoverContext(teamName, memberName);
|
||||||
|
const member = teamMembers.find((candidate) => candidate.name === memberName) ?? null;
|
||||||
const provisioningPresentation =
|
const provisioningPresentation =
|
||||||
teamData && teamName
|
teamData && teamName
|
||||||
? buildTeamProvisioningPresentation({
|
? buildTeamProvisioningPresentation({
|
||||||
progress,
|
progress,
|
||||||
members: teamData.members,
|
members: teamMembers,
|
||||||
memberSpawnStatuses,
|
memberSpawnStatuses,
|
||||||
memberSpawnSnapshot,
|
memberSpawnSnapshot,
|
||||||
})
|
})
|
||||||
|
|
@ -425,7 +432,7 @@ const MemberPopoverContent = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Context usage stays hidden for now because LeadContextUsage.percent is unreliable. */}
|
{/* Context usage stays hidden for now because lead context telemetry is still incomplete. */}
|
||||||
|
|
||||||
{/* Current task indicator — reuses same pattern as MemberCard */}
|
{/* Current task indicator — reuses same pattern as MemberCard */}
|
||||||
{node.currentTaskId && node.currentTaskSubject && (
|
{node.currentTaskId && node.currentTaskSubject && (
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,176 @@
|
||||||
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ACTIVITY_LANE,
|
||||||
|
getTransientHandoffCardAlpha,
|
||||||
|
type TransientHandoffCard,
|
||||||
|
} from '@claude-teams/agent-graph';
|
||||||
|
import { buildMessageContext } from '@renderer/components/team/activity/activityMessageContext';
|
||||||
|
import { useStableTeamMentionMeta } from '@renderer/hooks/useStableTeamMentionMeta';
|
||||||
|
|
||||||
|
import { useGraphActivityContext } from '../hooks/useGraphActivityContext';
|
||||||
|
|
||||||
|
import { buildTransientHandoffMessage } from './buildTransientHandoffMessage';
|
||||||
|
import { GraphActivityCard } from './GraphActivityCard';
|
||||||
|
|
||||||
|
interface GraphTransientHandoffHudProps {
|
||||||
|
teamName: string;
|
||||||
|
getTransientHandoffSnapshot?: (options?: {
|
||||||
|
focusNodeIds?: ReadonlySet<string> | null;
|
||||||
|
focusEdgeIds?: ReadonlySet<string> | null;
|
||||||
|
}) => { cards: TransientHandoffCard[]; time: number };
|
||||||
|
getCameraZoom?: () => number;
|
||||||
|
worldToScreen?: (x: number, y: number) => { x: number; y: number };
|
||||||
|
getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null;
|
||||||
|
focusNodeIds: ReadonlySet<string> | null;
|
||||||
|
focusEdgeIds: ReadonlySet<string> | null;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CARD_WIDTH = ACTIVITY_LANE.width;
|
||||||
|
const CARD_HEIGHT = 72;
|
||||||
|
const STACK_GAP = 10;
|
||||||
|
|
||||||
|
export const GraphTransientHandoffHud = ({
|
||||||
|
teamName,
|
||||||
|
getTransientHandoffSnapshot = () => ({ cards: [], time: 0 }),
|
||||||
|
getCameraZoom = () => 1,
|
||||||
|
worldToScreen,
|
||||||
|
getNodeWorldPosition = () => null,
|
||||||
|
focusNodeIds,
|
||||||
|
focusEdgeIds,
|
||||||
|
enabled = true,
|
||||||
|
}: GraphTransientHandoffHudProps): React.JSX.Element | null => {
|
||||||
|
const worldLayerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const shellRefs = useRef(new Map<string, HTMLDivElement | null>());
|
||||||
|
const signatureRef = useRef('');
|
||||||
|
const [cards, setCards] = useState<TransientHandoffCard[]>([]);
|
||||||
|
const { teamData, teams } = useGraphActivityContext(teamName);
|
||||||
|
const messageContext = useMemo(() => buildMessageContext(teamData?.members), [teamData?.members]);
|
||||||
|
const { teamNames, teamColorByName } = useStableTeamMentionMeta(teams);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
signatureRef.current = '';
|
||||||
|
setCards([]);
|
||||||
|
}, [teamName]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!enabled) {
|
||||||
|
setCards([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let frameId = 0;
|
||||||
|
const tick = (): void => {
|
||||||
|
const snapshot = getTransientHandoffSnapshot({
|
||||||
|
focusNodeIds,
|
||||||
|
focusEdgeIds,
|
||||||
|
});
|
||||||
|
const nextCards = snapshot.cards.filter(
|
||||||
|
(card) => card.anchorKind === 'lead' || card.anchorKind === 'member'
|
||||||
|
);
|
||||||
|
const nextSignature = nextCards
|
||||||
|
.map((card) => `${card.key}:${card.count}:${card.updatedAt}:${card.anchorNodeId}`)
|
||||||
|
.join('|');
|
||||||
|
if (nextSignature !== signatureRef.current) {
|
||||||
|
signatureRef.current = nextSignature;
|
||||||
|
setCards(nextCards);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 stackIndexByAnchor = new Map<string, number>();
|
||||||
|
for (const card of nextCards) {
|
||||||
|
const shell = shellRefs.current.get(card.key);
|
||||||
|
if (!shell) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeWorld = getNodeWorldPosition(card.anchorNodeId);
|
||||||
|
const alpha = getTransientHandoffCardAlpha(card, snapshot.time);
|
||||||
|
if (!nodeWorld || !worldToScreen || alpha <= 0.001) {
|
||||||
|
shell.style.opacity = '0';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stackIndex = stackIndexByAnchor.get(card.anchorNodeId) ?? 0;
|
||||||
|
stackIndexByAnchor.set(card.anchorNodeId, stackIndex + 1);
|
||||||
|
const lift = stackIndex * (CARD_HEIGHT * 0.34 + STACK_GAP);
|
||||||
|
const scale = 0.94 + alpha * 0.06;
|
||||||
|
|
||||||
|
shell.style.left = `${Math.round(nodeWorld.x)}px`;
|
||||||
|
shell.style.top = `${Math.round(nodeWorld.y)}px`;
|
||||||
|
shell.style.opacity = String(alpha);
|
||||||
|
shell.style.transform = `translate(-50%, calc(-50% - ${lift.toFixed(1)}px)) scale(${scale.toFixed(3)})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
frameId = window.requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
|
||||||
|
tick();
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(frameId);
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
enabled,
|
||||||
|
focusEdgeIds,
|
||||||
|
focusNodeIds,
|
||||||
|
getCameraZoom,
|
||||||
|
getNodeWorldPosition,
|
||||||
|
getTransientHandoffSnapshot,
|
||||||
|
worldToScreen,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handoffMessages = useMemo(
|
||||||
|
() =>
|
||||||
|
cards.map((card, index) => ({
|
||||||
|
card,
|
||||||
|
message: buildTransientHandoffMessage(teamName, card),
|
||||||
|
zebraShade: index % 2 === 1,
|
||||||
|
})),
|
||||||
|
[cards, teamName]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!enabled || !teamData || cards.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={worldLayerRef}
|
||||||
|
className="pointer-events-none absolute left-0 top-0 z-[9] origin-top-left select-none"
|
||||||
|
>
|
||||||
|
{handoffMessages.map(({ card, message, zebraShade }) => (
|
||||||
|
<div
|
||||||
|
key={card.key}
|
||||||
|
ref={(element) => {
|
||||||
|
shellRefs.current.set(card.key, element);
|
||||||
|
}}
|
||||||
|
className="pointer-events-none absolute z-[9] origin-center opacity-0 transition-opacity duration-150 ease-out"
|
||||||
|
style={{
|
||||||
|
width: `${CARD_WIDTH}px`,
|
||||||
|
maxWidth: `${CARD_WIDTH}px`,
|
||||||
|
}}
|
||||||
|
onDragStart={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<GraphActivityCard
|
||||||
|
message={message}
|
||||||
|
teamName={teamName}
|
||||||
|
messageContext={messageContext}
|
||||||
|
teamNames={teamNames}
|
||||||
|
teamColorByName={teamColorByName}
|
||||||
|
zebraShade={zebraShade}
|
||||||
|
className="pointer-events-none drop-shadow-[0_0_22px_rgba(94,234,212,0.12)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -18,6 +18,7 @@ import { GraphActivityHud } from './GraphActivityHud';
|
||||||
import { GraphBlockingEdgePopover } from './GraphBlockingEdgePopover';
|
import { GraphBlockingEdgePopover } from './GraphBlockingEdgePopover';
|
||||||
import { GraphNodePopover } from './GraphNodePopover';
|
import { GraphNodePopover } from './GraphNodePopover';
|
||||||
import { GraphProvisioningHud } from './GraphProvisioningHud';
|
import { GraphProvisioningHud } from './GraphProvisioningHud';
|
||||||
|
import { GraphTransientHandoffHud } from './GraphTransientHandoffHud';
|
||||||
|
|
||||||
import type { GraphDomainRef, GraphEventPort } from '@claude-teams/agent-graph';
|
import type { GraphDomainRef, GraphEventPort } from '@claude-teams/agent-graph';
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -88,7 +89,6 @@ export const TeamGraphOverlay = ({
|
||||||
const openCreateTask = useCallback(() => {
|
const openCreateTask = useCallback(() => {
|
||||||
openCreateTaskDialog('');
|
openCreateTaskDialog('');
|
||||||
}, [openCreateTaskDialog]);
|
}, [openCreateTaskDialog]);
|
||||||
|
|
||||||
const events: GraphEventPort = {
|
const events: GraphEventPort = {
|
||||||
onNodeDoubleClick: useCallback(
|
onNodeDoubleClick: useCallback(
|
||||||
(ref: GraphDomainRef) => {
|
(ref: GraphDomainRef) => {
|
||||||
|
|
@ -141,13 +141,30 @@ export const TeamGraphOverlay = ({
|
||||||
height: number;
|
height: number;
|
||||||
} | null;
|
} | null;
|
||||||
getCameraZoom?: () => number;
|
getCameraZoom?: () => number;
|
||||||
|
getTransientHandoffSnapshot?: (options?: {
|
||||||
|
focusNodeIds?: ReadonlySet<string> | null;
|
||||||
|
focusEdgeIds?: ReadonlySet<string> | null;
|
||||||
|
}) => {
|
||||||
|
cards: import('@claude-teams/agent-graph').TransientHandoffCard[];
|
||||||
|
time: number;
|
||||||
|
};
|
||||||
worldToScreen?: (x: number, y: number) => { x: number; y: number };
|
worldToScreen?: (x: number, y: number) => { x: number; y: number };
|
||||||
getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null;
|
getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null;
|
||||||
|
focusEdgeIds?: ReadonlySet<string> | null;
|
||||||
};
|
};
|
||||||
const { getViewportSize, focusNodeIds } = extraHudProps;
|
const { getViewportSize, focusNodeIds } = extraHudProps;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<GraphTransientHandoffHud
|
||||||
|
teamName={teamName}
|
||||||
|
getTransientHandoffSnapshot={extraHudProps.getTransientHandoffSnapshot}
|
||||||
|
getCameraZoom={extraHudProps.getCameraZoom}
|
||||||
|
worldToScreen={extraHudProps.worldToScreen}
|
||||||
|
getNodeWorldPosition={extraHudProps.getNodeWorldPosition}
|
||||||
|
focusNodeIds={focusNodeIds}
|
||||||
|
focusEdgeIds={extraHudProps.focusEdgeIds ?? null}
|
||||||
|
/>
|
||||||
<GraphActivityHud
|
<GraphActivityHud
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
nodes={graphData.nodes}
|
nodes={graphData.nodes}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import { GraphActivityHud } from './GraphActivityHud';
|
||||||
import { GraphBlockingEdgePopover } from './GraphBlockingEdgePopover';
|
import { GraphBlockingEdgePopover } from './GraphBlockingEdgePopover';
|
||||||
import { GraphNodePopover } from './GraphNodePopover';
|
import { GraphNodePopover } from './GraphNodePopover';
|
||||||
import { GraphProvisioningHud } from './GraphProvisioningHud';
|
import { GraphProvisioningHud } from './GraphProvisioningHud';
|
||||||
|
import { GraphTransientHandoffHud } from './GraphTransientHandoffHud';
|
||||||
|
|
||||||
import type { GraphDomainRef, GraphEventPort } from '@claude-teams/agent-graph';
|
import type { GraphDomainRef, GraphEventPort } from '@claude-teams/agent-graph';
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -78,7 +79,6 @@ export const TeamGraphTab = ({
|
||||||
const openCreateTask = useCallback(() => {
|
const openCreateTask = useCallback(() => {
|
||||||
openCreateTaskDialog('');
|
openCreateTaskDialog('');
|
||||||
}, [openCreateTaskDialog]);
|
}, [openCreateTaskDialog]);
|
||||||
|
|
||||||
// Task action dispatchers
|
// Task action dispatchers
|
||||||
const dispatchTaskAction = useCallback(
|
const dispatchTaskAction = useCallback(
|
||||||
(action: string) => (taskId: string) =>
|
(action: string) => (taskId: string) =>
|
||||||
|
|
@ -165,13 +165,31 @@ export const TeamGraphTab = ({
|
||||||
height: number;
|
height: number;
|
||||||
} | null;
|
} | null;
|
||||||
getCameraZoom?: () => number;
|
getCameraZoom?: () => number;
|
||||||
|
getTransientHandoffSnapshot?: (options?: {
|
||||||
|
focusNodeIds?: ReadonlySet<string> | null;
|
||||||
|
focusEdgeIds?: ReadonlySet<string> | null;
|
||||||
|
}) => {
|
||||||
|
cards: import('@claude-teams/agent-graph').TransientHandoffCard[];
|
||||||
|
time: number;
|
||||||
|
};
|
||||||
worldToScreen?: (x: number, y: number) => { x: number; y: number };
|
worldToScreen?: (x: number, y: number) => { x: number; y: number };
|
||||||
getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null;
|
getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null;
|
||||||
|
focusEdgeIds?: ReadonlySet<string> | null;
|
||||||
};
|
};
|
||||||
const { getViewportSize, focusNodeIds } = extraHudProps;
|
const { getViewportSize, focusNodeIds } = extraHudProps;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<GraphTransientHandoffHud
|
||||||
|
teamName={teamName}
|
||||||
|
getTransientHandoffSnapshot={extraHudProps.getTransientHandoffSnapshot}
|
||||||
|
getCameraZoom={extraHudProps.getCameraZoom}
|
||||||
|
worldToScreen={extraHudProps.worldToScreen}
|
||||||
|
getNodeWorldPosition={extraHudProps.getNodeWorldPosition}
|
||||||
|
focusNodeIds={focusNodeIds}
|
||||||
|
focusEdgeIds={extraHudProps.focusEdgeIds ?? null}
|
||||||
|
enabled={isActive}
|
||||||
|
/>
|
||||||
<GraphActivityHud
|
<GraphActivityHud
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
nodes={graphData.nodes}
|
nodes={graphData.nodes}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
import type { TransientHandoffCard } from '@claude-teams/agent-graph';
|
||||||
|
import type { InboxMessage, TaskRef } from '@shared/types';
|
||||||
|
|
||||||
|
function buildTaskRefs(teamName: string, card: TransientHandoffCard): TaskRef[] | undefined {
|
||||||
|
if (!card.relatedTaskId) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
taskId: card.relatedTaskId,
|
||||||
|
displayId: card.relatedTaskDisplayId ?? card.relatedTaskId.slice(0, 8),
|
||||||
|
teamName,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSummary(card: TransientHandoffCard): string {
|
||||||
|
const preview = card.preview?.trim();
|
||||||
|
if (preview) {
|
||||||
|
return preview;
|
||||||
|
}
|
||||||
|
if (card.kind === 'task_assign' && card.relatedTaskDisplayId) {
|
||||||
|
return `Task ${card.relatedTaskDisplayId} assigned`;
|
||||||
|
}
|
||||||
|
if (card.kind === 'task_comment' && card.relatedTaskDisplayId) {
|
||||||
|
return `${card.relatedTaskDisplayId} updated`;
|
||||||
|
}
|
||||||
|
return `${card.sourceLabel} -> ${card.destinationLabel}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildText(card: TransientHandoffCard): string {
|
||||||
|
const preview = card.preview?.trim();
|
||||||
|
switch (card.kind) {
|
||||||
|
case 'task_assign': {
|
||||||
|
const taskLabel = card.relatedTaskDisplayId ?? card.relatedTaskId ?? 'task';
|
||||||
|
return `New task assigned to you: ${taskLabel}${preview ? ` - ${preview}` : ''}`;
|
||||||
|
}
|
||||||
|
case 'task_comment':
|
||||||
|
return preview ?? `${card.sourceLabel} added a comment`;
|
||||||
|
case 'review_request':
|
||||||
|
return preview ?? `Review requested by ${card.sourceLabel}`;
|
||||||
|
case 'review_response':
|
||||||
|
return preview ?? `Review response from ${card.sourceLabel}`;
|
||||||
|
case 'inbox_message':
|
||||||
|
default:
|
||||||
|
return preview ?? `${card.sourceLabel} -> ${card.destinationLabel}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTransientHandoffMessage(
|
||||||
|
teamName: string,
|
||||||
|
card: TransientHandoffCard
|
||||||
|
): InboxMessage {
|
||||||
|
const messageKind = card.kind === 'task_comment' ? 'task_comment_notification' : 'default';
|
||||||
|
const taskRefs = buildTaskRefs(teamName, card);
|
||||||
|
|
||||||
|
return {
|
||||||
|
from: card.sourceLabel,
|
||||||
|
to: card.destinationLabel,
|
||||||
|
text: buildText(card),
|
||||||
|
timestamp: new Date(card.updatedAt * 1000).toISOString(),
|
||||||
|
read: true,
|
||||||
|
summary: buildSummary(card),
|
||||||
|
color: card.color,
|
||||||
|
messageId: `graph-handoff:${card.key}`,
|
||||||
|
source: 'inbox',
|
||||||
|
messageKind,
|
||||||
|
taskRefs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -199,7 +199,7 @@ export class CodexAppServerClient {
|
||||||
{
|
{
|
||||||
clientInfo: {
|
clientInfo: {
|
||||||
name: 'claude-agent-teams-ui',
|
name: 'claude-agent-teams-ui',
|
||||||
title: 'Claude Agent Teams UI',
|
title: 'Agent Teams UI',
|
||||||
version: '0.1.0',
|
version: '0.1.0',
|
||||||
},
|
},
|
||||||
capabilities: {
|
capabilities: {
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { type DashboardRecentProject } from '@features/recent-projects/contracts';
|
import { type DashboardRecentProject } from '@features/recent-projects/contracts';
|
||||||
import { api, isElectronMode } from '@renderer/api';
|
import { api, isElectronMode } from '@renderer/api';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { buildTaskCountsByProject, normalizePath } from '@renderer/utils/pathNormalize';
|
import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice';
|
||||||
|
import { buildTaskCountsByProject } from '@renderer/utils/pathNormalize';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { adaptRecentProjectsSection } from '../adapters/RecentProjectsSectionAdapter';
|
import { adaptRecentProjectsSection } from '../adapters/RecentProjectsSectionAdapter';
|
||||||
|
import { buildActiveTeamsByProject } from '../utils/activeProjectTeams';
|
||||||
import {
|
import {
|
||||||
sortRecentProjectsByDisplayPriority,
|
sortRecentProjectsByDisplayPriority,
|
||||||
subscribeRecentProjectOpenHistory,
|
subscribeRecentProjectOpenHistory,
|
||||||
|
|
@ -62,16 +64,27 @@ export function useRecentProjectsSection(
|
||||||
openProjectPath: (projectPath: string) => Promise<void>;
|
openProjectPath: (projectPath: string) => Promise<void>;
|
||||||
selectProjectFolder: () => Promise<void>;
|
selectProjectFolder: () => Promise<void>;
|
||||||
} {
|
} {
|
||||||
const { globalTasks, globalTasksInitialized, globalTasksLoading, fetchAllTasks, teams } =
|
const {
|
||||||
useStore(
|
globalTasks,
|
||||||
useShallow((state) => ({
|
globalTasksInitialized,
|
||||||
globalTasks: state.globalTasks,
|
globalTasksLoading,
|
||||||
globalTasksInitialized: state.globalTasksInitialized,
|
fetchAllTasks,
|
||||||
globalTasksLoading: state.globalTasksLoading,
|
teams,
|
||||||
fetchAllTasks: state.fetchAllTasks,
|
provisioningRuns,
|
||||||
teams: state.teams,
|
currentProvisioningRunIdByTeam,
|
||||||
}))
|
provisioningSnapshotByTeam,
|
||||||
);
|
} = useStore(
|
||||||
|
useShallow((state) => ({
|
||||||
|
globalTasks: state.globalTasks,
|
||||||
|
globalTasksInitialized: state.globalTasksInitialized,
|
||||||
|
globalTasksLoading: state.globalTasksLoading,
|
||||||
|
fetchAllTasks: state.fetchAllTasks,
|
||||||
|
teams: state.teams,
|
||||||
|
provisioningRuns: state.provisioningRuns,
|
||||||
|
currentProvisioningRunIdByTeam: state.currentProvisioningRunIdByTeam,
|
||||||
|
provisioningSnapshotByTeam: state.provisioningSnapshotByTeam,
|
||||||
|
}))
|
||||||
|
);
|
||||||
const initialSnapshot = useMemo(() => getRecentProjectsClientSnapshot(), []);
|
const initialSnapshot = useMemo(() => getRecentProjectsClientSnapshot(), []);
|
||||||
const { openRecentProject, openProjectPath, selectProjectFolder } = useOpenRecentProject();
|
const { openRecentProject, openProjectPath, selectProjectFolder } = useOpenRecentProject();
|
||||||
const [recentProjects, setRecentProjects] = useState<DashboardRecentProject[]>(
|
const [recentProjects, setRecentProjects] = useState<DashboardRecentProject[]>(
|
||||||
|
|
@ -92,6 +105,21 @@ export function useRecentProjectsSection(
|
||||||
const recentProjectsRef = useRef<DashboardRecentProject[]>(
|
const recentProjectsRef = useRef<DashboardRecentProject[]>(
|
||||||
initialSnapshot?.payload.projects ?? []
|
initialSnapshot?.payload.projects ?? []
|
||||||
);
|
);
|
||||||
|
const provisioningState = useMemo(
|
||||||
|
() => ({ currentProvisioningRunIdByTeam, provisioningRuns }),
|
||||||
|
[currentProvisioningRunIdByTeam, provisioningRuns]
|
||||||
|
);
|
||||||
|
const provisioningTeamNames = useMemo(
|
||||||
|
() =>
|
||||||
|
Object.keys(currentProvisioningRunIdByTeam).filter((teamName) =>
|
||||||
|
isTeamProvisioningActive(provisioningState, teamName)
|
||||||
|
),
|
||||||
|
[currentProvisioningRunIdByTeam, provisioningState]
|
||||||
|
);
|
||||||
|
const provisioningTeamNamesKey = useMemo(
|
||||||
|
() => [...provisioningTeamNames].sort().join('\u0000'),
|
||||||
|
[provisioningTeamNames]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
recentProjectsRef.current = recentProjects;
|
recentProjectsRef.current = recentProjects;
|
||||||
|
|
@ -173,7 +201,7 @@ export function useRecentProjectsSection(
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [teams]);
|
}, [provisioningTeamNamesKey, teams]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!searchQuery.trim()) {
|
if (!searchQuery.trim()) {
|
||||||
|
|
@ -189,25 +217,13 @@ export function useRecentProjectsSection(
|
||||||
const taskCountsByProject = useMemo(() => buildTaskCountsByProject(globalTasks), [globalTasks]);
|
const taskCountsByProject = useMemo(() => buildTaskCountsByProject(globalTasks), [globalTasks]);
|
||||||
|
|
||||||
const activeTeamsByProject = useMemo(() => {
|
const activeTeamsByProject = useMemo(() => {
|
||||||
const aliveSet = new Set(aliveTeams);
|
return buildActiveTeamsByProject({
|
||||||
const teamsByProject = new Map<string, TeamSummary[]>();
|
teams,
|
||||||
|
aliveTeamNames: aliveTeams,
|
||||||
for (const team of teams) {
|
provisioningTeamNames,
|
||||||
if (!team.projectPath || !aliveSet.has(team.teamName)) {
|
provisioningSnapshotByTeam,
|
||||||
continue;
|
});
|
||||||
}
|
}, [aliveTeams, provisioningSnapshotByTeam, provisioningTeamNames, teams]);
|
||||||
|
|
||||||
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(
|
const decoratedCards = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||||
|
|
||||||
|
import type { TeamSummary } from '@shared/types';
|
||||||
|
|
||||||
|
interface BuildActiveTeamsByProjectInput {
|
||||||
|
teams: TeamSummary[];
|
||||||
|
aliveTeamNames: readonly string[];
|
||||||
|
provisioningTeamNames: readonly string[];
|
||||||
|
provisioningSnapshotByTeam: Record<string, TeamSummary>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildActiveTeamsByProject({
|
||||||
|
teams,
|
||||||
|
aliveTeamNames,
|
||||||
|
provisioningTeamNames,
|
||||||
|
provisioningSnapshotByTeam,
|
||||||
|
}: BuildActiveTeamsByProjectInput): Map<string, TeamSummary[]> {
|
||||||
|
const activeTeamNames = new Set<string>([...aliveTeamNames, ...provisioningTeamNames]);
|
||||||
|
if (activeTeamNames.size === 0) {
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingTeamNames = new Set(teams.map((team) => team.teamName));
|
||||||
|
const syntheticProvisioningTeams = provisioningTeamNames
|
||||||
|
.filter((teamName) => !existingTeamNames.has(teamName))
|
||||||
|
.map((teamName) => provisioningSnapshotByTeam[teamName])
|
||||||
|
.filter((team): team is TeamSummary => Boolean(team));
|
||||||
|
|
||||||
|
const teamsByProject = new Map<string, TeamSummary[]>();
|
||||||
|
const visibleTeams =
|
||||||
|
syntheticProvisioningTeams.length > 0 ? [...teams, ...syntheticProvisioningTeams] : teams;
|
||||||
|
|
||||||
|
for (const team of visibleTeams) {
|
||||||
|
if (!team.projectPath || !activeTeamNames.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;
|
||||||
|
}
|
||||||
|
|
@ -157,7 +157,7 @@ function resolveHistoryOpenedAt(lookup: HistoryLookup, projectPath: string): num
|
||||||
}
|
}
|
||||||
|
|
||||||
const foldedMatch = lookup.folded.get(foldHistoryPath(normalizedPath));
|
const foldedMatch = lookup.folded.get(foldHistoryPath(normalizedPath));
|
||||||
if (!foldedMatch || foldedMatch.exactPaths.size !== 1) {
|
if (foldedMatch?.exactPaths.size !== 1) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,12 @@ export async function killTmuxPaneForCurrentPlatform(paneId: string): Promise<vo
|
||||||
invalidateTmuxRuntimeStatusCache();
|
invalidateTmuxRuntimeStatusCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listTmuxPanePidsForCurrentPlatform(
|
||||||
|
paneIds: readonly string[]
|
||||||
|
): Promise<Map<string, number>> {
|
||||||
|
return runtimeCommandExecutor.listPanePids(paneIds);
|
||||||
|
}
|
||||||
|
|
||||||
export function killTmuxPaneForCurrentPlatformSync(paneId: string): void {
|
export function killTmuxPaneForCurrentPlatformSync(paneId: string): void {
|
||||||
runtimeCommandExecutor.killPaneSync(paneId);
|
runtimeCommandExecutor.killPaneSync(paneId);
|
||||||
invalidateTmuxRuntimeStatusCache();
|
invalidateTmuxRuntimeStatusCache();
|
||||||
|
|
|
||||||
|
|
@ -9,4 +9,5 @@ export {
|
||||||
isTmuxRuntimeReadyForCurrentPlatform,
|
isTmuxRuntimeReadyForCurrentPlatform,
|
||||||
killTmuxPaneForCurrentPlatform,
|
killTmuxPaneForCurrentPlatform,
|
||||||
killTmuxPaneForCurrentPlatformSync,
|
killTmuxPaneForCurrentPlatformSync,
|
||||||
|
listTmuxPanePidsForCurrentPlatform,
|
||||||
} from './composition/runtimeSupport';
|
} from './composition/runtimeSupport';
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,36 @@ export class TmuxPlatformCommandExecutor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listPanePids(paneIds: readonly string[]): Promise<Map<string, number>> {
|
||||||
|
const normalizedPaneIds = [...new Set(paneIds.map((paneId) => paneId.trim()).filter(Boolean))];
|
||||||
|
if (normalizedPaneIds.length === 0) {
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.execTmux(
|
||||||
|
['list-panes', '-a', '-F', '#{pane_id}\t#{pane_pid}'],
|
||||||
|
3_000
|
||||||
|
);
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
throw new Error(result.stderr || 'Failed to list tmux panes');
|
||||||
|
}
|
||||||
|
|
||||||
|
const wanted = new Set(normalizedPaneIds);
|
||||||
|
const panePidById = new Map<string, number>();
|
||||||
|
for (const line of result.stdout.split('\n')) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
const [paneId = '', rawPid = ''] = trimmed.split('\t');
|
||||||
|
const normalizedPaneId = paneId.trim();
|
||||||
|
if (!wanted.has(normalizedPaneId)) continue;
|
||||||
|
const pid = Number.parseInt(rawPid.trim(), 10);
|
||||||
|
if (Number.isFinite(pid) && pid > 0) {
|
||||||
|
panePidById.set(normalizedPaneId, pid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return panePidById;
|
||||||
|
}
|
||||||
|
|
||||||
killPaneSync(paneId: string): void {
|
killPaneSync(paneId: string): void {
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
const preferredDistro = this.#wslService.getPersistedPreferredDistroSync();
|
const preferredDistro = this.#wslService.getPersistedPreferredDistroSync();
|
||||||
|
|
|
||||||
|
|
@ -68,4 +68,26 @@ describe('TmuxPlatformCommandExecutor', () => {
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('lists pane pids for the requested pane ids only', async () => {
|
||||||
|
const executor = new TmuxPlatformCommandExecutor(
|
||||||
|
{
|
||||||
|
getPersistedPreferredDistroSync: () => null,
|
||||||
|
} as never,
|
||||||
|
{} as never
|
||||||
|
);
|
||||||
|
vi.spyOn(executor, 'execTmux').mockResolvedValue({
|
||||||
|
exitCode: 0,
|
||||||
|
stdout: '%1\t111\n%2\t222\n%3\tnot-a-pid\n',
|
||||||
|
stderr: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(executor.listPanePids(['%2', '%3', '%2'])).resolves.toEqual(
|
||||||
|
new Map([['%2', 222]])
|
||||||
|
);
|
||||||
|
expect(executor.execTmux).toHaveBeenCalledWith(
|
||||||
|
['list-panes', '-a', '-F', '#{pane_id}\t#{pane_pid}'],
|
||||||
|
3_000
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -471,7 +471,7 @@ export class TmuxWslService {
|
||||||
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', POWERSHELL_FEATURE_QUERY],
|
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', POWERSHELL_FEATURE_QUERY],
|
||||||
6_000
|
6_000
|
||||||
);
|
);
|
||||||
if (!result || result.exitCode !== 0 || !result.stdout.trim()) {
|
if (result?.exitCode !== 0 || !result.stdout.trim()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/**
|
/**
|
||||||
* Main process entry point for Claude Agent Teams UI.
|
* Main process entry point for Agent Teams UI.
|
||||||
*
|
*
|
||||||
* Responsibilities:
|
* Responsibilities:
|
||||||
* - Initialize Electron app and main window
|
* - Initialize Electron app and main window
|
||||||
|
|
@ -88,6 +88,7 @@ import {
|
||||||
} from './services/extensions';
|
} from './services/extensions';
|
||||||
import { startEventLoopLagMonitor } from './services/infrastructure/EventLoopLagMonitor';
|
import { startEventLoopLagMonitor } from './services/infrastructure/EventLoopLagMonitor';
|
||||||
import { HttpServer } from './services/infrastructure/HttpServer';
|
import { HttpServer } from './services/infrastructure/HttpServer';
|
||||||
|
import { clearAutoResumeService } from './services/team/AutoResumeService';
|
||||||
import {
|
import {
|
||||||
buildTeamControlApiBaseUrl,
|
buildTeamControlApiBaseUrl,
|
||||||
clearTeamControlApiState,
|
clearTeamControlApiState,
|
||||||
|
|
@ -563,6 +564,13 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
||||||
const teamName = row.teamName.trim();
|
const teamName = row.teamName.trim();
|
||||||
const detail = typeof row.detail === 'string' ? row.detail : '';
|
const detail = typeof row.detail === 'string' ? row.detail : '';
|
||||||
|
|
||||||
|
if (
|
||||||
|
teamDataService &&
|
||||||
|
(row.type === 'inbox' || row.type === 'lead-message' || row.type === 'config')
|
||||||
|
) {
|
||||||
|
teamDataService.invalidateMessageFeed(teamName);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Inbox change events: relay to lead + native OS notifications ---
|
// --- Inbox change events: relay to lead + native OS notifications ---
|
||||||
if (row.type === 'inbox') {
|
if (row.type === 'inbox') {
|
||||||
if (reconcileScheduler) {
|
if (reconcileScheduler) {
|
||||||
|
|
@ -905,6 +913,12 @@ async function initializeServices(): Promise<void> {
|
||||||
});
|
});
|
||||||
|
|
||||||
const forwardTeamChange = (event: TeamChangeEvent): void => {
|
const forwardTeamChange = (event: TeamChangeEvent): void => {
|
||||||
|
if (
|
||||||
|
teamDataService &&
|
||||||
|
(event.type === 'inbox' || event.type === 'lead-message' || event.type === 'config')
|
||||||
|
) {
|
||||||
|
teamDataService.invalidateMessageFeed(event.teamName);
|
||||||
|
}
|
||||||
safeSendToRenderer(mainWindow, TEAM_CHANGE, event);
|
safeSendToRenderer(mainWindow, TEAM_CHANGE, event);
|
||||||
httpServer?.broadcast('team-change', event);
|
httpServer?.broadcast('team-change', event);
|
||||||
};
|
};
|
||||||
|
|
@ -964,6 +978,7 @@ async function initializeServices(): Promise<void> {
|
||||||
boardTaskExactLogsService,
|
boardTaskExactLogsService,
|
||||||
boardTaskExactLogDetailService,
|
boardTaskExactLogDetailService,
|
||||||
teammateToolTracker ?? undefined,
|
teammateToolTracker ?? undefined,
|
||||||
|
teamLogSourceTracker,
|
||||||
branchStatusService ?? undefined,
|
branchStatusService ?? undefined,
|
||||||
{
|
{
|
||||||
rewire: rewireContextEvents,
|
rewire: rewireContextEvents,
|
||||||
|
|
@ -1070,6 +1085,11 @@ async function startHttpServer(
|
||||||
function shutdownServices(): void {
|
function shutdownServices(): void {
|
||||||
logger.info('Shutting down services...');
|
logger.info('Shutting down services...');
|
||||||
|
|
||||||
|
// Clear pending auto-resume timers before anything else — otherwise the
|
||||||
|
// dangling setTimeout handles keep the event loop alive past shutdown and
|
||||||
|
// may fire against a torn-down provisioning service.
|
||||||
|
clearAutoResumeService();
|
||||||
|
|
||||||
// Kill all team CLI processes via SIGKILL BEFORE anything else.
|
// Kill all team CLI processes via SIGKILL BEFORE anything else.
|
||||||
// This must happen before the OS closes stdin pipes (on app exit),
|
// This must happen before the OS closes stdin pipes (on app exit),
|
||||||
// because stdin EOF triggers CLI's graceful shutdown which deletes team files.
|
// because stdin EOF triggers CLI's graceful shutdown which deletes team files.
|
||||||
|
|
@ -1212,7 +1232,7 @@ function createWindow(): void {
|
||||||
backgroundColor: '#1a1a1a',
|
backgroundColor: '#1a1a1a',
|
||||||
...(useNativeTitleBar ? {} : { titleBarStyle: 'hidden' as const }),
|
...(useNativeTitleBar ? {} : { titleBarStyle: 'hidden' as const }),
|
||||||
...(isMac && { trafficLightPosition: getTrafficLightPositionForZoom(1) }),
|
...(isMac && { trafficLightPosition: getTrafficLightPositionForZoom(1) }),
|
||||||
title: 'Claude Agent Teams UI',
|
title: 'Agent Teams UI',
|
||||||
});
|
});
|
||||||
markRendererUnavailable(mainWindow);
|
markRendererUnavailable(mainWindow);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,7 @@ function validateNotificationsSection(
|
||||||
'notifyOnCrossTeamMessage',
|
'notifyOnCrossTeamMessage',
|
||||||
'notifyOnTeamLaunched',
|
'notifyOnTeamLaunched',
|
||||||
'notifyOnToolApproval',
|
'notifyOnToolApproval',
|
||||||
|
'autoResumeOnRateLimit',
|
||||||
'statusChangeOnlySolo',
|
'statusChangeOnlySolo',
|
||||||
'statusChangeStatuses',
|
'statusChangeStatuses',
|
||||||
'triggers',
|
'triggers',
|
||||||
|
|
@ -219,6 +220,12 @@ function validateNotificationsSection(
|
||||||
}
|
}
|
||||||
result.notifyOnToolApproval = value;
|
result.notifyOnToolApproval = value;
|
||||||
break;
|
break;
|
||||||
|
case 'autoResumeOnRateLimit':
|
||||||
|
if (typeof value !== 'boolean') {
|
||||||
|
return { valid: false, error: `notifications.${key} must be a boolean` };
|
||||||
|
}
|
||||||
|
result.autoResumeOnRateLimit = value;
|
||||||
|
break;
|
||||||
case 'statusChangeOnlySolo':
|
case 'statusChangeOnlySolo':
|
||||||
if (typeof value !== 'boolean') {
|
if (typeof value !== 'boolean') {
|
||||||
return { valid: false, error: `notifications.${key} must be a boolean` };
|
return { valid: false, error: `notifications.${key} must be a boolean` };
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,7 @@ import type {
|
||||||
ServiceContextRegistry,
|
ServiceContextRegistry,
|
||||||
SshConnectionManager,
|
SshConnectionManager,
|
||||||
TeamDataService,
|
TeamDataService,
|
||||||
|
TeamLogSourceTracker,
|
||||||
TeammateToolTracker,
|
TeammateToolTracker,
|
||||||
TeamMemberLogsFinder,
|
TeamMemberLogsFinder,
|
||||||
TeamProvisioningService,
|
TeamProvisioningService,
|
||||||
|
|
@ -141,6 +142,7 @@ export function initializeIpcHandlers(
|
||||||
boardTaskExactLogsService: BoardTaskExactLogsService,
|
boardTaskExactLogsService: BoardTaskExactLogsService,
|
||||||
boardTaskExactLogDetailService: BoardTaskExactLogDetailService,
|
boardTaskExactLogDetailService: BoardTaskExactLogDetailService,
|
||||||
teammateToolTracker: TeammateToolTracker | undefined,
|
teammateToolTracker: TeammateToolTracker | undefined,
|
||||||
|
teamLogSourceTracker: TeamLogSourceTracker | undefined,
|
||||||
branchStatusService: BranchStatusService | undefined,
|
branchStatusService: BranchStatusService | undefined,
|
||||||
contextCallbacks: {
|
contextCallbacks: {
|
||||||
rewire: (context: ServiceContext) => void;
|
rewire: (context: ServiceContext) => void;
|
||||||
|
|
@ -184,6 +186,7 @@ export function initializeIpcHandlers(
|
||||||
memberStatsComputer,
|
memberStatsComputer,
|
||||||
teamBackupService,
|
teamBackupService,
|
||||||
teammateToolTracker,
|
teammateToolTracker,
|
||||||
|
teamLogSourceTracker,
|
||||||
branchStatusService,
|
branchStatusService,
|
||||||
boardTaskActivityService,
|
boardTaskActivityService,
|
||||||
boardTaskActivityDetailService,
|
boardTaskActivityDetailService,
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,14 @@ import {
|
||||||
TEAM_DELETE_DRAFT,
|
TEAM_DELETE_DRAFT,
|
||||||
TEAM_DELETE_TASK_ATTACHMENT,
|
TEAM_DELETE_TASK_ATTACHMENT,
|
||||||
TEAM_DELETE_TEAM,
|
TEAM_DELETE_TEAM,
|
||||||
|
TEAM_GET_AGENT_RUNTIME,
|
||||||
TEAM_GET_ALL_TASKS,
|
TEAM_GET_ALL_TASKS,
|
||||||
TEAM_GET_ATTACHMENTS,
|
TEAM_GET_ATTACHMENTS,
|
||||||
TEAM_GET_CLAUDE_LOGS,
|
TEAM_GET_CLAUDE_LOGS,
|
||||||
TEAM_GET_DATA,
|
TEAM_GET_DATA,
|
||||||
TEAM_GET_DELETED_TASKS,
|
TEAM_GET_DELETED_TASKS,
|
||||||
TEAM_GET_LOGS_FOR_TASK,
|
TEAM_GET_LOGS_FOR_TASK,
|
||||||
|
TEAM_GET_MEMBER_ACTIVITY_META,
|
||||||
TEAM_GET_MEMBER_LOGS,
|
TEAM_GET_MEMBER_LOGS,
|
||||||
TEAM_GET_MEMBER_STATS,
|
TEAM_GET_MEMBER_STATS,
|
||||||
TEAM_GET_MESSAGES_PAGE,
|
TEAM_GET_MESSAGES_PAGE,
|
||||||
|
|
@ -34,6 +36,7 @@ import {
|
||||||
TEAM_GET_TASK_EXACT_LOG_DETAIL,
|
TEAM_GET_TASK_EXACT_LOG_DETAIL,
|
||||||
TEAM_GET_TASK_EXACT_LOG_SUMMARIES,
|
TEAM_GET_TASK_EXACT_LOG_SUMMARIES,
|
||||||
TEAM_GET_TASK_LOG_STREAM,
|
TEAM_GET_TASK_LOG_STREAM,
|
||||||
|
TEAM_GET_TASK_LOG_STREAM_SUMMARY,
|
||||||
TEAM_KILL_PROCESS,
|
TEAM_KILL_PROCESS,
|
||||||
TEAM_LAUNCH,
|
TEAM_LAUNCH,
|
||||||
TEAM_LEAD_ACTIVITY,
|
TEAM_LEAD_ACTIVITY,
|
||||||
|
|
@ -50,6 +53,7 @@ import {
|
||||||
TEAM_REMOVE_TASK_RELATIONSHIP,
|
TEAM_REMOVE_TASK_RELATIONSHIP,
|
||||||
TEAM_REPLACE_MEMBERS,
|
TEAM_REPLACE_MEMBERS,
|
||||||
TEAM_REQUEST_REVIEW,
|
TEAM_REQUEST_REVIEW,
|
||||||
|
TEAM_RESTART_MEMBER,
|
||||||
TEAM_RESTORE,
|
TEAM_RESTORE,
|
||||||
TEAM_RESTORE_TASK,
|
TEAM_RESTORE_TASK,
|
||||||
TEAM_SAVE_TASK_ATTACHMENT,
|
TEAM_SAVE_TASK_ATTACHMENT,
|
||||||
|
|
@ -57,6 +61,7 @@ import {
|
||||||
TEAM_SET_CHANGE_PRESENCE_TRACKING,
|
TEAM_SET_CHANGE_PRESENCE_TRACKING,
|
||||||
TEAM_SET_PROJECT_BRANCH_TRACKING,
|
TEAM_SET_PROJECT_BRANCH_TRACKING,
|
||||||
TEAM_SET_TASK_CLARIFICATION,
|
TEAM_SET_TASK_CLARIFICATION,
|
||||||
|
TEAM_SET_TASK_LOG_STREAM_TRACKING,
|
||||||
TEAM_SET_TOOL_ACTIVITY_TRACKING,
|
TEAM_SET_TOOL_ACTIVITY_TRACKING,
|
||||||
TEAM_SHOW_MESSAGE_NOTIFICATION,
|
TEAM_SHOW_MESSAGE_NOTIFICATION,
|
||||||
TEAM_SOFT_DELETE_TASK,
|
TEAM_SOFT_DELETE_TASK,
|
||||||
|
|
@ -92,7 +97,7 @@ import {
|
||||||
parseStandaloneSlashCommand,
|
parseStandaloneSlashCommand,
|
||||||
} from '@shared/utils/slashCommands';
|
} from '@shared/utils/slashCommands';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { BrowserWindow, type IpcMain, type IpcMainInvokeEvent, Notification } from 'electron';
|
import { app, BrowserWindow, type IpcMain, type IpcMainInvokeEvent, Notification } from 'electron';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
|
|
@ -103,10 +108,15 @@ import {
|
||||||
buildActionModeAgentBlock,
|
buildActionModeAgentBlock,
|
||||||
isAgentActionMode,
|
isAgentActionMode,
|
||||||
} from '../services/team/actionModeInstructions';
|
} from '../services/team/actionModeInstructions';
|
||||||
|
import {
|
||||||
|
getAutoResumeService,
|
||||||
|
initializeAutoResumeService,
|
||||||
|
} from '../services/team/AutoResumeService';
|
||||||
import {
|
import {
|
||||||
buildReplaceMembersDiff,
|
buildReplaceMembersDiff,
|
||||||
buildReplaceMembersSummaryMessage,
|
buildReplaceMembersSummaryMessage,
|
||||||
} from '../services/team/memberUpdateNotifications';
|
} from '../services/team/memberUpdateNotifications';
|
||||||
|
import { mergeLiveLeadProcessMessages } from '../services/team/mergeLiveLeadProcessMessages';
|
||||||
import { TeamAttachmentStore } from '../services/team/TeamAttachmentStore';
|
import { TeamAttachmentStore } from '../services/team/TeamAttachmentStore';
|
||||||
import { TeamMembersMetaStore } from '../services/team/TeamMembersMetaStore';
|
import { TeamMembersMetaStore } from '../services/team/TeamMembersMetaStore';
|
||||||
import { TeamMetaStore } from '../services/team/TeamMetaStore';
|
import { TeamMetaStore } from '../services/team/TeamMetaStore';
|
||||||
|
|
@ -130,6 +140,7 @@ import type {
|
||||||
BranchStatusService,
|
BranchStatusService,
|
||||||
MemberStatsComputer,
|
MemberStatsComputer,
|
||||||
TeamDataService,
|
TeamDataService,
|
||||||
|
TeamLogSourceTracker,
|
||||||
TeammateToolTracker,
|
TeammateToolTracker,
|
||||||
TeamMemberLogsFinder,
|
TeamMemberLogsFinder,
|
||||||
TeamProvisioningService,
|
TeamProvisioningService,
|
||||||
|
|
@ -146,17 +157,16 @@ import type {
|
||||||
BoardTaskExactLogDetailResult,
|
BoardTaskExactLogDetailResult,
|
||||||
BoardTaskExactLogSummariesResponse,
|
BoardTaskExactLogSummariesResponse,
|
||||||
BoardTaskLogStreamResponse,
|
BoardTaskLogStreamResponse,
|
||||||
|
BoardTaskLogStreamSummary,
|
||||||
CreateTaskRequest,
|
CreateTaskRequest,
|
||||||
EffortLevel,
|
EffortLevel,
|
||||||
GlobalTask,
|
GlobalTask,
|
||||||
IpcResult,
|
IpcResult,
|
||||||
KanbanColumnId,
|
KanbanColumnId,
|
||||||
LeadActivitySnapshot,
|
LeadActivitySnapshot,
|
||||||
LeadContextUsage,
|
|
||||||
LeadContextUsageSnapshot,
|
LeadContextUsageSnapshot,
|
||||||
MemberFullStats,
|
MemberFullStats,
|
||||||
MemberLogSummary,
|
MemberLogSummary,
|
||||||
MemberSpawnStatusEntry,
|
|
||||||
MemberSpawnStatusesSnapshot,
|
MemberSpawnStatusesSnapshot,
|
||||||
MessagesPage,
|
MessagesPage,
|
||||||
SendMessageRequest,
|
SendMessageRequest,
|
||||||
|
|
@ -164,15 +174,16 @@ import type {
|
||||||
TaskAttachmentMeta,
|
TaskAttachmentMeta,
|
||||||
TaskComment,
|
TaskComment,
|
||||||
TaskRef,
|
TaskRef,
|
||||||
|
TeamAgentRuntimeSnapshot,
|
||||||
TeamClaudeLogsQuery,
|
TeamClaudeLogsQuery,
|
||||||
TeamClaudeLogsResponse,
|
TeamClaudeLogsResponse,
|
||||||
TeamConfig,
|
TeamConfig,
|
||||||
TeamCreateConfigRequest,
|
TeamCreateConfigRequest,
|
||||||
TeamCreateRequest,
|
TeamCreateRequest,
|
||||||
TeamCreateResponse,
|
TeamCreateResponse,
|
||||||
TeamData,
|
|
||||||
TeamLaunchRequest,
|
TeamLaunchRequest,
|
||||||
TeamLaunchResponse,
|
TeamLaunchResponse,
|
||||||
|
TeamMemberActivityMeta,
|
||||||
TeamMessageNotificationData,
|
TeamMessageNotificationData,
|
||||||
TeamProvisioningPrepareResult,
|
TeamProvisioningPrepareResult,
|
||||||
TeamProvisioningProgress,
|
TeamProvisioningProgress,
|
||||||
|
|
@ -180,6 +191,7 @@ import type {
|
||||||
TeamTask,
|
TeamTask,
|
||||||
TeamTaskStatus,
|
TeamTaskStatus,
|
||||||
TeamUpdateConfigRequest,
|
TeamUpdateConfigRequest,
|
||||||
|
TeamViewSnapshot,
|
||||||
ToolApprovalFileContent,
|
ToolApprovalFileContent,
|
||||||
ToolApprovalSettings,
|
ToolApprovalSettings,
|
||||||
UpdateKanbanPatch,
|
UpdateKanbanPatch,
|
||||||
|
|
@ -196,6 +208,16 @@ const logger = createLogger('IPC:teams');
|
||||||
const seenRateLimitKeys = new Set<string>();
|
const seenRateLimitKeys = new Set<string>();
|
||||||
const SEEN_RATE_LIMIT_KEYS_MAX = 500;
|
const SEEN_RATE_LIMIT_KEYS_MAX = 500;
|
||||||
|
|
||||||
|
function noteHeavyTeamDataWorkerFallback(operation: string): void {
|
||||||
|
if (!app.isPackaged) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error(
|
||||||
|
`[${operation}] team-data-worker unavailable in packaged runtime; falling back to main-thread execution for heavy message/activity path`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function getDurableLeadTeammateRoster(
|
async function getDurableLeadTeammateRoster(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
leadName: string
|
leadName: string
|
||||||
|
|
@ -301,11 +323,25 @@ const SEEN_API_ERROR_KEYS_MAX = 500;
|
||||||
* and NotificationManager dedupeKey (to prevent storage duplicates).
|
* and NotificationManager dedupeKey (to prevent storage duplicates).
|
||||||
*/
|
*/
|
||||||
function checkRateLimitMessages(
|
function checkRateLimitMessages(
|
||||||
messages: readonly { messageId?: string; from: string; text: string; timestamp: string }[],
|
messages: readonly {
|
||||||
|
messageId?: string;
|
||||||
|
from: string;
|
||||||
|
text: string;
|
||||||
|
timestamp: string;
|
||||||
|
to?: string;
|
||||||
|
source?: string;
|
||||||
|
leadSessionId?: string;
|
||||||
|
}[],
|
||||||
teamName: string,
|
teamName: string,
|
||||||
teamDisplayName: string,
|
teamDisplayName: string,
|
||||||
projectPath?: string
|
projectPath?: string,
|
||||||
|
teamIsAlive = true,
|
||||||
|
currentLeadSessionId: string | null = null
|
||||||
): void {
|
): void {
|
||||||
|
const observedAt = new Date();
|
||||||
|
const autoResumeEnabled =
|
||||||
|
ConfigManager.getInstance().getConfig().notifications.autoResumeOnRateLimit;
|
||||||
|
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
if (msg.from === 'user') continue;
|
if (msg.from === 'user') continue;
|
||||||
if (!isRateLimitMessage(msg.text)) continue;
|
if (!isRateLimitMessage(msg.text)) continue;
|
||||||
|
|
@ -313,28 +349,55 @@ function checkRateLimitMessages(
|
||||||
const rawKey = msg.messageId ?? `${msg.from}:${msg.timestamp}`;
|
const rawKey = msg.messageId ?? `${msg.from}:${msg.timestamp}`;
|
||||||
const dedupeKey = `rate-limit:${teamName}:${rawKey}`;
|
const dedupeKey = `rate-limit:${teamName}:${rawKey}`;
|
||||||
|
|
||||||
// In-memory guard: prevents resurrection after user deletes the notification
|
// In-memory guard: prevents resurrection after user deletes the notification.
|
||||||
if (seenRateLimitKeys.has(dedupeKey)) continue;
|
if (!seenRateLimitKeys.has(dedupeKey)) {
|
||||||
seenRateLimitKeys.add(dedupeKey);
|
seenRateLimitKeys.add(dedupeKey);
|
||||||
|
|
||||||
// Evict oldest entries to prevent unbounded growth
|
// Evict oldest entries to prevent unbounded growth
|
||||||
if (seenRateLimitKeys.size > SEEN_RATE_LIMIT_KEYS_MAX) {
|
if (seenRateLimitKeys.size > SEEN_RATE_LIMIT_KEYS_MAX) {
|
||||||
const first = seenRateLimitKeys.values().next().value;
|
const first = seenRateLimitKeys.values().next().value;
|
||||||
if (first) seenRateLimitKeys.delete(first);
|
if (first) seenRateLimitKeys.delete(first);
|
||||||
|
}
|
||||||
|
|
||||||
|
void NotificationManager.getInstance()
|
||||||
|
.addTeamNotification({
|
||||||
|
teamEventType: 'rate_limit',
|
||||||
|
teamName,
|
||||||
|
teamDisplayName,
|
||||||
|
from: msg.from,
|
||||||
|
summary: `Rate limit: ${msg.from}`,
|
||||||
|
body: msg.text.slice(0, 200),
|
||||||
|
dedupeKey,
|
||||||
|
projectPath,
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
void NotificationManager.getInstance()
|
// Only schedule auto-resume while a live team run currently exists.
|
||||||
.addTeamNotification({
|
// Persisted history for an offline/stopped team may still contain the old
|
||||||
teamEventType: 'rate_limit',
|
// rate-limit message, but arming a new timer from that stale history would
|
||||||
|
// resurrect the nudge into a later manual restart.
|
||||||
|
const isLeadAutoResumeCandidate =
|
||||||
|
!msg.to && (msg.source === 'lead_process' || msg.source === 'lead_session');
|
||||||
|
|
||||||
|
if (autoResumeEnabled && teamIsAlive && isLeadAutoResumeCandidate) {
|
||||||
|
// Only let persisted lead_session history rebuild auto-resume when it
|
||||||
|
// clearly belongs to the currently running lead session. Otherwise an old
|
||||||
|
// rate-limit from a previous manual run can resurrect into a newer restart.
|
||||||
|
if (msg.source === 'lead_session') {
|
||||||
|
if (!currentLeadSessionId) continue;
|
||||||
|
if (msg.leadSessionId !== currentLeadSessionId) continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass the original message timestamp so relative reset windows survive restarts
|
||||||
|
// and old history does not rebuild a fresh auto-resume timer from "now".
|
||||||
|
getAutoResumeService().handleRateLimitMessage(
|
||||||
teamName,
|
teamName,
|
||||||
teamDisplayName,
|
msg.text,
|
||||||
from: msg.from,
|
observedAt,
|
||||||
summary: `Rate limit: ${msg.from}`,
|
new Date(msg.timestamp)
|
||||||
body: msg.text.slice(0, 200),
|
);
|
||||||
dedupeKey,
|
}
|
||||||
projectPath,
|
|
||||||
})
|
|
||||||
.catch(() => undefined);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -385,12 +448,26 @@ function checkApiErrorMessages(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scanTeamMessageNotifications(
|
||||||
|
messages: readonly { messageId?: string; from: string; text: string; timestamp: string }[],
|
||||||
|
teamName: string,
|
||||||
|
teamDisplayName: string,
|
||||||
|
projectPath?: string
|
||||||
|
): void {
|
||||||
|
if (messages.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
checkRateLimitMessages(messages, teamName, teamDisplayName, projectPath);
|
||||||
|
checkApiErrorMessages(messages, teamName, teamDisplayName, projectPath);
|
||||||
|
}
|
||||||
|
|
||||||
let teamDataService: TeamDataService | null = null;
|
let teamDataService: TeamDataService | null = null;
|
||||||
let teamProvisioningService: TeamProvisioningService | null = null;
|
let teamProvisioningService: TeamProvisioningService | null = null;
|
||||||
let teamMemberLogsFinder: TeamMemberLogsFinder | null = null;
|
let teamMemberLogsFinder: TeamMemberLogsFinder | null = null;
|
||||||
let memberStatsComputer: MemberStatsComputer | null = null;
|
let memberStatsComputer: MemberStatsComputer | null = null;
|
||||||
let teamBackupService: TeamBackupService | null = null;
|
let teamBackupService: TeamBackupService | null = null;
|
||||||
let teammateToolTracker: TeammateToolTracker | null = null;
|
let teammateToolTracker: TeammateToolTracker | null = null;
|
||||||
|
let teamLogSourceTracker: TeamLogSourceTracker | null = null;
|
||||||
let branchStatusService: BranchStatusService | null = null;
|
let branchStatusService: BranchStatusService | null = null;
|
||||||
let boardTaskActivityService: BoardTaskActivityService | null = null;
|
let boardTaskActivityService: BoardTaskActivityService | null = null;
|
||||||
let boardTaskActivityDetailService: BoardTaskActivityDetailService | null = null;
|
let boardTaskActivityDetailService: BoardTaskActivityDetailService | null = null;
|
||||||
|
|
@ -427,6 +504,7 @@ export function initializeTeamHandlers(
|
||||||
statsComputer?: MemberStatsComputer,
|
statsComputer?: MemberStatsComputer,
|
||||||
backupService?: TeamBackupService,
|
backupService?: TeamBackupService,
|
||||||
toolTracker?: TeammateToolTracker,
|
toolTracker?: TeammateToolTracker,
|
||||||
|
logSourceTracker?: TeamLogSourceTracker,
|
||||||
branchTracker?: BranchStatusService,
|
branchTracker?: BranchStatusService,
|
||||||
taskActivityService?: BoardTaskActivityService,
|
taskActivityService?: BoardTaskActivityService,
|
||||||
taskActivityDetailService?: BoardTaskActivityDetailService,
|
taskActivityDetailService?: BoardTaskActivityDetailService,
|
||||||
|
|
@ -436,10 +514,12 @@ export function initializeTeamHandlers(
|
||||||
): void {
|
): void {
|
||||||
teamDataService = service;
|
teamDataService = service;
|
||||||
teamProvisioningService = provisioningService;
|
teamProvisioningService = provisioningService;
|
||||||
|
initializeAutoResumeService(provisioningService);
|
||||||
teamMemberLogsFinder = logsFinder ?? null;
|
teamMemberLogsFinder = logsFinder ?? null;
|
||||||
memberStatsComputer = statsComputer ?? null;
|
memberStatsComputer = statsComputer ?? null;
|
||||||
teamBackupService = backupService ?? null;
|
teamBackupService = backupService ?? null;
|
||||||
teammateToolTracker = toolTracker ?? null;
|
teammateToolTracker = toolTracker ?? null;
|
||||||
|
teamLogSourceTracker = logSourceTracker ?? null;
|
||||||
branchStatusService = branchTracker ?? null;
|
branchStatusService = branchTracker ?? null;
|
||||||
boardTaskActivityService = taskActivityService ?? null;
|
boardTaskActivityService = taskActivityService ?? null;
|
||||||
boardTaskActivityDetailService = taskActivityDetailService ?? null;
|
boardTaskActivityDetailService = taskActivityDetailService ?? null;
|
||||||
|
|
@ -454,6 +534,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.handle(TEAM_GET_TASK_CHANGE_PRESENCE, handleGetTaskChangePresence);
|
ipcMain.handle(TEAM_GET_TASK_CHANGE_PRESENCE, handleGetTaskChangePresence);
|
||||||
ipcMain.handle(TEAM_SET_CHANGE_PRESENCE_TRACKING, handleSetChangePresenceTracking);
|
ipcMain.handle(TEAM_SET_CHANGE_PRESENCE_TRACKING, handleSetChangePresenceTracking);
|
||||||
ipcMain.handle(TEAM_SET_PROJECT_BRANCH_TRACKING, handleSetProjectBranchTracking);
|
ipcMain.handle(TEAM_SET_PROJECT_BRANCH_TRACKING, handleSetProjectBranchTracking);
|
||||||
|
ipcMain.handle(TEAM_SET_TASK_LOG_STREAM_TRACKING, handleSetTaskLogStreamTracking);
|
||||||
ipcMain.handle(TEAM_SET_TOOL_ACTIVITY_TRACKING, handleSetToolActivityTracking);
|
ipcMain.handle(TEAM_SET_TOOL_ACTIVITY_TRACKING, handleSetToolActivityTracking);
|
||||||
ipcMain.handle(TEAM_GET_CLAUDE_LOGS, handleGetClaudeLogs);
|
ipcMain.handle(TEAM_GET_CLAUDE_LOGS, handleGetClaudeLogs);
|
||||||
ipcMain.handle(TEAM_PREPARE_PROVISIONING, handlePrepareProvisioning);
|
ipcMain.handle(TEAM_PREPARE_PROVISIONING, handlePrepareProvisioning);
|
||||||
|
|
@ -463,6 +544,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.handle(TEAM_CANCEL_PROVISIONING, handleCancelProvisioning);
|
ipcMain.handle(TEAM_CANCEL_PROVISIONING, handleCancelProvisioning);
|
||||||
ipcMain.handle(TEAM_SEND_MESSAGE, handleSendMessage);
|
ipcMain.handle(TEAM_SEND_MESSAGE, handleSendMessage);
|
||||||
ipcMain.handle(TEAM_GET_MESSAGES_PAGE, handleGetMessagesPage);
|
ipcMain.handle(TEAM_GET_MESSAGES_PAGE, handleGetMessagesPage);
|
||||||
|
ipcMain.handle(TEAM_GET_MEMBER_ACTIVITY_META, handleGetMemberActivityMeta);
|
||||||
ipcMain.handle(TEAM_CREATE_TASK, handleCreateTask);
|
ipcMain.handle(TEAM_CREATE_TASK, handleCreateTask);
|
||||||
ipcMain.handle(TEAM_REQUEST_REVIEW, handleRequestReview);
|
ipcMain.handle(TEAM_REQUEST_REVIEW, handleRequestReview);
|
||||||
ipcMain.handle(TEAM_UPDATE_KANBAN, handleUpdateKanban);
|
ipcMain.handle(TEAM_UPDATE_KANBAN, handleUpdateKanban);
|
||||||
|
|
@ -482,6 +564,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.handle(TEAM_GET_LOGS_FOR_TASK, handleGetLogsForTask);
|
ipcMain.handle(TEAM_GET_LOGS_FOR_TASK, handleGetLogsForTask);
|
||||||
ipcMain.handle(TEAM_GET_TASK_ACTIVITY, handleGetTaskActivity);
|
ipcMain.handle(TEAM_GET_TASK_ACTIVITY, handleGetTaskActivity);
|
||||||
ipcMain.handle(TEAM_GET_TASK_ACTIVITY_DETAIL, handleGetTaskActivityDetail);
|
ipcMain.handle(TEAM_GET_TASK_ACTIVITY_DETAIL, handleGetTaskActivityDetail);
|
||||||
|
ipcMain.handle(TEAM_GET_TASK_LOG_STREAM_SUMMARY, handleGetTaskLogStreamSummary);
|
||||||
ipcMain.handle(TEAM_GET_TASK_LOG_STREAM, handleGetTaskLogStream);
|
ipcMain.handle(TEAM_GET_TASK_LOG_STREAM, handleGetTaskLogStream);
|
||||||
ipcMain.handle(TEAM_GET_TASK_EXACT_LOG_SUMMARIES, handleGetTaskExactLogSummaries);
|
ipcMain.handle(TEAM_GET_TASK_EXACT_LOG_SUMMARIES, handleGetTaskExactLogSummaries);
|
||||||
ipcMain.handle(TEAM_GET_TASK_EXACT_LOG_DETAIL, handleGetTaskExactLogDetail);
|
ipcMain.handle(TEAM_GET_TASK_EXACT_LOG_DETAIL, handleGetTaskExactLogDetail);
|
||||||
|
|
@ -501,6 +584,8 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.handle(TEAM_LEAD_ACTIVITY, handleLeadActivity);
|
ipcMain.handle(TEAM_LEAD_ACTIVITY, handleLeadActivity);
|
||||||
ipcMain.handle(TEAM_LEAD_CONTEXT, handleLeadContext);
|
ipcMain.handle(TEAM_LEAD_CONTEXT, handleLeadContext);
|
||||||
ipcMain.handle(TEAM_MEMBER_SPAWN_STATUSES, handleMemberSpawnStatuses);
|
ipcMain.handle(TEAM_MEMBER_SPAWN_STATUSES, handleMemberSpawnStatuses);
|
||||||
|
ipcMain.handle(TEAM_GET_AGENT_RUNTIME, handleGetAgentRuntime);
|
||||||
|
ipcMain.handle(TEAM_RESTART_MEMBER, handleRestartMember);
|
||||||
ipcMain.handle(TEAM_SOFT_DELETE_TASK, handleSoftDeleteTask);
|
ipcMain.handle(TEAM_SOFT_DELETE_TASK, handleSoftDeleteTask);
|
||||||
ipcMain.handle(TEAM_RESTORE_TASK, handleRestoreTask);
|
ipcMain.handle(TEAM_RESTORE_TASK, handleRestoreTask);
|
||||||
ipcMain.handle(TEAM_GET_DELETED_TASKS, handleGetDeletedTasks);
|
ipcMain.handle(TEAM_GET_DELETED_TASKS, handleGetDeletedTasks);
|
||||||
|
|
@ -526,6 +611,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.removeHandler(TEAM_GET_TASK_CHANGE_PRESENCE);
|
ipcMain.removeHandler(TEAM_GET_TASK_CHANGE_PRESENCE);
|
||||||
ipcMain.removeHandler(TEAM_SET_CHANGE_PRESENCE_TRACKING);
|
ipcMain.removeHandler(TEAM_SET_CHANGE_PRESENCE_TRACKING);
|
||||||
ipcMain.removeHandler(TEAM_SET_PROJECT_BRANCH_TRACKING);
|
ipcMain.removeHandler(TEAM_SET_PROJECT_BRANCH_TRACKING);
|
||||||
|
ipcMain.removeHandler(TEAM_SET_TASK_LOG_STREAM_TRACKING);
|
||||||
ipcMain.removeHandler(TEAM_SET_TOOL_ACTIVITY_TRACKING);
|
ipcMain.removeHandler(TEAM_SET_TOOL_ACTIVITY_TRACKING);
|
||||||
ipcMain.removeHandler(TEAM_GET_CLAUDE_LOGS);
|
ipcMain.removeHandler(TEAM_GET_CLAUDE_LOGS);
|
||||||
ipcMain.removeHandler(TEAM_PREPARE_PROVISIONING);
|
ipcMain.removeHandler(TEAM_PREPARE_PROVISIONING);
|
||||||
|
|
@ -535,6 +621,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.removeHandler(TEAM_CANCEL_PROVISIONING);
|
ipcMain.removeHandler(TEAM_CANCEL_PROVISIONING);
|
||||||
ipcMain.removeHandler(TEAM_SEND_MESSAGE);
|
ipcMain.removeHandler(TEAM_SEND_MESSAGE);
|
||||||
ipcMain.removeHandler(TEAM_GET_MESSAGES_PAGE);
|
ipcMain.removeHandler(TEAM_GET_MESSAGES_PAGE);
|
||||||
|
ipcMain.removeHandler(TEAM_GET_MEMBER_ACTIVITY_META);
|
||||||
ipcMain.removeHandler(TEAM_CREATE_TASK);
|
ipcMain.removeHandler(TEAM_CREATE_TASK);
|
||||||
ipcMain.removeHandler(TEAM_REQUEST_REVIEW);
|
ipcMain.removeHandler(TEAM_REQUEST_REVIEW);
|
||||||
ipcMain.removeHandler(TEAM_UPDATE_KANBAN);
|
ipcMain.removeHandler(TEAM_UPDATE_KANBAN);
|
||||||
|
|
@ -554,6 +641,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.removeHandler(TEAM_GET_LOGS_FOR_TASK);
|
ipcMain.removeHandler(TEAM_GET_LOGS_FOR_TASK);
|
||||||
ipcMain.removeHandler(TEAM_GET_TASK_ACTIVITY);
|
ipcMain.removeHandler(TEAM_GET_TASK_ACTIVITY);
|
||||||
ipcMain.removeHandler(TEAM_GET_TASK_ACTIVITY_DETAIL);
|
ipcMain.removeHandler(TEAM_GET_TASK_ACTIVITY_DETAIL);
|
||||||
|
ipcMain.removeHandler(TEAM_GET_TASK_LOG_STREAM_SUMMARY);
|
||||||
ipcMain.removeHandler(TEAM_GET_TASK_LOG_STREAM);
|
ipcMain.removeHandler(TEAM_GET_TASK_LOG_STREAM);
|
||||||
ipcMain.removeHandler(TEAM_GET_TASK_EXACT_LOG_SUMMARIES);
|
ipcMain.removeHandler(TEAM_GET_TASK_EXACT_LOG_SUMMARIES);
|
||||||
ipcMain.removeHandler(TEAM_GET_TASK_EXACT_LOG_DETAIL);
|
ipcMain.removeHandler(TEAM_GET_TASK_EXACT_LOG_DETAIL);
|
||||||
|
|
@ -573,6 +661,8 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.removeHandler(TEAM_LEAD_ACTIVITY);
|
ipcMain.removeHandler(TEAM_LEAD_ACTIVITY);
|
||||||
ipcMain.removeHandler(TEAM_LEAD_CONTEXT);
|
ipcMain.removeHandler(TEAM_LEAD_CONTEXT);
|
||||||
ipcMain.removeHandler(TEAM_MEMBER_SPAWN_STATUSES);
|
ipcMain.removeHandler(TEAM_MEMBER_SPAWN_STATUSES);
|
||||||
|
ipcMain.removeHandler(TEAM_GET_AGENT_RUNTIME);
|
||||||
|
ipcMain.removeHandler(TEAM_RESTART_MEMBER);
|
||||||
ipcMain.removeHandler(TEAM_SOFT_DELETE_TASK);
|
ipcMain.removeHandler(TEAM_SOFT_DELETE_TASK);
|
||||||
ipcMain.removeHandler(TEAM_RESTORE_TASK);
|
ipcMain.removeHandler(TEAM_RESTORE_TASK);
|
||||||
ipcMain.removeHandler(TEAM_GET_DELETED_TASKS);
|
ipcMain.removeHandler(TEAM_GET_DELETED_TASKS);
|
||||||
|
|
@ -612,6 +702,13 @@ function getTeammateToolTracker(): TeammateToolTracker {
|
||||||
return teammateToolTracker;
|
return teammateToolTracker;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getTeamLogSourceTracker(): TeamLogSourceTracker {
|
||||||
|
if (!teamLogSourceTracker) {
|
||||||
|
throw new Error('Team log source tracker is not initialized');
|
||||||
|
}
|
||||||
|
return teamLogSourceTracker;
|
||||||
|
}
|
||||||
|
|
||||||
function getBranchStatusService(): BranchStatusService {
|
function getBranchStatusService(): BranchStatusService {
|
||||||
if (!branchStatusService) {
|
if (!branchStatusService) {
|
||||||
throw new Error('Branch status service is not initialized');
|
throw new Error('Branch status service is not initialized');
|
||||||
|
|
@ -702,14 +799,14 @@ async function handleListTeams(_event: IpcMainInvokeEvent): Promise<IpcResult<Te
|
||||||
async function handleGetData(
|
async function handleGetData(
|
||||||
_event: IpcMainInvokeEvent,
|
_event: IpcMainInvokeEvent,
|
||||||
teamName: unknown
|
teamName: unknown
|
||||||
): Promise<IpcResult<TeamData>> {
|
): Promise<IpcResult<TeamViewSnapshot>> {
|
||||||
const validated = validateTeamName(teamName);
|
const validated = validateTeamName(teamName);
|
||||||
if (!validated.valid) {
|
if (!validated.valid) {
|
||||||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||||||
}
|
}
|
||||||
const tn = validated.value!;
|
const tn = validated.value!;
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
let data: TeamData;
|
let data: TeamViewSnapshot;
|
||||||
setCurrentMainOp('team:getData');
|
setCurrentMainOp('team:getData');
|
||||||
try {
|
try {
|
||||||
// Prefer worker thread to keep main event loop responsive
|
// Prefer worker thread to keep main event loop responsive
|
||||||
|
|
@ -721,9 +818,11 @@ async function handleGetData(
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`[teams:getData] worker failed, falling back: ${workerErr instanceof Error ? workerErr.message : workerErr}`
|
`[teams:getData] worker failed, falling back: ${workerErr instanceof Error ? workerErr.message : workerErr}`
|
||||||
);
|
);
|
||||||
|
noteHeavyTeamDataWorkerFallback('teams:getData');
|
||||||
data = await getTeamDataService().getTeamData(tn);
|
data = await getTeamDataService().getTeamData(tn);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
noteHeavyTeamDataWorkerFallback('teams:getData');
|
||||||
data = await getTeamDataService().getTeamData(tn);
|
data = await getTeamDataService().getTeamData(tn);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -759,95 +858,52 @@ async function handleGetData(
|
||||||
}
|
}
|
||||||
const provisioning = getTeamProvisioningService();
|
const provisioning = getTeamProvisioningService();
|
||||||
const isAlive = provisioning.isTeamAlive(tn);
|
const isAlive = provisioning.isTeamAlive(tn);
|
||||||
|
const currentLeadSessionId = provisioning.getCurrentLeadSessionId(tn);
|
||||||
|
|
||||||
const displayName = data.config.name || tn;
|
const displayName = data.config.name || tn;
|
||||||
const projectPath = data.config.projectPath;
|
const projectPath = data.config.projectPath;
|
||||||
|
|
||||||
const live = provisioning.getLiveLeadProcessMessages(tn);
|
const live = provisioning.getLiveLeadProcessMessages(tn);
|
||||||
|
const durableMessages = Array.isArray((data as { messages?: unknown }).messages)
|
||||||
|
? ((data as { messages?: typeof live }).messages ?? [])
|
||||||
|
: [];
|
||||||
|
|
||||||
if (live.length === 0) {
|
if (live.length === 0) {
|
||||||
checkRateLimitMessages(data.messages, tn, displayName, projectPath);
|
if (durableMessages.length > 0) {
|
||||||
checkApiErrorMessages(data.messages, tn, displayName, projectPath);
|
checkRateLimitMessages(
|
||||||
|
durableMessages,
|
||||||
|
tn,
|
||||||
|
displayName,
|
||||||
|
projectPath,
|
||||||
|
isAlive,
|
||||||
|
currentLeadSessionId
|
||||||
|
);
|
||||||
|
checkApiErrorMessages(durableMessages, tn, displayName, projectPath);
|
||||||
|
} else {
|
||||||
|
scanTeamMessageNotifications(live, tn, displayName, projectPath);
|
||||||
|
}
|
||||||
return { success: true, data: { ...data, isAlive } };
|
return { success: true, data: { ...data, isAlive } };
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n');
|
let merged = mergeLiveLeadProcessMessages(durableMessages, live);
|
||||||
const isLeadThoughtLike = (msg: { source?: unknown; to?: string }): boolean =>
|
if (durableMessages.length >= 50) {
|
||||||
!msg.to && (msg.source === 'lead_process' || msg.source === 'lead_session');
|
try {
|
||||||
const getLeadThoughtFingerprint = (msg: {
|
const newestPage = await teamDataService.getMessagesPage(tn, {
|
||||||
from: string;
|
limit: 50,
|
||||||
text: string;
|
liveMessages: live,
|
||||||
leadSessionId?: string;
|
});
|
||||||
}): string => `${msg.leadSessionId ?? ''}\0${msg.from}\0${normalizeText(msg.text)}`;
|
merged = newestPage.messages;
|
||||||
|
} catch (error) {
|
||||||
// Collect fingerprints only for thought-like lead messages. Include leadSessionId so a
|
logger.warn(
|
||||||
// repeated thought in a new session does not get collapsed into an old session's history.
|
`[teams:getData] failed to rebuild newest merged messages for ${tn}: ${
|
||||||
const existingTextFingerprints = new Set<string>();
|
error instanceof Error ? error.message : String(error)
|
||||||
for (const msg of data.messages) {
|
}`
|
||||||
if (typeof msg.from !== 'string' || typeof msg.text !== 'string') continue;
|
);
|
||||||
if (!isLeadThoughtLike(msg)) continue;
|
}
|
||||||
existingTextFingerprints.add(getLeadThoughtFingerprint(msg));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const keyFor = (m: {
|
checkRateLimitMessages(merged, tn, displayName, projectPath, isAlive, currentLeadSessionId);
|
||||||
messageId?: string;
|
|
||||||
timestamp: string;
|
|
||||||
from: string;
|
|
||||||
text: string;
|
|
||||||
}): string => {
|
|
||||||
if (typeof m.messageId === 'string' && m.messageId.trim().length > 0) {
|
|
||||||
return m.messageId;
|
|
||||||
}
|
|
||||||
return `${m.timestamp}\0${m.from}\0${(m.text ?? '').slice(0, 80)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Text-based fingerprints for live lead thoughts to catch duplicates with different
|
|
||||||
// messageIds inside the same session (e.g. lead-turn-* re-emits).
|
|
||||||
const leadProcessTextFingerprints = new Set<string>();
|
|
||||||
|
|
||||||
// Content-based dedup for SendMessage captures: Claude Code CLI and our
|
|
||||||
// persistInboxMessage both write to inboxes/{member}.json, producing two entries
|
|
||||||
// with identical content but different messageIds. Track content fingerprints
|
|
||||||
// (from+to+text) with timestamps to collapse them within a 5-second window.
|
|
||||||
const contentSeen = new Map<string, number>(); // fingerprint → timestamp ms
|
|
||||||
|
|
||||||
const merged: typeof data.messages = [];
|
|
||||||
const seen = new Set<string>();
|
|
||||||
for (const msg of [...data.messages, ...live]) {
|
|
||||||
if ((msg as { source?: unknown }).source === 'lead_process' && !msg.to) {
|
|
||||||
const fp = getLeadThoughtFingerprint(msg);
|
|
||||||
// Skip if the same thought already exists in persisted history for the same session.
|
|
||||||
if (existingTextFingerprints.has(fp)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Dedup live lead_process thoughts with the same text in the same session.
|
|
||||||
if (leadProcessTextFingerprints.has(fp)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
leadProcessTextFingerprints.add(fp);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Content dedup for directed messages (SendMessage captures):
|
|
||||||
// same from+to+text within 5 seconds = duplicate from CLI + our persist.
|
|
||||||
if (typeof msg.to === 'string' && msg.to.trim().length > 0) {
|
|
||||||
const contentFp = `${msg.from}\0${msg.to}\0${(msg.text ?? '').replace(/\s+/g, ' ').slice(0, 100)}`;
|
|
||||||
const msgMs = Date.parse(msg.timestamp);
|
|
||||||
const existingMs = contentSeen.get(contentFp);
|
|
||||||
if (existingMs !== undefined && Math.abs(msgMs - existingMs) <= 5000) {
|
|
||||||
continue; // duplicate within 5s window — skip
|
|
||||||
}
|
|
||||||
contentSeen.set(contentFp, msgMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = keyFor(msg);
|
|
||||||
if (seen.has(key)) continue;
|
|
||||||
seen.add(key);
|
|
||||||
merged.push(msg);
|
|
||||||
}
|
|
||||||
merged.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
|
|
||||||
|
|
||||||
checkRateLimitMessages(merged, tn, displayName, projectPath);
|
|
||||||
checkApiErrorMessages(merged, tn, displayName, projectPath);
|
checkApiErrorMessages(merged, tn, displayName, projectPath);
|
||||||
return { success: true, data: { ...data, isAlive, messages: merged } };
|
return { success: true, data: { ...data, isAlive } };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleGetTaskChangePresence(
|
async function handleGetTaskChangePresence(
|
||||||
|
|
@ -917,6 +973,28 @@ async function handleSetToolActivityTracking(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleSetTaskLogStreamTracking(
|
||||||
|
_event: IpcMainInvokeEvent,
|
||||||
|
teamName: unknown,
|
||||||
|
enabled: unknown
|
||||||
|
): Promise<IpcResult<void>> {
|
||||||
|
const validated = validateTeamName(teamName);
|
||||||
|
if (!validated.valid) {
|
||||||
|
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||||||
|
}
|
||||||
|
if (typeof enabled !== 'boolean') {
|
||||||
|
return { success: false, error: 'enabled must be a boolean' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return wrapTeamHandler('setTaskLogStreamTracking', async () => {
|
||||||
|
if (enabled) {
|
||||||
|
await getTeamLogSourceTracker().enableTracking(validated.value!, 'task_log_stream');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await getTeamLogSourceTracker().disableTracking(validated.value!, 'task_log_stream');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function handleDeleteTeam(
|
async function handleDeleteTeam(
|
||||||
_event: IpcMainInvokeEvent,
|
_event: IpcMainInvokeEvent,
|
||||||
teamName: unknown
|
teamName: unknown
|
||||||
|
|
@ -926,6 +1004,7 @@ async function handleDeleteTeam(
|
||||||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||||||
}
|
}
|
||||||
return wrapTeamHandler('deleteTeam', async () => {
|
return wrapTeamHandler('deleteTeam', async () => {
|
||||||
|
getAutoResumeService().cancelPendingAutoResume(validated.value!);
|
||||||
getTeamProvisioningService().stopTeam(validated.value!);
|
getTeamProvisioningService().stopTeam(validated.value!);
|
||||||
await getTeamDataService().deleteTeam(validated.value!);
|
await getTeamDataService().deleteTeam(validated.value!);
|
||||||
});
|
});
|
||||||
|
|
@ -951,6 +1030,7 @@ async function handlePermanentlyDeleteTeam(
|
||||||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||||||
}
|
}
|
||||||
return wrapTeamHandler('permanentlyDeleteTeam', async () => {
|
return wrapTeamHandler('permanentlyDeleteTeam', async () => {
|
||||||
|
getAutoResumeService().cancelPendingAutoResume(validated.value!);
|
||||||
await getTeamDataService().permanentlyDeleteTeam(validated.value!);
|
await getTeamDataService().permanentlyDeleteTeam(validated.value!);
|
||||||
// Clean up app-owned data (attachments, task-attachments) that lives outside ~/.claude/
|
// Clean up app-owned data (attachments, task-attachments) that lives outside ~/.claude/
|
||||||
const appData = getAppDataPath();
|
const appData = getAppDataPath();
|
||||||
|
|
@ -1724,16 +1804,89 @@ async function handleGetMessagesPage(
|
||||||
return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||||||
}
|
}
|
||||||
const opts = (options && typeof options === 'object' ? options : {}) as {
|
const opts = (options && typeof options === 'object' ? options : {}) as {
|
||||||
beforeTimestamp?: string;
|
cursor?: string | null;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
};
|
};
|
||||||
const limit = Math.min(Math.max(1, opts.limit ?? 50), 200);
|
const limit = Math.min(Math.max(1, opts.limit ?? 50), 200);
|
||||||
const beforeTimestamp =
|
const cursor =
|
||||||
typeof opts.beforeTimestamp === 'string' ? opts.beforeTimestamp : undefined;
|
typeof opts.cursor === 'string' ? opts.cursor : opts.cursor === null ? null : undefined;
|
||||||
|
|
||||||
return wrapTeamHandler('getMessagesPage', async () => {
|
return wrapTeamHandler('getMessagesPage', async () => {
|
||||||
const service = getTeamDataService();
|
let page: MessagesPage;
|
||||||
return service.getMessagesPage(vTeam.value!, { beforeTimestamp, limit });
|
const notificationContext = await getTeamDataService().getTeamNotificationContext(vTeam.value!);
|
||||||
|
const liveMessages =
|
||||||
|
cursor == null ? getTeamProvisioningService().getLiveLeadProcessMessages(vTeam.value!) : [];
|
||||||
|
|
||||||
|
if (liveMessages.length > 0) {
|
||||||
|
page = await getTeamDataService().getMessagesPage(vTeam.value!, {
|
||||||
|
cursor,
|
||||||
|
limit,
|
||||||
|
liveMessages,
|
||||||
|
});
|
||||||
|
scanTeamMessageNotifications(
|
||||||
|
page.messages,
|
||||||
|
vTeam.value!,
|
||||||
|
notificationContext.displayName,
|
||||||
|
notificationContext.projectPath
|
||||||
|
);
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
const worker = getTeamDataWorkerClient();
|
||||||
|
if (worker.isAvailable()) {
|
||||||
|
try {
|
||||||
|
page = await worker.getMessagesPage(vTeam.value!, { cursor, limit });
|
||||||
|
scanTeamMessageNotifications(
|
||||||
|
page.messages,
|
||||||
|
vTeam.value!,
|
||||||
|
notificationContext.displayName,
|
||||||
|
notificationContext.projectPath
|
||||||
|
);
|
||||||
|
return page;
|
||||||
|
} catch (workerErr) {
|
||||||
|
logger.warn(
|
||||||
|
`[teams:getMessagesPage] worker failed, falling back: ${
|
||||||
|
workerErr instanceof Error ? workerErr.message : workerErr
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
noteHeavyTeamDataWorkerFallback('teams:getMessagesPage');
|
||||||
|
page = await getTeamDataService().getMessagesPage(vTeam.value!, { cursor, limit });
|
||||||
|
scanTeamMessageNotifications(
|
||||||
|
page.messages,
|
||||||
|
vTeam.value!,
|
||||||
|
notificationContext.displayName,
|
||||||
|
notificationContext.projectPath
|
||||||
|
);
|
||||||
|
return page;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGetMemberActivityMeta(
|
||||||
|
_event: IpcMainInvokeEvent,
|
||||||
|
teamName: unknown
|
||||||
|
): Promise<IpcResult<TeamMemberActivityMeta>> {
|
||||||
|
const vTeam = validateTeamName(teamName);
|
||||||
|
if (!vTeam.valid) {
|
||||||
|
return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return wrapTeamHandler('getMemberActivityMeta', async () => {
|
||||||
|
const worker = getTeamDataWorkerClient();
|
||||||
|
if (worker.isAvailable()) {
|
||||||
|
try {
|
||||||
|
return await worker.getMemberActivityMeta(vTeam.value!);
|
||||||
|
} catch (workerErr) {
|
||||||
|
logger.warn(
|
||||||
|
`[teams:getMemberActivityMeta] worker failed, falling back: ${
|
||||||
|
workerErr instanceof Error ? workerErr.message : workerErr
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
noteHeavyTeamDataWorkerFallback('teams:getMemberActivityMeta');
|
||||||
|
return getTeamDataService().getMemberActivityMeta(vTeam.value!);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2603,6 +2756,24 @@ async function handleGetTaskLogStream(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleGetTaskLogStreamSummary(
|
||||||
|
_event: IpcMainInvokeEvent,
|
||||||
|
teamName: unknown,
|
||||||
|
taskId: unknown
|
||||||
|
): Promise<IpcResult<BoardTaskLogStreamSummary>> {
|
||||||
|
const vTeam = validateTeamName(teamName);
|
||||||
|
if (!vTeam.valid) {
|
||||||
|
return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||||||
|
}
|
||||||
|
const vTask = validateTaskId(taskId);
|
||||||
|
if (!vTask.valid) {
|
||||||
|
return { success: false, error: vTask.error ?? 'Invalid taskId' };
|
||||||
|
}
|
||||||
|
return wrapTeamHandler('getTaskLogStreamSummary', () =>
|
||||||
|
getBoardTaskLogStreamService().getTaskLogStreamSummary(vTeam.value!, vTask.value!)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleGetTaskExactLogSummaries(
|
async function handleGetTaskExactLogSummaries(
|
||||||
_event: IpcMainInvokeEvent,
|
_event: IpcMainInvokeEvent,
|
||||||
teamName: unknown,
|
teamName: unknown,
|
||||||
|
|
@ -2723,6 +2894,37 @@ async function handleMemberSpawnStatuses(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleGetAgentRuntime(
|
||||||
|
_event: IpcMainInvokeEvent,
|
||||||
|
teamName: unknown
|
||||||
|
): Promise<IpcResult<TeamAgentRuntimeSnapshot>> {
|
||||||
|
const validated = validateTeamName(teamName);
|
||||||
|
if (!validated.valid) {
|
||||||
|
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||||||
|
}
|
||||||
|
return wrapTeamHandler('getAgentRuntime', async () =>
|
||||||
|
getTeamProvisioningService().getTeamAgentRuntimeSnapshot(validated.value!)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRestartMember(
|
||||||
|
_event: IpcMainInvokeEvent,
|
||||||
|
teamName: unknown,
|
||||||
|
memberName: unknown
|
||||||
|
): Promise<IpcResult<void>> {
|
||||||
|
const validatedTeamName = validateTeamName(teamName);
|
||||||
|
if (!validatedTeamName.valid) {
|
||||||
|
return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' };
|
||||||
|
}
|
||||||
|
const validatedMemberName = validateMemberName(memberName);
|
||||||
|
if (!validatedMemberName.valid) {
|
||||||
|
return { success: false, error: validatedMemberName.error ?? 'Invalid memberName' };
|
||||||
|
}
|
||||||
|
return wrapTeamHandler('restartMember', async () =>
|
||||||
|
getTeamProvisioningService().restartMember(validatedTeamName.value!, validatedMemberName.value!)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleStopTeam(
|
async function handleStopTeam(
|
||||||
_event: IpcMainInvokeEvent,
|
_event: IpcMainInvokeEvent,
|
||||||
teamName: unknown
|
teamName: unknown
|
||||||
|
|
@ -2733,6 +2935,7 @@ async function handleStopTeam(
|
||||||
}
|
}
|
||||||
return wrapTeamHandler('stop', async () => {
|
return wrapTeamHandler('stop', async () => {
|
||||||
addMainBreadcrumb('team', 'stop', { teamName: validated.value! });
|
addMainBreadcrumb('team', 'stop', { teamName: validated.value! });
|
||||||
|
getAutoResumeService().cancelPendingAutoResume(validated.value!);
|
||||||
getTeamProvisioningService().stopTeam(validated.value!);
|
getTeamProvisioningService().stopTeam(validated.value!);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,7 @@ export class ApiKeyService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!request.value) throw new Error('Key value is required');
|
if (!request.value) throw new Error('Key value is required');
|
||||||
if (request.scope === 'project' && (!request.projectPath || !request.projectPath.trim())) {
|
if (request.scope === 'project' && !request.projectPath?.trim()) {
|
||||||
throw new Error('Project-scoped API keys require a project path');
|
throw new Error('Project-scoped API keys require a project path');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ async function buildManagementCliEnvForBinary(binaryPath: string): Promise<NodeJ
|
||||||
});
|
});
|
||||||
return env;
|
return env;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExtensionsRuntimeAdapter {
|
export interface ExtensionsRuntimeAdapter {
|
||||||
readonly flavor: CliFlavor;
|
readonly flavor: CliFlavor;
|
||||||
buildManagementCliEnv(binaryPath: string): Promise<NodeJS.ProcessEnv>;
|
buildManagementCliEnv(binaryPath: string): Promise<NodeJS.ProcessEnv>;
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,6 @@ function isSensitiveCliFlag(flag: string): boolean {
|
||||||
const normalizedFlag = flag.toLowerCase().replace(/^--/, '').replace(/[-_]/g, '');
|
const normalizedFlag = flag.toLowerCase().replace(/^--/, '').replace(/[-_]/g, '');
|
||||||
return SENSITIVE_FLAG_NAMES.has(normalizedFlag);
|
return SENSITIVE_FLAG_NAMES.has(normalizedFlag);
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractJsonObject<T>(raw: string): T {
|
function extractJsonObject<T>(raw: string): T {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,12 @@ export interface NotificationConfig {
|
||||||
notifyOnTeamLaunched: boolean;
|
notifyOnTeamLaunched: boolean;
|
||||||
/** Whether to show native OS notifications when a tool needs user approval */
|
/** Whether to show native OS notifications when a tool needs user approval */
|
||||||
notifyOnToolApproval: boolean;
|
notifyOnToolApproval: boolean;
|
||||||
|
/** Whether to automatically resume a rate-limited team when the limit resets.
|
||||||
|
* When enabled, the app parses the reset time from Claude's rate-limit
|
||||||
|
* message and schedules a nudge to the team lead once the limit expires.
|
||||||
|
* Default is `false` — opt-in to avoid unexpected API usage after the reset.
|
||||||
|
*/
|
||||||
|
autoResumeOnRateLimit: boolean;
|
||||||
/** Only notify on status changes in solo teams (no teammates) */
|
/** Only notify on status changes in solo teams (no teammates) */
|
||||||
statusChangeOnlySolo: boolean;
|
statusChangeOnlySolo: boolean;
|
||||||
/** Which target statuses to notify about (e.g. ['in_progress', 'completed']) */
|
/** Which target statuses to notify about (e.g. ['in_progress', 'completed']) */
|
||||||
|
|
@ -306,6 +312,7 @@ const DEFAULT_CONFIG: AppConfig = {
|
||||||
notifyOnCrossTeamMessage: true,
|
notifyOnCrossTeamMessage: true,
|
||||||
notifyOnTeamLaunched: true,
|
notifyOnTeamLaunched: true,
|
||||||
notifyOnToolApproval: true,
|
notifyOnToolApproval: true,
|
||||||
|
autoResumeOnRateLimit: false,
|
||||||
statusChangeOnlySolo: false,
|
statusChangeOnlySolo: false,
|
||||||
statusChangeStatuses: ['in_progress', 'completed'],
|
statusChangeStatuses: ['in_progress', 'completed'],
|
||||||
triggers: DEFAULT_TRIGGERS,
|
triggers: DEFAULT_TRIGGERS,
|
||||||
|
|
@ -502,8 +509,55 @@ export class ConfigManager {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
notifications: {
|
notifications: {
|
||||||
...DEFAULT_CONFIG.notifications,
|
enabled: loadedNotifications.enabled ?? DEFAULT_CONFIG.notifications.enabled,
|
||||||
...loadedNotifications,
|
soundEnabled: loadedNotifications.soundEnabled ?? DEFAULT_CONFIG.notifications.soundEnabled,
|
||||||
|
ignoredRegex: loadedNotifications.ignoredRegex ?? DEFAULT_CONFIG.notifications.ignoredRegex,
|
||||||
|
ignoredRepositories:
|
||||||
|
loadedNotifications.ignoredRepositories ??
|
||||||
|
DEFAULT_CONFIG.notifications.ignoredRepositories,
|
||||||
|
snoozedUntil: loadedNotifications.snoozedUntil ?? DEFAULT_CONFIG.notifications.snoozedUntil,
|
||||||
|
snoozeMinutes:
|
||||||
|
loadedNotifications.snoozeMinutes ?? DEFAULT_CONFIG.notifications.snoozeMinutes,
|
||||||
|
includeSubagentErrors:
|
||||||
|
loadedNotifications.includeSubagentErrors ??
|
||||||
|
DEFAULT_CONFIG.notifications.includeSubagentErrors,
|
||||||
|
notifyOnLeadInbox:
|
||||||
|
loadedNotifications.notifyOnLeadInbox ?? DEFAULT_CONFIG.notifications.notifyOnLeadInbox,
|
||||||
|
notifyOnUserInbox:
|
||||||
|
loadedNotifications.notifyOnUserInbox ?? DEFAULT_CONFIG.notifications.notifyOnUserInbox,
|
||||||
|
notifyOnClarifications:
|
||||||
|
loadedNotifications.notifyOnClarifications ??
|
||||||
|
DEFAULT_CONFIG.notifications.notifyOnClarifications,
|
||||||
|
notifyOnStatusChange:
|
||||||
|
loadedNotifications.notifyOnStatusChange ??
|
||||||
|
DEFAULT_CONFIG.notifications.notifyOnStatusChange,
|
||||||
|
notifyOnTaskComments:
|
||||||
|
loadedNotifications.notifyOnTaskComments ??
|
||||||
|
DEFAULT_CONFIG.notifications.notifyOnTaskComments,
|
||||||
|
notifyOnTaskCreated:
|
||||||
|
loadedNotifications.notifyOnTaskCreated ??
|
||||||
|
DEFAULT_CONFIG.notifications.notifyOnTaskCreated,
|
||||||
|
notifyOnAllTasksCompleted:
|
||||||
|
loadedNotifications.notifyOnAllTasksCompleted ??
|
||||||
|
DEFAULT_CONFIG.notifications.notifyOnAllTasksCompleted,
|
||||||
|
notifyOnCrossTeamMessage:
|
||||||
|
loadedNotifications.notifyOnCrossTeamMessage ??
|
||||||
|
DEFAULT_CONFIG.notifications.notifyOnCrossTeamMessage,
|
||||||
|
notifyOnTeamLaunched:
|
||||||
|
loadedNotifications.notifyOnTeamLaunched ??
|
||||||
|
DEFAULT_CONFIG.notifications.notifyOnTeamLaunched,
|
||||||
|
notifyOnToolApproval:
|
||||||
|
loadedNotifications.notifyOnToolApproval ??
|
||||||
|
DEFAULT_CONFIG.notifications.notifyOnToolApproval,
|
||||||
|
autoResumeOnRateLimit:
|
||||||
|
loadedNotifications.autoResumeOnRateLimit ??
|
||||||
|
DEFAULT_CONFIG.notifications.autoResumeOnRateLimit,
|
||||||
|
statusChangeOnlySolo:
|
||||||
|
loadedNotifications.statusChangeOnlySolo ??
|
||||||
|
DEFAULT_CONFIG.notifications.statusChangeOnlySolo,
|
||||||
|
statusChangeStatuses:
|
||||||
|
loadedNotifications.statusChangeStatuses ??
|
||||||
|
DEFAULT_CONFIG.notifications.statusChangeStatuses,
|
||||||
triggers: mergedTriggers,
|
triggers: mergedTriggers,
|
||||||
},
|
},
|
||||||
general: mergedGeneral,
|
general: mergedGeneral,
|
||||||
|
|
|
||||||
|
|
@ -543,10 +543,10 @@ export class NotificationManager extends EventEmitter {
|
||||||
logger.debug(`[test-notification] creating Notification (platform=${process.platform})`);
|
logger.debug(`[test-notification] creating Notification (platform=${process.platform})`);
|
||||||
const notification = new NotificationClass({
|
const notification = new NotificationClass({
|
||||||
title: 'Test Notification',
|
title: 'Test Notification',
|
||||||
...(isMac ? { subtitle: 'Claude Agent Teams UI' } : {}),
|
...(isMac ? { subtitle: 'Agent Teams UI' } : {}),
|
||||||
body: isMac
|
body: isMac
|
||||||
? 'Notifications are working correctly!'
|
? 'Notifications are working correctly!'
|
||||||
: 'Claude Agent Teams UI\nNotifications are working correctly!',
|
: 'Agent Teams UI\nNotifications are working correctly!',
|
||||||
...(iconPath ? { icon: iconPath } : {}),
|
...(iconPath ? { icon: iconPath } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -161,7 +161,7 @@ function classifyFailedProbe(
|
||||||
|
|
||||||
export class CliProviderModelAvailabilityService {
|
export class CliProviderModelAvailabilityService {
|
||||||
private readonly cache = new Map<string, ProviderModelAvailabilityCacheEntry>();
|
private readonly cache = new Map<string, ProviderModelAvailabilityCacheEntry>();
|
||||||
private readonly queue: Array<() => void> = [];
|
private readonly queue: (() => void)[] = [];
|
||||||
private activeProbeCount = 0;
|
private activeProbeCount = 0;
|
||||||
|
|
||||||
constructor(private readonly onUpdate?: ProviderAvailabilityUpdateHandler) {}
|
constructor(private readonly onUpdate?: ProviderAvailabilityUpdateHandler) {}
|
||||||
|
|
|
||||||
209
src/main/services/team/AutoResumeService.ts
Normal file
209
src/main/services/team/AutoResumeService.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
import { parseRateLimitResetTime } from '@shared/utils/rateLimitDetector';
|
||||||
|
|
||||||
|
import { ConfigManager } from '../infrastructure/ConfigManager';
|
||||||
|
|
||||||
|
import type { TeamProvisioningService } from './TeamProvisioningService';
|
||||||
|
|
||||||
|
const logger = createLogger('Service:AutoResume');
|
||||||
|
|
||||||
|
const AUTO_RESUME_BUFFER_MS = 30 * 1000;
|
||||||
|
const AUTO_RESUME_MAX_DELAY_MS = 12 * 60 * 60 * 1000;
|
||||||
|
const AUTO_RESUME_HISTORY_FRESH_MS = 5 * 1000;
|
||||||
|
const AUTO_RESUME_MESSAGE =
|
||||||
|
'Your rate limit has reset. Please resume the work you were doing before the limit was hit.';
|
||||||
|
|
||||||
|
interface PendingAutoResumeEntry {
|
||||||
|
timer: NodeJS.Timeout;
|
||||||
|
fireAtMs: number;
|
||||||
|
sourceMessageAtMs: number;
|
||||||
|
sourceRunId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AutoResumeProvisioning = Pick<
|
||||||
|
TeamProvisioningService,
|
||||||
|
'getCurrentRunId' | 'isTeamAlive' | 'sendMessageToTeam'
|
||||||
|
>;
|
||||||
|
type AutoResumeConfigReader = Pick<ConfigManager, 'getConfig'>;
|
||||||
|
|
||||||
|
export class AutoResumeService {
|
||||||
|
private readonly pendingTimers = new Map<string, PendingAutoResumeEntry>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly provisioningService: AutoResumeProvisioning,
|
||||||
|
private readonly configManager: AutoResumeConfigReader = ConfigManager.getInstance()
|
||||||
|
) {}
|
||||||
|
|
||||||
|
handleRateLimitMessage(
|
||||||
|
teamName: string,
|
||||||
|
messageText: string,
|
||||||
|
observedAt: Date = new Date(),
|
||||||
|
messageTimestamp: Date = observedAt
|
||||||
|
): void {
|
||||||
|
const cfg = this.configManager.getConfig();
|
||||||
|
if (!cfg.notifications.autoResumeOnRateLimit) return;
|
||||||
|
|
||||||
|
const observedAtMs = observedAt.getTime();
|
||||||
|
const messageAtMs = Number.isFinite(messageTimestamp.getTime())
|
||||||
|
? messageTimestamp.getTime()
|
||||||
|
: observedAtMs;
|
||||||
|
const parseReferenceTime = Number.isFinite(messageTimestamp.getTime())
|
||||||
|
? messageTimestamp
|
||||||
|
: observedAt;
|
||||||
|
|
||||||
|
const resetTime = parseRateLimitResetTime(messageText, parseReferenceTime);
|
||||||
|
if (!resetTime) {
|
||||||
|
logger.info(
|
||||||
|
`[auto-resume] Rate limit detected for "${teamName}" but reset time was not parseable - skipping auto-resume`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetAtMs = resetTime.getTime();
|
||||||
|
const rawDelayMs = resetAtMs - observedAtMs;
|
||||||
|
const targetFireAtMs = resetAtMs + AUTO_RESUME_BUFFER_MS;
|
||||||
|
const messageAgeMs = Math.max(0, observedAtMs - messageAtMs);
|
||||||
|
const existing = this.pendingTimers.get(teamName);
|
||||||
|
const sourceRunId = this.provisioningService.getCurrentRunId(teamName);
|
||||||
|
|
||||||
|
if (existing && messageAtMs < existing.sourceMessageAtMs) {
|
||||||
|
logger.info(
|
||||||
|
`[auto-resume] Ignoring older rate-limit message for "${teamName}" because a newer timer is already pending`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetFireAtMs <= observedAtMs && messageAgeMs > AUTO_RESUME_HISTORY_FRESH_MS) {
|
||||||
|
logger.info(
|
||||||
|
`[auto-resume] Parsed reset time for "${teamName}" passed its buffered fire deadline ${Math.round((observedAtMs - targetFireAtMs) / 1000)}s ago - skipping stale history replay`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rawDelayMs < 0) {
|
||||||
|
logger.warn(
|
||||||
|
`[auto-resume] Parsed reset time for "${teamName}" is ${Math.round(-rawDelayMs / 1000)}s in the past - using remaining buffered delay`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const delayMs = Math.max(0, targetFireAtMs - observedAtMs);
|
||||||
|
const fireAtMs = observedAtMs + delayMs;
|
||||||
|
|
||||||
|
if (delayMs > AUTO_RESUME_MAX_DELAY_MS) {
|
||||||
|
if (existing) {
|
||||||
|
clearTimeout(existing.timer);
|
||||||
|
this.pendingTimers.delete(teamName);
|
||||||
|
}
|
||||||
|
logger.warn(
|
||||||
|
`[auto-resume] Parsed reset time for "${teamName}" is ${Math.round(delayMs / 60000)}m away - exceeds ceiling, skipping`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
existing?.fireAtMs === fireAtMs &&
|
||||||
|
existing.sourceMessageAtMs === messageAtMs &&
|
||||||
|
existing.sourceRunId === sourceRunId
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
clearTimeout(existing.timer);
|
||||||
|
this.pendingTimers.delete(teamName);
|
||||||
|
logger.info(
|
||||||
|
`[auto-resume] Rescheduling resume for "${teamName}" to ${resetTime.toISOString()}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.info(
|
||||||
|
`[auto-resume] Scheduling resume for "${teamName}" at ${resetTime.toISOString()} (in ${Math.round(delayMs / 1000)}s)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.pendingTimers.delete(teamName);
|
||||||
|
void this.fireResumeNudge(teamName, sourceRunId);
|
||||||
|
}, delayMs);
|
||||||
|
|
||||||
|
this.pendingTimers.set(teamName, {
|
||||||
|
timer,
|
||||||
|
fireAtMs,
|
||||||
|
sourceMessageAtMs: messageAtMs,
|
||||||
|
sourceRunId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelPendingAutoResume(teamName: string): void {
|
||||||
|
const pending = this.pendingTimers.get(teamName);
|
||||||
|
if (!pending) return;
|
||||||
|
clearTimeout(pending.timer);
|
||||||
|
this.pendingTimers.delete(teamName);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearAllPendingAutoResume(): void {
|
||||||
|
for (const pending of this.pendingTimers.values()) {
|
||||||
|
clearTimeout(pending.timer);
|
||||||
|
}
|
||||||
|
this.pendingTimers.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fireResumeNudge(teamName: string, sourceRunId: string | null): Promise<void> {
|
||||||
|
const current = this.configManager.getConfig();
|
||||||
|
if (!current.notifications.autoResumeOnRateLimit) {
|
||||||
|
logger.info(
|
||||||
|
`[auto-resume] Config flag was disabled while timer was pending - skipping nudge for "${teamName}"`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!this.provisioningService.isTeamAlive(teamName)) {
|
||||||
|
logger.info(
|
||||||
|
`[auto-resume] Team "${teamName}" is no longer alive at fire time - skipping resume nudge`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentRunId = this.provisioningService.getCurrentRunId(teamName);
|
||||||
|
if (sourceRunId && currentRunId !== sourceRunId) {
|
||||||
|
logger.info(
|
||||||
|
`[auto-resume] Team "${teamName}" advanced from run "${sourceRunId}" to "${currentRunId ?? 'none'}" before fire time - skipping stale resume nudge`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.provisioningService.sendMessageToTeam(teamName, AUTO_RESUME_MESSAGE);
|
||||||
|
logger.info(`[auto-resume] Sent resume nudge to "${teamName}"`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`[auto-resume] Failed to send resume nudge to "${teamName}": ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let autoResumeService: AutoResumeService | null = null;
|
||||||
|
|
||||||
|
export function initializeAutoResumeService(
|
||||||
|
provisioningService: AutoResumeProvisioning
|
||||||
|
): AutoResumeService {
|
||||||
|
autoResumeService?.clearAllPendingAutoResume();
|
||||||
|
autoResumeService = new AutoResumeService(provisioningService);
|
||||||
|
return autoResumeService;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAutoResumeService(): AutoResumeService {
|
||||||
|
if (!autoResumeService) {
|
||||||
|
throw new Error('AutoResumeService is not initialized');
|
||||||
|
}
|
||||||
|
return autoResumeService;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function peekAutoResumeService(): AutoResumeService | null {
|
||||||
|
return autoResumeService;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAutoResumeService(): void {
|
||||||
|
autoResumeService?.clearAllPendingAutoResume();
|
||||||
|
autoResumeService = null;
|
||||||
|
}
|
||||||
128
src/main/services/team/MemberActivityMetaService.ts
Normal file
128
src/main/services/team/MemberActivityMetaService.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
import type { TeamMessageFeedService } from './TeamMessageFeedService';
|
||||||
|
import type { InboxMessage, MemberActivityMetaEntry, TeamMemberActivityMeta } from '@shared/types';
|
||||||
|
|
||||||
|
interface MemberActivityMetaCacheEntry {
|
||||||
|
feedRevision: string;
|
||||||
|
meta: TeamMemberActivityMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageSignalsTermination(message: InboxMessage | null | undefined): boolean {
|
||||||
|
if (!message) return false;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(message.text) as {
|
||||||
|
type?: string;
|
||||||
|
approve?: boolean;
|
||||||
|
approved?: boolean;
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
(parsed.type === 'shutdown_response' &&
|
||||||
|
(parsed.approve === true || parsed.approved === true)) ||
|
||||||
|
parsed.type === 'shutdown_approved'
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function areMemberActivityEntriesEqual(
|
||||||
|
left: MemberActivityMetaEntry | undefined,
|
||||||
|
right: MemberActivityMetaEntry
|
||||||
|
): boolean {
|
||||||
|
if (!left) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
left.memberName === right.memberName &&
|
||||||
|
left.lastAuthoredMessageAt === right.lastAuthoredMessageAt &&
|
||||||
|
left.messageCountExact === right.messageCountExact &&
|
||||||
|
left.latestAuthoredMessageSignalsTermination === right.latestAuthoredMessageSignalsTermination
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function structurallyShareMemberFacts(
|
||||||
|
previous: Record<string, MemberActivityMetaEntry> | undefined,
|
||||||
|
next: Record<string, MemberActivityMetaEntry>
|
||||||
|
): Record<string, MemberActivityMetaEntry> {
|
||||||
|
if (!previous) {
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextKeys = Object.keys(next);
|
||||||
|
const previousKeys = Object.keys(previous);
|
||||||
|
let changed = nextKeys.length !== previousKeys.length;
|
||||||
|
const shared: Record<string, MemberActivityMetaEntry> = {};
|
||||||
|
|
||||||
|
for (const key of nextKeys) {
|
||||||
|
const nextEntry = next[key];
|
||||||
|
const previousEntry = previous[key];
|
||||||
|
if (!areMemberActivityEntriesEqual(previousEntry, nextEntry)) {
|
||||||
|
changed = true;
|
||||||
|
shared[key] = nextEntry;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
shared[key] = previousEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
return changed ? shared : previous;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MemberActivityMetaService {
|
||||||
|
private readonly cacheByTeam = new Map<string, MemberActivityMetaCacheEntry>();
|
||||||
|
|
||||||
|
constructor(private readonly feedService: TeamMessageFeedService) {}
|
||||||
|
|
||||||
|
invalidate(teamName: string): void {
|
||||||
|
this.cacheByTeam.delete(teamName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMeta(teamName: string): Promise<TeamMemberActivityMeta> {
|
||||||
|
const feed = await this.feedService.getFeed(teamName);
|
||||||
|
const cached = this.cacheByTeam.get(teamName);
|
||||||
|
if (cached?.feedRevision === feed.feedRevision) {
|
||||||
|
return cached.meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestByMember = new Map<string, InboxMessage>();
|
||||||
|
const countsByMember = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const message of feed.messages) {
|
||||||
|
const memberName = typeof message.from === 'string' ? message.from.trim() : '';
|
||||||
|
if (!memberName || memberName === 'user' || memberName === 'system') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
countsByMember.set(memberName, (countsByMember.get(memberName) ?? 0) + 1);
|
||||||
|
if (!latestByMember.has(memberName)) {
|
||||||
|
latestByMember.set(memberName, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextMembers = Object.fromEntries(
|
||||||
|
Array.from(new Set([...countsByMember.keys(), ...latestByMember.keys()]))
|
||||||
|
.sort((left, right) => left.localeCompare(right))
|
||||||
|
.map((memberName) => {
|
||||||
|
const latestMessage = latestByMember.get(memberName) ?? null;
|
||||||
|
return [
|
||||||
|
memberName,
|
||||||
|
{
|
||||||
|
memberName,
|
||||||
|
lastAuthoredMessageAt: latestMessage?.timestamp ?? null,
|
||||||
|
messageCountExact: countsByMember.get(memberName) ?? 0,
|
||||||
|
latestAuthoredMessageSignalsTermination: messageSignalsTermination(latestMessage),
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const members = structurallyShareMemberFacts(cached?.meta.members, nextMembers);
|
||||||
|
|
||||||
|
const meta: TeamMemberActivityMeta = {
|
||||||
|
teamName,
|
||||||
|
computedAt: new Date().toISOString(),
|
||||||
|
members,
|
||||||
|
feedRevision: feed.feedRevision,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.cacheByTeam.set(teamName, { feedRevision: feed.feedRevision, meta });
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,5 @@
|
||||||
import { yieldToEventLoop } from '@main/utils/asyncYield';
|
import { yieldToEventLoop } from '@main/utils/asyncYield';
|
||||||
import {
|
import { getClaudeBasePath, getTasksBasePath, getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||||
encodePath,
|
|
||||||
extractBaseDir,
|
|
||||||
getClaudeBasePath,
|
|
||||||
getProjectsBasePath,
|
|
||||||
getTasksBasePath,
|
|
||||||
getTeamsBasePath,
|
|
||||||
} from '@main/utils/pathDecoder';
|
|
||||||
import { killProcessByPid } from '@main/utils/processKill';
|
import { killProcessByPid } from '@main/utils/processKill';
|
||||||
import {
|
import {
|
||||||
AGENT_BLOCK_CLOSE,
|
AGENT_BLOCK_CLOSE,
|
||||||
|
|
@ -16,7 +9,7 @@ import {
|
||||||
} from '@shared/constants/agentBlocks';
|
} from '@shared/constants/agentBlocks';
|
||||||
import { getMemberColorByName } from '@shared/constants/memberColors';
|
import { getMemberColorByName } from '@shared/constants/memberColors';
|
||||||
import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSemantics';
|
import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSemantics';
|
||||||
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/utils/reviewState';
|
import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/utils/reviewState';
|
||||||
import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands';
|
import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands';
|
||||||
|
|
@ -39,6 +32,11 @@ import {
|
||||||
} from './cache/LeadSessionParseCache';
|
} from './cache/LeadSessionParseCache';
|
||||||
import { atomicWriteAsync } from './atomicWrite';
|
import { atomicWriteAsync } from './atomicWrite';
|
||||||
import { extractLeadSessionMessagesFromJsonl } from './leadSessionMessageExtractor';
|
import { extractLeadSessionMessagesFromJsonl } from './leadSessionMessageExtractor';
|
||||||
|
import { MemberActivityMetaService } from './MemberActivityMetaService';
|
||||||
|
import {
|
||||||
|
getLiveLeadProcessMessageKey,
|
||||||
|
mergeLiveLeadProcessMessages,
|
||||||
|
} from './mergeLiveLeadProcessMessages';
|
||||||
import { buildTaskChangePresenceDescriptor } from './taskChangePresenceUtils';
|
import { buildTaskChangePresenceDescriptor } from './taskChangePresenceUtils';
|
||||||
import { TeamConfigReader } from './TeamConfigReader';
|
import { TeamConfigReader } from './TeamConfigReader';
|
||||||
import { TeamInboxReader } from './TeamInboxReader';
|
import { TeamInboxReader } from './TeamInboxReader';
|
||||||
|
|
@ -47,11 +45,13 @@ import { TeamKanbanManager } from './TeamKanbanManager';
|
||||||
import { TeamMemberResolver } from './TeamMemberResolver';
|
import { TeamMemberResolver } from './TeamMemberResolver';
|
||||||
import { TeamMemberRuntimeAdvisoryService } from './TeamMemberRuntimeAdvisoryService';
|
import { TeamMemberRuntimeAdvisoryService } from './TeamMemberRuntimeAdvisoryService';
|
||||||
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
||||||
|
import { TeamMessageFeedService } from './TeamMessageFeedService';
|
||||||
import { TeamMetaStore } from './TeamMetaStore';
|
import { TeamMetaStore } from './TeamMetaStore';
|
||||||
import { TeamSentMessagesStore } from './TeamSentMessagesStore';
|
import { TeamSentMessagesStore } from './TeamSentMessagesStore';
|
||||||
import { TeamTaskCommentNotificationJournal } from './TeamTaskCommentNotificationJournal';
|
import { TeamTaskCommentNotificationJournal } from './TeamTaskCommentNotificationJournal';
|
||||||
import { TeamTaskReader } from './TeamTaskReader';
|
import { TeamTaskReader } from './TeamTaskReader';
|
||||||
import { TeamTaskWriter } from './TeamTaskWriter';
|
import { TeamTaskWriter } from './TeamTaskWriter';
|
||||||
|
import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver';
|
||||||
|
|
||||||
import type { PersistedTaskChangePresenceIndex } from './cache/taskChangePresenceCacheTypes';
|
import type { PersistedTaskChangePresenceIndex } from './cache/taskChangePresenceCacheTypes';
|
||||||
import type { TaskChangePresenceRepository } from './cache/TaskChangePresenceRepository';
|
import type { TaskChangePresenceRepository } from './cache/TaskChangePresenceRepository';
|
||||||
|
|
@ -65,7 +65,6 @@ import type {
|
||||||
KanbanColumnId,
|
KanbanColumnId,
|
||||||
KanbanState,
|
KanbanState,
|
||||||
MessagesPage,
|
MessagesPage,
|
||||||
ResolvedTeamMember,
|
|
||||||
SendMessageRequest,
|
SendMessageRequest,
|
||||||
SendMessageResult,
|
SendMessageResult,
|
||||||
TaskAttachmentMeta,
|
TaskAttachmentMeta,
|
||||||
|
|
@ -74,13 +73,14 @@ import type {
|
||||||
TaskRef,
|
TaskRef,
|
||||||
TeamConfig,
|
TeamConfig,
|
||||||
TeamCreateConfigRequest,
|
TeamCreateConfigRequest,
|
||||||
TeamData,
|
|
||||||
TeamMember,
|
TeamMember,
|
||||||
|
TeamMemberActivityMeta,
|
||||||
TeamProcess,
|
TeamProcess,
|
||||||
TeamSummary,
|
TeamSummary,
|
||||||
TeamTask,
|
TeamTask,
|
||||||
TeamTaskStatus,
|
TeamTaskStatus,
|
||||||
TeamTaskWithKanban,
|
TeamTaskWithKanban,
|
||||||
|
TeamViewSnapshot,
|
||||||
ToolCallMeta,
|
ToolCallMeta,
|
||||||
UpdateKanbanPatch,
|
UpdateKanbanPatch,
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
|
|
@ -98,6 +98,14 @@ const TASK_MAP_YIELD_EVERY = 250;
|
||||||
const TASK_COMMENT_NOTIFICATION_SOURCE = 'system_notification';
|
const TASK_COMMENT_NOTIFICATION_SOURCE = 'system_notification';
|
||||||
const PASSIVE_USER_REPLY_LINK_WINDOW_MS = 15_000;
|
const PASSIVE_USER_REPLY_LINK_WINDOW_MS = 15_000;
|
||||||
|
|
||||||
|
function requireCanonicalMessageId(message: InboxMessage): string {
|
||||||
|
const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : '';
|
||||||
|
if (messageId.length > 0) {
|
||||||
|
return messageId;
|
||||||
|
}
|
||||||
|
throw new Error('Canonical team message is missing effective messageId');
|
||||||
|
}
|
||||||
|
|
||||||
interface EligibleTaskCommentNotification {
|
interface EligibleTaskCommentNotification {
|
||||||
key: string;
|
key: string;
|
||||||
messageId: string;
|
messageId: string;
|
||||||
|
|
@ -162,6 +170,8 @@ export class TeamDataService {
|
||||||
private taskChangePresenceRepository: TaskChangePresenceRepository | null = null;
|
private taskChangePresenceRepository: TaskChangePresenceRepository | null = null;
|
||||||
private teamLogSourceTracker: TeamLogSourceTracker | null = null;
|
private teamLogSourceTracker: TeamLogSourceTracker | null = null;
|
||||||
private fileWatchReconcileDiagnostics = new Map<string, FileWatchReconcileDiagnostics>();
|
private fileWatchReconcileDiagnostics = new Map<string, FileWatchReconcileDiagnostics>();
|
||||||
|
private readonly messageFeedService: TeamMessageFeedService;
|
||||||
|
private readonly memberActivityMetaService: MemberActivityMetaService;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
|
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
|
||||||
|
|
@ -182,8 +192,19 @@ export class TeamDataService {
|
||||||
private readonly taskCommentNotificationJournal: TeamTaskCommentNotificationJournal = new TeamTaskCommentNotificationJournal(),
|
private readonly taskCommentNotificationJournal: TeamTaskCommentNotificationJournal = new TeamTaskCommentNotificationJournal(),
|
||||||
private readonly teamMetaStore: TeamMetaStore = new TeamMetaStore(),
|
private readonly teamMetaStore: TeamMetaStore = new TeamMetaStore(),
|
||||||
private memberRuntimeAdvisoryService: TeamMemberRuntimeAdvisoryService = new TeamMemberRuntimeAdvisoryService(),
|
private memberRuntimeAdvisoryService: TeamMemberRuntimeAdvisoryService = new TeamMemberRuntimeAdvisoryService(),
|
||||||
private readonly leadSessionParseCache: LeadSessionParseCache = new LeadSessionParseCache()
|
private readonly leadSessionParseCache: LeadSessionParseCache = new LeadSessionParseCache(),
|
||||||
) {}
|
private readonly projectResolver: TeamTranscriptProjectResolver = new TeamTranscriptProjectResolver(
|
||||||
|
configReader
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
this.messageFeedService = new TeamMessageFeedService({
|
||||||
|
getConfig: (teamName) => this.configReader.getConfig(teamName),
|
||||||
|
getInboxMessages: (teamName) => this.inboxReader.getMessages(teamName),
|
||||||
|
getLeadSessionMessages: (teamName, config) => this.extractLeadSessionTexts(teamName, config),
|
||||||
|
getSentMessages: (teamName) => this.sentMessagesStore.readMessages(teamName),
|
||||||
|
});
|
||||||
|
this.memberActivityMetaService = new MemberActivityMetaService(this.messageFeedService);
|
||||||
|
}
|
||||||
|
|
||||||
private getController(teamName: string): AgentTeamsController {
|
private getController(teamName: string): AgentTeamsController {
|
||||||
return this.controllerFactory(teamName);
|
return this.controllerFactory(teamName);
|
||||||
|
|
@ -622,7 +643,7 @@ export class TeamDataService {
|
||||||
await fs.promises.rm(tasksDir, { recursive: true, force: true });
|
await fs.promises.rm(tasksDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTeamData(teamName: string): Promise<TeamData> {
|
async getTeamData(teamName: string): Promise<TeamViewSnapshot> {
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
const marks: Record<string, number> = {};
|
const marks: Record<string, number> = {};
|
||||||
const mark = (label: string): void => {
|
const mark = (label: string): void => {
|
||||||
|
|
@ -726,12 +747,6 @@ export class TeamDataService {
|
||||||
warningText: 'Inboxes failed to load',
|
warningText: 'Inboxes failed to load',
|
||||||
load: () => this.inboxReader.listInboxNames(teamName),
|
load: () => this.inboxReader.listInboxNames(teamName),
|
||||||
});
|
});
|
||||||
const sentMessagesStep = startReadStep({
|
|
||||||
label: 'sentMessages',
|
|
||||||
createFallback: () => [],
|
|
||||||
warningText: 'Sent messages failed to load',
|
|
||||||
load: () => this.sentMessagesStore.readMessages(teamName),
|
|
||||||
});
|
|
||||||
const metaMembersStep = startReadStep({
|
const metaMembersStep = startReadStep({
|
||||||
label: 'metaMembers',
|
label: 'metaMembers',
|
||||||
createFallback: () => [],
|
createFallback: () => [],
|
||||||
|
|
@ -756,40 +771,8 @@ export class TeamDataService {
|
||||||
load: () => this.taskReader.getTasks(teamName),
|
load: () => this.taskReader.getTasks(teamName),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
const messagesStep = runWithConcurrencyLimit(() =>
|
const [tasksStepResult, inboxNamesStepResult, metaMembersStepResult, kanbanStateStepResult] =
|
||||||
startReadStep({
|
await Promise.all([tasksStep, inboxNamesStep, metaMembersStep, kanbanStateStep]);
|
||||||
label: 'messages',
|
|
||||||
createFallback: () => [],
|
|
||||||
warningText: 'Messages failed to load',
|
|
||||||
load: () => this.inboxReader.getMessages(teamName),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
const leadTextsStep = runWithConcurrencyLimit(() =>
|
|
||||||
startReadStep({
|
|
||||||
label: 'leadTexts',
|
|
||||||
createFallback: () => [],
|
|
||||||
warningText: 'Lead session texts failed to load',
|
|
||||||
load: () => this.extractLeadSessionTexts(config),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const [
|
|
||||||
tasksStepResult,
|
|
||||||
inboxNamesStepResult,
|
|
||||||
messagesStepResult,
|
|
||||||
leadTextsStepResult,
|
|
||||||
sentMessagesStepResult,
|
|
||||||
metaMembersStepResult,
|
|
||||||
kanbanStateStepResult,
|
|
||||||
] = await Promise.all([
|
|
||||||
tasksStep,
|
|
||||||
inboxNamesStep,
|
|
||||||
messagesStep,
|
|
||||||
leadTextsStep,
|
|
||||||
sentMessagesStep,
|
|
||||||
metaMembersStep,
|
|
||||||
kanbanStateStep,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// After parallelizing the top read phase, these marks no longer represent
|
// After parallelizing the top read phase, these marks no longer represent
|
||||||
// serial stage boundaries. They now capture the actual completion time for
|
// serial stage boundaries. They now capture the actual completion time for
|
||||||
|
|
@ -797,178 +780,18 @@ export class TeamDataService {
|
||||||
// diagnostics useful without mutating marks from concurrent branches.
|
// diagnostics useful without mutating marks from concurrent branches.
|
||||||
marks.tasks = tasksStepResult.completedAt;
|
marks.tasks = tasksStepResult.completedAt;
|
||||||
marks.inboxNames = inboxNamesStepResult.completedAt;
|
marks.inboxNames = inboxNamesStepResult.completedAt;
|
||||||
marks.messages = messagesStepResult.completedAt;
|
|
||||||
marks.leadTexts = leadTextsStepResult.completedAt;
|
|
||||||
marks.sentMessages = sentMessagesStepResult.completedAt;
|
|
||||||
marks.metaMembers = metaMembersStepResult.completedAt;
|
marks.metaMembers = metaMembersStepResult.completedAt;
|
||||||
marks.kanbanState = kanbanStateStepResult.completedAt;
|
marks.kanbanState = kanbanStateStepResult.completedAt;
|
||||||
|
|
||||||
if (tasksStepResult.warning) warnings.push(tasksStepResult.warning);
|
if (tasksStepResult.warning) warnings.push(tasksStepResult.warning);
|
||||||
if (inboxNamesStepResult.warning) warnings.push(inboxNamesStepResult.warning);
|
if (inboxNamesStepResult.warning) warnings.push(inboxNamesStepResult.warning);
|
||||||
if (messagesStepResult.warning) warnings.push(messagesStepResult.warning);
|
|
||||||
if (leadTextsStepResult.warning) warnings.push(leadTextsStepResult.warning);
|
|
||||||
if (sentMessagesStepResult.warning) warnings.push(sentMessagesStepResult.warning);
|
|
||||||
if (metaMembersStepResult.warning) warnings.push(metaMembersStepResult.warning);
|
if (metaMembersStepResult.warning) warnings.push(metaMembersStepResult.warning);
|
||||||
if (kanbanStateStepResult.warning) warnings.push(kanbanStateStepResult.warning);
|
if (kanbanStateStepResult.warning) warnings.push(kanbanStateStepResult.warning);
|
||||||
|
|
||||||
const tasks: TeamTask[] = tasksStepResult.value;
|
const tasks: TeamTask[] = tasksStepResult.value;
|
||||||
const inboxNames: string[] = inboxNamesStepResult.value;
|
const inboxNames: string[] = inboxNamesStepResult.value;
|
||||||
let messages: InboxMessage[] = messagesStepResult.value;
|
|
||||||
const leadTexts: InboxMessage[] = leadTextsStepResult.value;
|
|
||||||
const sentMessages: InboxMessage[] = sentMessagesStepResult.value;
|
|
||||||
mark('postStart');
|
mark('postStart');
|
||||||
|
|
||||||
if (leadTexts.length > 0) {
|
|
||||||
messages = [...messages, ...leadTexts];
|
|
||||||
}
|
|
||||||
if (sentMessages.length > 0) {
|
|
||||||
messages = [...messages, ...sentMessages];
|
|
||||||
}
|
|
||||||
mark('mergeMessages');
|
|
||||||
|
|
||||||
// Dedup: if a lead_process message text is also present in lead_session, prefer lead_session.
|
|
||||||
// This avoids double-rendering when we persist lead process messages and later load the lead JSONL.
|
|
||||||
// Exception: lead_process messages with `to` field are captured SendMessage — never dedup those.
|
|
||||||
if (leadTexts.length > 0) {
|
|
||||||
const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n');
|
|
||||||
const getLeadThoughtFingerprint = (
|
|
||||||
msg: Pick<InboxMessage, 'from' | 'text' | 'leadSessionId'>
|
|
||||||
) => `${msg.leadSessionId ?? ''}\0${msg.from}\0${normalizeText(msg.text ?? '')}`;
|
|
||||||
const leadSessionFingerprints = new Set<string>();
|
|
||||||
for (const msg of leadTexts) {
|
|
||||||
if (msg.source !== 'lead_session') continue;
|
|
||||||
leadSessionFingerprints.add(getLeadThoughtFingerprint(msg));
|
|
||||||
}
|
|
||||||
messages = messages.filter((m) => {
|
|
||||||
if (m.source !== 'lead_process') return true;
|
|
||||||
// Captured SendMessage messages (with recipient) are real messages — never dedup
|
|
||||||
if (m.to) return true;
|
|
||||||
const fp = getLeadThoughtFingerprint(m);
|
|
||||||
return !leadSessionFingerprints.has(fp);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
mark('dedupLeadTexts');
|
|
||||||
|
|
||||||
// Dedup exact message copies that can appear as both live lead_process rows and
|
|
||||||
// their persisted inbox/sent-message counterpart. If the messageId is identical,
|
|
||||||
// keep a single row so the UI does not show the same SendMessage twice
|
|
||||||
// (for example "LIVE" plus the stored copy).
|
|
||||||
const duplicateMessageIds = new Set<string>();
|
|
||||||
const messageIdCounts = new Map<string, number>();
|
|
||||||
for (const msg of messages) {
|
|
||||||
const id = typeof msg.messageId === 'string' ? msg.messageId.trim() : '';
|
|
||||||
if (!id) continue;
|
|
||||||
const nextCount = (messageIdCounts.get(id) ?? 0) + 1;
|
|
||||||
messageIdCounts.set(id, nextCount);
|
|
||||||
if (nextCount > 1) duplicateMessageIds.add(id);
|
|
||||||
}
|
|
||||||
if (duplicateMessageIds.size > 0) {
|
|
||||||
const choosePreferredMessage = (
|
|
||||||
current: InboxMessage,
|
|
||||||
candidate: InboxMessage
|
|
||||||
): InboxMessage => {
|
|
||||||
const score = (msg: InboxMessage): number => {
|
|
||||||
let value = 0;
|
|
||||||
if (msg.source !== 'lead_process') value += 4;
|
|
||||||
if (msg.read === false) value += 2;
|
|
||||||
if (msg.relayOfMessageId) value += 1;
|
|
||||||
if (msg.summary) value += 1;
|
|
||||||
if (msg.to) value += 1;
|
|
||||||
return value;
|
|
||||||
};
|
|
||||||
const currentScore = score(current);
|
|
||||||
const candidateScore = score(candidate);
|
|
||||||
if (candidateScore !== currentScore) {
|
|
||||||
return candidateScore > currentScore ? candidate : current;
|
|
||||||
}
|
|
||||||
const currentTs = Date.parse(current.timestamp);
|
|
||||||
const candidateTs = Date.parse(candidate.timestamp);
|
|
||||||
if (
|
|
||||||
Number.isFinite(currentTs) &&
|
|
||||||
Number.isFinite(candidateTs) &&
|
|
||||||
candidateTs !== currentTs
|
|
||||||
) {
|
|
||||||
return candidateTs > currentTs ? candidate : current;
|
|
||||||
}
|
|
||||||
return current;
|
|
||||||
};
|
|
||||||
|
|
||||||
const dedupedById = new Map<string, InboxMessage>();
|
|
||||||
const dedupedWithoutId: InboxMessage[] = [];
|
|
||||||
for (const msg of messages) {
|
|
||||||
const id = typeof msg.messageId === 'string' ? msg.messageId.trim() : '';
|
|
||||||
if (!id) {
|
|
||||||
dedupedWithoutId.push(msg);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const existing = dedupedById.get(id);
|
|
||||||
if (!existing) {
|
|
||||||
dedupedById.set(id, msg);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
dedupedById.set(id, choosePreferredMessage(existing, msg));
|
|
||||||
}
|
|
||||||
messages = [...dedupedWithoutId, ...dedupedById.values()];
|
|
||||||
}
|
|
||||||
mark('dedupMessageIds');
|
|
||||||
|
|
||||||
messages = this.linkPassiveUserReplySummaries(messages);
|
|
||||||
mark('linkPassiveUserReplySummaries');
|
|
||||||
|
|
||||||
// Enrich inbox messages without leadSessionId by assigning the nearest neighbor's
|
|
||||||
// session ID (by timestamp). This avoids the old forward-only propagation bug.
|
|
||||||
if (config.leadSessionId || messages.some((m) => m.leadSessionId)) {
|
|
||||||
messages.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
|
|
||||||
|
|
||||||
const anchors: { index: number; time: number; sessionId: string }[] = [];
|
|
||||||
for (let i = 0; i < messages.length; i++) {
|
|
||||||
if (messages[i].leadSessionId) {
|
|
||||||
anchors.push({
|
|
||||||
index: i,
|
|
||||||
time: Date.parse(messages[i].timestamp),
|
|
||||||
sessionId: messages[i].leadSessionId!,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (anchors.length > 0) {
|
|
||||||
let anchorIdx = 0;
|
|
||||||
for (let i = 0; i < messages.length; i++) {
|
|
||||||
if (messages[i].leadSessionId) {
|
|
||||||
while (anchorIdx < anchors.length - 1 && anchors[anchorIdx].index < i) {
|
|
||||||
anchorIdx++;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const msgTime = Date.parse(messages[i].timestamp);
|
|
||||||
let bestAnchor = anchors[0];
|
|
||||||
let bestDist = Math.abs(msgTime - bestAnchor.time);
|
|
||||||
for (const anchor of anchors) {
|
|
||||||
const dist = Math.abs(msgTime - anchor.time);
|
|
||||||
if (dist < bestDist) {
|
|
||||||
bestDist = dist;
|
|
||||||
bestAnchor = anchor;
|
|
||||||
} else if (dist > bestDist && anchor.time > msgTime) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
messages[i].leadSessionId = bestAnchor.sessionId;
|
|
||||||
}
|
|
||||||
} else if (config.leadSessionId) {
|
|
||||||
for (const msg of messages) {
|
|
||||||
msg.leadSessionId = config.leadSessionId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
mark('attachLeadSessionIds');
|
|
||||||
|
|
||||||
messages.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
|
|
||||||
this.annotateSlashCommandResponses(messages);
|
|
||||||
|
|
||||||
messages.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
|
|
||||||
mark('normalizeMessages');
|
|
||||||
|
|
||||||
const metaMembers: TeamConfig['members'] = metaMembersStepResult.value;
|
const metaMembers: TeamConfig['members'] = metaMembersStepResult.value;
|
||||||
const kanbanState: KanbanState = kanbanStateStepResult.value;
|
const kanbanState: KanbanState = kanbanStateStepResult.value;
|
||||||
|
|
||||||
|
|
@ -1000,8 +823,7 @@ export class TeamDataService {
|
||||||
config,
|
config,
|
||||||
metaMembers,
|
metaMembers,
|
||||||
inboxNames,
|
inboxNames,
|
||||||
tasksWithKanban,
|
tasksWithKanban
|
||||||
messages
|
|
||||||
);
|
);
|
||||||
mark('resolveMembers');
|
mark('resolveMembers');
|
||||||
|
|
||||||
|
|
@ -1036,30 +858,13 @@ export class TeamDataService {
|
||||||
|
|
||||||
const totalMs = Date.now() - startedAt;
|
const totalMs = Date.now() - startedAt;
|
||||||
if (totalMs >= 1500) {
|
if (totalMs >= 1500) {
|
||||||
const counts = `counts=tasks:${tasks.length},messages:${messages.length},inboxNames:${inboxNames.length},leadTexts:${leadTexts.length},sent:${sentMessages.length},members:${members.length},processes:${processes.length}`;
|
const counts = `counts=tasks:${tasks.length},inboxNames:${inboxNames.length},members:${members.length},processes:${processes.length}`;
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`getTeamData team=${teamName} slow total=${totalMs}ms config=${msSince('config')} tasks=${msSince('tasks')} inboxNames=${msSince(
|
`getTeamData team=${teamName} slow total=${totalMs}ms config=${msSince('config')} tasks=${msSince('tasks')} inboxNames=${msSince(
|
||||||
'inboxNames'
|
'inboxNames'
|
||||||
)} messages=${msSince('messages')} leadTexts=${msSince('leadTexts')} sent=${msSince(
|
|
||||||
'sentMessages'
|
|
||||||
)} membersMeta=${msSince('metaMembers')} kanban=${msSince('kanbanState')} kanbanGc=${msSince(
|
)} membersMeta=${msSince('metaMembers')} kanban=${msSince('kanbanState')} kanbanGc=${msSince(
|
||||||
'kanbanGc'
|
'kanbanGc'
|
||||||
)} post=${msBetween(
|
)} post=${msBetween('postStart', 'attachKanban')}/loadPresenceIndex=${msBetween(
|
||||||
'postStart',
|
|
||||||
'mergeMessages'
|
|
||||||
)}/dedupLead=${msBetween('mergeMessages', 'dedupLeadTexts')}/dedupIds=${msBetween(
|
|
||||||
'dedupLeadTexts',
|
|
||||||
'dedupMessageIds'
|
|
||||||
)}/attachLeadSession=${msBetween(
|
|
||||||
'dedupMessageIds',
|
|
||||||
'attachLeadSessionIds'
|
|
||||||
)}/normalizeMessages=${msBetween(
|
|
||||||
'attachLeadSessionIds',
|
|
||||||
'normalizeMessages'
|
|
||||||
)}/attachKanban=${msBetween(
|
|
||||||
'normalizeMessages',
|
|
||||||
'attachKanban'
|
|
||||||
)}/loadPresenceIndex=${msBetween(
|
|
||||||
'attachKanban',
|
'attachKanban',
|
||||||
'loadPresenceIndex'
|
'loadPresenceIndex'
|
||||||
)}/changePresence=${msBetween(
|
)}/changePresence=${msBetween(
|
||||||
|
|
@ -1088,21 +893,14 @@ export class TeamDataService {
|
||||||
this.processHealthTeams.delete(teamName);
|
this.processHealthTeams.delete(teamName);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cap messages to keep IPC payloads small. Full history is available
|
|
||||||
// via the paginated getMessagesPage() API. We still include a small
|
|
||||||
// batch here for backward compatibility (notifications, dedup, etc.).
|
|
||||||
const MAX_RETURN_MESSAGES = 50;
|
|
||||||
const cappedMessages =
|
|
||||||
messages.length > MAX_RETURN_MESSAGES ? messages.slice(0, MAX_RETURN_MESSAGES) : messages;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
teamName,
|
teamName,
|
||||||
config,
|
config,
|
||||||
tasks: tasksWithKanban,
|
tasks: tasksWithKanban,
|
||||||
members,
|
members,
|
||||||
messages: cappedMessages,
|
|
||||||
kanbanState,
|
kanbanState,
|
||||||
processes,
|
processes,
|
||||||
|
isAlive: hasAlive,
|
||||||
warnings: warnings.length > 0 ? warnings : undefined,
|
warnings: warnings.length > 0 ? warnings : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -1113,106 +911,103 @@ export class TeamDataService {
|
||||||
*/
|
*/
|
||||||
async getMessagesPage(
|
async getMessagesPage(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
options: { beforeTimestamp?: string; limit: number }
|
options: { cursor?: string | null; limit: number; liveMessages?: InboxMessage[] }
|
||||||
): Promise<MessagesPage> {
|
): Promise<MessagesPage> {
|
||||||
const config = await this.configReader.getConfig(teamName);
|
const feed = await this.messageFeedService.getFeed(teamName);
|
||||||
if (!config) {
|
const newestDurableMessages = feed.messages;
|
||||||
return { messages: [], nextCursor: null, hasMore: false };
|
const durableMessageIndexByKey = new Map(
|
||||||
}
|
newestDurableMessages.map((message, index) => [getLiveLeadProcessMessageKey(message), index])
|
||||||
|
);
|
||||||
|
let messages = newestDurableMessages;
|
||||||
|
|
||||||
// Collect all messages from the same sources as getTeamData
|
if (options.cursor) {
|
||||||
let messages: InboxMessage[] = [];
|
const [cursorTs, cursorId] = options.cursor.split('|');
|
||||||
|
|
||||||
const [inboxMessages, leadTexts, sentMessages] = await Promise.all([
|
|
||||||
this.inboxReader.getMessages(teamName).catch(() => [] as InboxMessage[]),
|
|
||||||
this.extractLeadSessionTexts(config).catch(() => [] as InboxMessage[]),
|
|
||||||
this.sentMessagesStore.readMessages(teamName).catch(() => [] as InboxMessage[]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
messages = [...inboxMessages, ...leadTexts, ...sentMessages];
|
|
||||||
|
|
||||||
// Dedup lead_session vs lead_process (same logic as getTeamData)
|
|
||||||
if (leadTexts.length > 0) {
|
|
||||||
const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n');
|
|
||||||
const getFingerprint = (msg: Pick<InboxMessage, 'from' | 'text' | 'leadSessionId'>) =>
|
|
||||||
`${msg.leadSessionId ?? ''}\0${msg.from}\0${normalizeText(msg.text ?? '')}`;
|
|
||||||
const leadSessionFingerprints = new Set<string>();
|
|
||||||
for (const msg of leadTexts) {
|
|
||||||
if (msg.source === 'lead_session') leadSessionFingerprints.add(getFingerprint(msg));
|
|
||||||
}
|
|
||||||
messages = messages.filter((m) => {
|
|
||||||
if (m.source !== 'lead_process') return true;
|
|
||||||
if (m.to) return true;
|
|
||||||
return !leadSessionFingerprints.has(getFingerprint(m));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enrich: propagate leadSessionId to messages missing it (same as getTeamData)
|
|
||||||
if (config.leadSessionId || messages.some((m) => m.leadSessionId)) {
|
|
||||||
messages.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
|
|
||||||
const anchors: { time: number; sessionId: string }[] = [];
|
|
||||||
for (const msg of messages) {
|
|
||||||
if (msg.leadSessionId) {
|
|
||||||
anchors.push({ time: Date.parse(msg.timestamp), sessionId: msg.leadSessionId });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (anchors.length > 0) {
|
|
||||||
for (const msg of messages) {
|
|
||||||
if (msg.leadSessionId) continue;
|
|
||||||
const msgTime = Date.parse(msg.timestamp);
|
|
||||||
let best = anchors[0];
|
|
||||||
let bestDist = Math.abs(msgTime - best.time);
|
|
||||||
for (const a of anchors) {
|
|
||||||
const dist = Math.abs(msgTime - a.time);
|
|
||||||
if (dist < bestDist) {
|
|
||||||
bestDist = dist;
|
|
||||||
best = a;
|
|
||||||
} else if (dist > bestDist && a.time > msgTime) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
msg.leadSessionId = best.sessionId;
|
|
||||||
}
|
|
||||||
} else if (config.leadSessionId) {
|
|
||||||
for (const msg of messages) {
|
|
||||||
msg.leadSessionId = config.leadSessionId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enrich: annotate slash command responses
|
|
||||||
this.annotateSlashCommandResponses(messages);
|
|
||||||
|
|
||||||
// Sort newest-first, with stable tie-breaker by messageId
|
|
||||||
messages.sort((a, b) => {
|
|
||||||
const diff = Date.parse(b.timestamp) - Date.parse(a.timestamp);
|
|
||||||
if (diff !== 0) return diff;
|
|
||||||
return (a.messageId ?? '').localeCompare(b.messageId ?? '');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Apply cursor filter. Cursor format: "timestamp|messageId" (compound)
|
|
||||||
// to handle multiple messages sharing the same timestamp.
|
|
||||||
if (options.beforeTimestamp) {
|
|
||||||
const [cursorTs, cursorId] = options.beforeTimestamp.split('|');
|
|
||||||
const cursorMs = Date.parse(cursorTs);
|
const cursorMs = Date.parse(cursorTs);
|
||||||
messages = messages.filter((m) => {
|
messages = messages.filter((m) => {
|
||||||
const ms = Date.parse(m.timestamp);
|
const ms = Date.parse(m.timestamp);
|
||||||
if (ms < cursorMs) return true;
|
if (ms < cursorMs) return true;
|
||||||
if (ms > cursorMs) return false;
|
if (ms > cursorMs) return false;
|
||||||
// Same timestamp — use messageId tie-breaker
|
|
||||||
if (!cursorId) return false;
|
if (!cursorId) return false;
|
||||||
return (m.messageId ?? '').localeCompare(cursorId) > 0;
|
return requireCanonicalMessageId(m).localeCompare(cursorId) > 0;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Paginate
|
|
||||||
const hasMore = messages.length > options.limit;
|
const hasMore = messages.length > options.limit;
|
||||||
const page = messages.slice(0, options.limit);
|
const page = messages.slice(0, options.limit);
|
||||||
const lastMsg = page[page.length - 1];
|
const lastMsg = page[page.length - 1];
|
||||||
const nextCursor =
|
const nextCursor =
|
||||||
hasMore && lastMsg ? `${lastMsg.timestamp}|${lastMsg.messageId ?? ''}` : null;
|
hasMore && lastMsg ? `${lastMsg.timestamp}|${requireCanonicalMessageId(lastMsg)}` : null;
|
||||||
|
|
||||||
return { messages: page, nextCursor, hasMore };
|
if (options.cursor || !options.liveMessages?.length) {
|
||||||
|
return { messages: page, nextCursor, hasMore, feedRevision: feed.feedRevision };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge live lead thoughts against the full durable newest-page history so we do not
|
||||||
|
// re-introduce persisted thoughts that have simply paged off the first durable page.
|
||||||
|
const displayMessages = mergeLiveLeadProcessMessages(
|
||||||
|
newestDurableMessages,
|
||||||
|
options.liveMessages
|
||||||
|
).slice(0, options.limit);
|
||||||
|
|
||||||
|
if (displayMessages.length === 0) {
|
||||||
|
return {
|
||||||
|
messages: displayMessages,
|
||||||
|
nextCursor: null,
|
||||||
|
hasMore: false,
|
||||||
|
feedRevision: feed.feedRevision,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastDurableDisplayed: InboxMessage | null = null;
|
||||||
|
for (let index = displayMessages.length - 1; index >= 0; index -= 1) {
|
||||||
|
const candidate = displayMessages[index];
|
||||||
|
if (durableMessageIndexByKey.has(getLiveLeadProcessMessageKey(candidate))) {
|
||||||
|
lastDurableDisplayed = candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!lastDurableDisplayed) {
|
||||||
|
const boundary = displayMessages[displayMessages.length - 1];
|
||||||
|
return {
|
||||||
|
messages: displayMessages,
|
||||||
|
nextCursor:
|
||||||
|
newestDurableMessages.length > 0
|
||||||
|
? `${boundary.timestamp}|${boundary.messageId ?? ''}`
|
||||||
|
: null,
|
||||||
|
hasMore: newestDurableMessages.length > 0,
|
||||||
|
feedRevision: feed.feedRevision,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const durableIndex =
|
||||||
|
durableMessageIndexByKey.get(getLiveLeadProcessMessageKey(lastDurableDisplayed)) ??
|
||||||
|
Number.POSITIVE_INFINITY;
|
||||||
|
const durableHasMore = durableIndex < newestDurableMessages.length - 1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages: displayMessages,
|
||||||
|
nextCursor: durableHasMore
|
||||||
|
? `${lastDurableDisplayed.timestamp}|${lastDurableDisplayed.messageId ?? ''}`
|
||||||
|
: null,
|
||||||
|
hasMore: durableHasMore,
|
||||||
|
feedRevision: feed.feedRevision,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMessageFeed(
|
||||||
|
teamName: string
|
||||||
|
): Promise<{ teamName: string; feedRevision: string; messages: InboxMessage[] }> {
|
||||||
|
return this.messageFeedService.getFeed(teamName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMemberActivityMeta(teamName: string): Promise<TeamMemberActivityMeta> {
|
||||||
|
return this.memberActivityMetaService.getMeta(teamName);
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidateMessageFeed(teamName: string): void {
|
||||||
|
this.messageFeedService.invalidate(teamName);
|
||||||
|
this.memberActivityMetaService.invalidate(teamName);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1220,7 +1015,7 @@ export class TeamDataService {
|
||||||
* Mutates members in-place for efficiency (called right after resolveMembers).
|
* Mutates members in-place for efficiency (called right after resolveMembers).
|
||||||
*/
|
*/
|
||||||
private async enrichMemberBranches(
|
private async enrichMemberBranches(
|
||||||
members: ResolvedTeamMember[],
|
members: TeamViewSnapshot['members'],
|
||||||
config: TeamConfig
|
config: TeamConfig
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const leadEntry = config.members?.find((member) => isLeadMember(member));
|
const leadEntry = config.members?.find((member) => isLeadMember(member));
|
||||||
|
|
@ -1892,7 +1687,7 @@ export class TeamDataService {
|
||||||
slashCommand: slashCommandMeta,
|
slashCommand: slashCommandMeta,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return this.getController(teamName).messages.sendMessage({
|
const result = this.getController(teamName).messages.sendMessage({
|
||||||
member: enrichedRequest.member,
|
member: enrichedRequest.member,
|
||||||
from: enrichedRequest.from,
|
from: enrichedRequest.from,
|
||||||
text: enrichedRequest.text,
|
text: enrichedRequest.text,
|
||||||
|
|
@ -1913,6 +1708,8 @@ export class TeamDataService {
|
||||||
leadSessionId: enrichedRequest.leadSessionId,
|
leadSessionId: enrichedRequest.leadSessionId,
|
||||||
attachments: enrichedRequest.attachments,
|
attachments: enrichedRequest.attachments,
|
||||||
}) as SendMessageResult;
|
}) as SendMessageResult;
|
||||||
|
this.invalidateMessageFeed(teamName);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveLeadNameFromConfig(config: TeamConfig | null): string {
|
private resolveLeadNameFromConfig(config: TeamConfig | null): string {
|
||||||
|
|
@ -2469,6 +2266,23 @@ export class TeamDataService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getTeamNotificationContext(teamName: string): Promise<{
|
||||||
|
displayName: string;
|
||||||
|
projectPath?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const config = await this.configReader.getConfig(teamName);
|
||||||
|
const displayName = config?.name?.trim() || teamName;
|
||||||
|
const projectPath =
|
||||||
|
typeof config?.projectPath === 'string' && config.projectPath.trim().length > 0
|
||||||
|
? config.projectPath
|
||||||
|
: undefined;
|
||||||
|
return { displayName, projectPath };
|
||||||
|
} catch {
|
||||||
|
return { displayName: teamName };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async requestReview(teamName: string, taskId: string): Promise<void> {
|
async requestReview(teamName: string, taskId: string): Promise<void> {
|
||||||
const { leadSessionId } = await this.resolveLeadRuntimeContext(teamName);
|
const { leadSessionId } = await this.resolveLeadRuntimeContext(teamName);
|
||||||
this.getController(teamName).review.requestReview(taskId, {
|
this.getController(teamName).review.requestReview(taskId, {
|
||||||
|
|
@ -2614,37 +2428,20 @@ export class TeamDataService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private getLeadProjectDirCandidates(projectPath: string): string[] {
|
private async getLeadSessionJsonlPaths(projectDir: string): Promise<Map<string, string>> {
|
||||||
const projectId = encodePath(projectPath);
|
|
||||||
const baseDir = extractBaseDir(projectId);
|
|
||||||
const candidateDirs = [
|
|
||||||
path.join(getProjectsBasePath(), baseDir),
|
|
||||||
// Claude Code encodes underscores as hyphens in project directory names;
|
|
||||||
// our encodePath only handles slashes. Try the underscore-to-hyphen variant.
|
|
||||||
...(baseDir.includes('_')
|
|
||||||
? [path.join(getProjectsBasePath(), baseDir.replace(/_/g, '-'))]
|
|
||||||
: []),
|
|
||||||
];
|
|
||||||
|
|
||||||
return [...new Set(candidateDirs)];
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getLeadSessionJsonlPaths(projectPath: string): Promise<Map<string, string>> {
|
|
||||||
const jsonlPaths = new Map<string, string>();
|
const jsonlPaths = new Map<string, string>();
|
||||||
for (const dirPath of this.getLeadProjectDirCandidates(projectPath)) {
|
let entries: fs.Dirent[];
|
||||||
let entries: fs.Dirent[];
|
try {
|
||||||
try {
|
entries = await fs.promises.readdir(projectDir, { withFileTypes: true });
|
||||||
entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
|
} catch {
|
||||||
} catch {
|
return jsonlPaths;
|
||||||
continue;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue;
|
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue;
|
||||||
const sessionId = entry.name.slice(0, -'.jsonl'.length).trim();
|
const sessionId = entry.name.slice(0, -'.jsonl'.length).trim();
|
||||||
if (!sessionId || jsonlPaths.has(sessionId)) continue;
|
if (!sessionId || jsonlPaths.has(sessionId)) continue;
|
||||||
jsonlPaths.set(sessionId, path.join(dirPath, entry.name));
|
jsonlPaths.set(sessionId, path.join(projectDir, entry.name));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return jsonlPaths;
|
return jsonlPaths;
|
||||||
|
|
@ -2890,17 +2687,25 @@ export class TeamDataService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async extractLeadSessionTexts(config: TeamConfig): Promise<InboxMessage[]> {
|
private async extractLeadSessionTexts(
|
||||||
if (!config.projectPath) {
|
teamName: string,
|
||||||
|
config: TeamConfig
|
||||||
|
): Promise<InboxMessage[]> {
|
||||||
|
const transcriptContext = await this.projectResolver.getContext(teamName);
|
||||||
|
if (!transcriptContext) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
const leadName =
|
||||||
const leadName = config.members?.find((m) => isLeadMember(m))?.name ?? 'team-lead';
|
transcriptContext.config.members?.find((m) => isLeadMember(m))?.name ?? 'team-lead';
|
||||||
const sessionIds = this.getRecentLeadSessionIds(config);
|
const knownLeadSessionIds = this.getRecentLeadSessionIds(config);
|
||||||
|
if (knownLeadSessionIds.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const sessionIds = knownLeadSessionIds;
|
||||||
if (sessionIds.length === 0) {
|
if (sessionIds.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const availableJsonlPaths = await this.getLeadSessionJsonlPaths(config.projectPath);
|
const availableJsonlPaths = await this.getLeadSessionJsonlPaths(transcriptContext.projectDir);
|
||||||
if (availableJsonlPaths.size === 0) {
|
if (availableJsonlPaths.size === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,12 @@ import { Worker } from 'node:worker_threads';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
||||||
import type { TeamDataWorkerRequest, TeamDataWorkerResponse } from './teamDataWorkerTypes';
|
import type { TeamDataWorkerRequest, TeamDataWorkerResponse } from './teamDataWorkerTypes';
|
||||||
import type { MemberLogSummary, TeamData } from '@shared/types';
|
import type {
|
||||||
|
MemberLogSummary,
|
||||||
|
MessagesPage,
|
||||||
|
TeamMemberActivityMeta,
|
||||||
|
TeamViewSnapshot,
|
||||||
|
} from '@shared/types';
|
||||||
|
|
||||||
const logger = createLogger('Service:TeamDataWorkerClient');
|
const logger = createLogger('Service:TeamDataWorkerClient');
|
||||||
const WORKER_CALL_TIMEOUT_MS = 30_000;
|
const WORKER_CALL_TIMEOUT_MS = 30_000;
|
||||||
|
|
@ -25,16 +30,20 @@ function makeId(): string {
|
||||||
return `${Date.now()}-${crypto.randomUUID().slice(0, 12)}`;
|
return `${Date.now()}-${crypto.randomUUID().slice(0, 12)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveWorkerPath(): string | null {
|
function getWorkerPathCandidates(): string[] {
|
||||||
const baseDir =
|
const baseDir =
|
||||||
typeof __dirname === 'string' && __dirname.length > 0
|
typeof __dirname === 'string' && __dirname.length > 0
|
||||||
? __dirname
|
? __dirname
|
||||||
: path.dirname(fileURLToPath(import.meta.url));
|
: path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
const candidates = [
|
return [
|
||||||
path.join(baseDir, 'team-data-worker.cjs'),
|
path.join(baseDir, 'team-data-worker.cjs'),
|
||||||
path.join(process.cwd(), 'dist-electron', 'main', 'team-data-worker.cjs'),
|
path.join(process.cwd(), 'dist-electron', 'main', 'team-data-worker.cjs'),
|
||||||
];
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveWorkerPath(): string | null {
|
||||||
|
const candidates = getWorkerPathCandidates();
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -75,7 +84,9 @@ export class TeamDataWorkerClient {
|
||||||
isAvailable(): boolean {
|
isAvailable(): boolean {
|
||||||
if (!this.workerPath && !this.warnedUnavailable) {
|
if (!this.workerPath && !this.warnedUnavailable) {
|
||||||
this.warnedUnavailable = true;
|
this.warnedUnavailable = true;
|
||||||
logger.debug('team-data-worker not found; falling back to main-thread execution');
|
logger.warn(
|
||||||
|
`team-data-worker not found; heavy team data paths may fall back to main-thread execution. expectedOneOf=${getWorkerPathCandidates().join(',')}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return this.workerPath !== null;
|
return this.workerPath !== null;
|
||||||
}
|
}
|
||||||
|
|
@ -144,9 +155,22 @@ export class TeamDataWorkerClient {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTeamData(teamName: string): Promise<TeamData> {
|
async getTeamData(teamName: string): Promise<TeamViewSnapshot> {
|
||||||
if (!SAFE_NAME_RE.test(teamName)) throw new Error('Invalid teamName');
|
if (!SAFE_NAME_RE.test(teamName)) throw new Error('Invalid teamName');
|
||||||
return this.call('getTeamData', { teamName }) as Promise<TeamData>;
|
return this.call('getTeamData', { teamName }) as Promise<TeamViewSnapshot>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMessagesPage(
|
||||||
|
teamName: string,
|
||||||
|
options: { cursor?: string | null; limit: number }
|
||||||
|
): Promise<MessagesPage> {
|
||||||
|
if (!SAFE_NAME_RE.test(teamName)) throw new Error('Invalid teamName');
|
||||||
|
return this.call('getMessagesPage', { teamName, options }) as Promise<MessagesPage>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMemberActivityMeta(teamName: string): Promise<TeamMemberActivityMeta> {
|
||||||
|
if (!SAFE_NAME_RE.test(teamName)) throw new Error('Invalid teamName');
|
||||||
|
return this.call('getMemberActivityMeta', { teamName }) as Promise<TeamMemberActivityMeta>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findLogsForTask(
|
async findLogsForTask(
|
||||||
|
|
|
||||||
|
|
@ -223,8 +223,7 @@ export function createPersistedLaunchSnapshot(params: {
|
||||||
for (const name of expectedMembers) {
|
for (const name of expectedMembers) {
|
||||||
const member = members[name];
|
const member = members[name];
|
||||||
if (
|
if (
|
||||||
member &&
|
member?.launchState === 'starting' &&
|
||||||
member.launchState === 'starting' &&
|
|
||||||
!member.agentToolAccepted &&
|
!member.agentToolAccepted &&
|
||||||
!member.runtimeAlive &&
|
!member.runtimeAlive &&
|
||||||
!member.bootstrapConfirmed &&
|
!member.bootstrapConfirmed &&
|
||||||
|
|
|
||||||
|
|
@ -14,13 +14,15 @@ import type { TeamChangeEvent } from '@shared/types';
|
||||||
import type { FSWatcher } from 'chokidar';
|
import type { FSWatcher } from 'chokidar';
|
||||||
|
|
||||||
const logger = createLogger('Service:TeamLogSourceTracker');
|
const logger = createLogger('Service:TeamLogSourceTracker');
|
||||||
|
const BOARD_TASK_LOG_FRESHNESS_DIRNAME = '.board-task-log-freshness';
|
||||||
|
const BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX = '.json';
|
||||||
|
|
||||||
interface TeamLogSourceSnapshot {
|
interface TeamLogSourceSnapshot {
|
||||||
projectFingerprint: string | null;
|
projectFingerprint: string | null;
|
||||||
logSourceGeneration: string | null;
|
logSourceGeneration: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TeamLogSourceTrackingConsumer = 'change_presence' | 'tool_activity';
|
export type TeamLogSourceTrackingConsumer = 'change_presence' | 'tool_activity' | 'task_log_stream';
|
||||||
|
|
||||||
interface TrackingState {
|
interface TrackingState {
|
||||||
watcher: FSWatcher | null;
|
watcher: FSWatcher | null;
|
||||||
|
|
@ -31,7 +33,7 @@ interface TrackingState {
|
||||||
recomputePromise: Promise<TeamLogSourceSnapshot> | null;
|
recomputePromise: Promise<TeamLogSourceSnapshot> | null;
|
||||||
recomputeVersion: number | null;
|
recomputeVersion: number | null;
|
||||||
snapshot: TeamLogSourceSnapshot;
|
snapshot: TeamLogSourceSnapshot;
|
||||||
consumers: Set<TeamLogSourceTrackingConsumer>;
|
consumerCounts: Map<TeamLogSourceTrackingConsumer, number>;
|
||||||
lifecycleVersion: number;
|
lifecycleVersion: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -67,19 +69,29 @@ export class TeamLogSourceTracker {
|
||||||
consumer: TeamLogSourceTrackingConsumer
|
consumer: TeamLogSourceTrackingConsumer
|
||||||
): Promise<TeamLogSourceSnapshot> {
|
): Promise<TeamLogSourceSnapshot> {
|
||||||
const state = this.getOrCreateState(teamName);
|
const state = this.getOrCreateState(teamName);
|
||||||
if (!state.consumers.has(consumer)) {
|
const activeConsumerCountBefore = this.getActiveConsumerCount(state);
|
||||||
state.consumers.add(consumer);
|
state.consumerCounts.set(consumer, (state.consumerCounts.get(consumer) ?? 0) + 1);
|
||||||
|
if (activeConsumerCountBefore === 0) {
|
||||||
state.lifecycleVersion += 1;
|
state.lifecycleVersion += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
state.initializePromise &&
|
state.initializePromise &&
|
||||||
state.initializeVersion === state.lifecycleVersion &&
|
state.initializeVersion === state.lifecycleVersion &&
|
||||||
state.consumers.size > 0
|
this.getActiveConsumerCount(state) > 0
|
||||||
) {
|
) {
|
||||||
return state.initializePromise;
|
return state.initializePromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
activeConsumerCountBefore > 0 &&
|
||||||
|
(state.watcher !== null ||
|
||||||
|
state.projectDir !== null ||
|
||||||
|
state.snapshot.logSourceGeneration !== null)
|
||||||
|
) {
|
||||||
|
return { ...state.snapshot };
|
||||||
|
}
|
||||||
|
|
||||||
const initializeVersion = state.lifecycleVersion;
|
const initializeVersion = state.lifecycleVersion;
|
||||||
const initializePromise = this.initializeTeam(teamName, initializeVersion)
|
const initializePromise = this.initializeTeam(teamName, initializeVersion)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
|
@ -118,13 +130,21 @@ export class TeamLogSourceTracker {
|
||||||
recomputePromise: null,
|
recomputePromise: null,
|
||||||
recomputeVersion: null,
|
recomputeVersion: null,
|
||||||
snapshot: { projectFingerprint: null, logSourceGeneration: null },
|
snapshot: { projectFingerprint: null, logSourceGeneration: null },
|
||||||
consumers: new Set(),
|
consumerCounts: new Map(),
|
||||||
lifecycleVersion: 0,
|
lifecycleVersion: 0,
|
||||||
};
|
};
|
||||||
this.stateByTeam.set(teamName, created);
|
this.stateByTeam.set(teamName, created);
|
||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getActiveConsumerCount(state: TrackingState): number {
|
||||||
|
let count = 0;
|
||||||
|
for (const value of state.consumerCounts.values()) {
|
||||||
|
count += value;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
async stopTracking(teamName: string): Promise<void> {
|
async stopTracking(teamName: string): Promise<void> {
|
||||||
await this.disableTracking(teamName, 'change_presence');
|
await this.disableTracking(teamName, 'change_presence');
|
||||||
}
|
}
|
||||||
|
|
@ -138,15 +158,24 @@ export class TeamLogSourceTracker {
|
||||||
return { projectFingerprint: null, logSourceGeneration: null };
|
return { projectFingerprint: null, logSourceGeneration: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.consumers.has(consumer)) {
|
const currentConsumerCount = state.consumerCounts.get(consumer) ?? 0;
|
||||||
state.consumers.delete(consumer);
|
if (currentConsumerCount > 1) {
|
||||||
state.lifecycleVersion += 1;
|
state.consumerCounts.set(consumer, currentConsumerCount - 1);
|
||||||
|
return { ...state.snapshot };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.consumers.size > 0) {
|
if (currentConsumerCount === 1) {
|
||||||
|
state.consumerCounts.delete(consumer);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.getActiveConsumerCount(state) > 0) {
|
||||||
return { ...state.snapshot };
|
return { ...state.snapshot };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (currentConsumerCount > 0) {
|
||||||
|
state.lifecycleVersion += 1;
|
||||||
|
}
|
||||||
|
|
||||||
if (state.refreshTimer) {
|
if (state.refreshTimer) {
|
||||||
clearTimeout(state.refreshTimer);
|
clearTimeout(state.refreshTimer);
|
||||||
state.refreshTimer = null;
|
state.refreshTimer = null;
|
||||||
|
|
@ -164,7 +193,11 @@ export class TeamLogSourceTracker {
|
||||||
|
|
||||||
private isTrackingCurrent(teamName: string, expectedVersion: number): boolean {
|
private isTrackingCurrent(teamName: string, expectedVersion: number): boolean {
|
||||||
const state = this.stateByTeam.get(teamName);
|
const state = this.stateByTeam.get(teamName);
|
||||||
return !!state && state.consumers.size > 0 && state.lifecycleVersion === expectedVersion;
|
return (
|
||||||
|
!!state &&
|
||||||
|
this.getActiveConsumerCount(state) > 0 &&
|
||||||
|
state.lifecycleVersion === expectedVersion
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async initializeTeam(
|
private async initializeTeam(
|
||||||
|
|
@ -207,7 +240,11 @@ export class TeamLogSourceTracker {
|
||||||
expectedVersion: number
|
expectedVersion: number
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const state = this.stateByTeam.get(teamName);
|
const state = this.stateByTeam.get(teamName);
|
||||||
if (!state || state.consumers.size === 0 || state.lifecycleVersion !== expectedVersion) {
|
if (
|
||||||
|
!state ||
|
||||||
|
this.getActiveConsumerCount(state) === 0 ||
|
||||||
|
state.lifecycleVersion !== expectedVersion
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (state.projectDir === projectDir && state.watcher) {
|
if (state.projectDir === projectDir && state.watcher) {
|
||||||
|
|
@ -240,9 +277,15 @@ export class TeamLogSourceTracker {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const scheduleRecompute = (): void => {
|
const scheduleRecompute = (changedPath?: string): void => {
|
||||||
const current = this.stateByTeam.get(teamName);
|
const current = this.stateByTeam.get(teamName);
|
||||||
if (!current || current.consumers.size === 0) {
|
if (!current || this.getActiveConsumerCount(current) === 0 || !current.projectDir) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
changedPath &&
|
||||||
|
this.handleTaskLogFreshnessSignalChange(teamName, current.projectDir, changedPath)
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (current.refreshTimer) {
|
if (current.refreshTimer) {
|
||||||
|
|
@ -264,15 +307,65 @@ export class TeamLogSourceTracker {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private handleTaskLogFreshnessSignalChange(
|
||||||
|
teamName: string,
|
||||||
|
projectDir: string,
|
||||||
|
changedPath: string
|
||||||
|
): boolean {
|
||||||
|
const signalDir = path.join(projectDir, BOARD_TASK_LOG_FRESHNESS_DIRNAME);
|
||||||
|
const relativePath = path.relative(signalDir, changedPath);
|
||||||
|
if (!relativePath || relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
|
||||||
|
return path.normalize(changedPath) === path.normalize(signalDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (relativePath === '.') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (relativePath.includes(path.sep)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskId = this.decodeTaskLogFreshnessTaskId(relativePath);
|
||||||
|
if (!taskId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emitter?.({
|
||||||
|
type: 'task-log-change',
|
||||||
|
teamName,
|
||||||
|
taskId,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private decodeTaskLogFreshnessTaskId(fileName: string): string | null {
|
||||||
|
if (!fileName.endsWith(BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const encodedTaskId = fileName.slice(0, -BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX.length);
|
||||||
|
if (!encodedTaskId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const taskId = decodeURIComponent(encodedTaskId);
|
||||||
|
return taskId.trim().length > 0 ? taskId : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async recompute(teamName: string): Promise<TeamLogSourceSnapshot> {
|
private async recompute(teamName: string): Promise<TeamLogSourceSnapshot> {
|
||||||
const state = this.getOrCreateState(teamName);
|
const state = this.getOrCreateState(teamName);
|
||||||
if (state.consumers.size === 0) {
|
if (this.getActiveConsumerCount(state) === 0) {
|
||||||
return state.snapshot;
|
return state.snapshot;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
state.recomputePromise &&
|
state.recomputePromise &&
|
||||||
state.recomputeVersion === state.lifecycleVersion &&
|
state.recomputeVersion === state.lifecycleVersion &&
|
||||||
state.consumers.size > 0
|
this.getActiveConsumerCount(state) > 0
|
||||||
) {
|
) {
|
||||||
return state.recomputePromise;
|
return state.recomputePromise;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,11 @@
|
||||||
|
import { getMemberColorByName } from '@shared/constants/memberColors';
|
||||||
import {
|
import {
|
||||||
createCliAutoSuffixNameGuard,
|
createCliAutoSuffixNameGuard,
|
||||||
createCliProvisionerNameGuard,
|
createCliProvisionerNameGuard,
|
||||||
} from '@shared/utils/teamMemberName';
|
} from '@shared/utils/teamMemberName';
|
||||||
import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
|
import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
|
||||||
|
|
||||||
import type {
|
import type { TeamConfig, TeamMember, TeamMemberSnapshot, TeamTaskWithKanban } from '@shared/types';
|
||||||
InboxMessage,
|
|
||||||
MemberStatus,
|
|
||||||
ResolvedTeamMember,
|
|
||||||
TeamConfig,
|
|
||||||
TeamMember,
|
|
||||||
TeamTaskWithKanban,
|
|
||||||
} from '@shared/types';
|
|
||||||
|
|
||||||
const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
|
const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
|
||||||
const CROSS_TEAM_TOOL_RECIPIENT_NAMES = new Set([
|
const CROSS_TEAM_TOOL_RECIPIENT_NAMES = new Set([
|
||||||
|
|
@ -63,9 +57,8 @@ export class TeamMemberResolver {
|
||||||
config: TeamConfig,
|
config: TeamConfig,
|
||||||
metaMembers: TeamConfig['members'],
|
metaMembers: TeamConfig['members'],
|
||||||
inboxNames: string[],
|
inboxNames: string[],
|
||||||
tasks: TeamTaskWithKanban[],
|
tasks: TeamTaskWithKanban[]
|
||||||
messages: InboxMessage[]
|
): TeamMemberSnapshot[] {
|
||||||
): ResolvedTeamMember[] {
|
|
||||||
const names = new Set<string>();
|
const names = new Set<string>();
|
||||||
const explicitNames = new Set<string>();
|
const explicitNames = new Set<string>();
|
||||||
const seenNames = new Set<string>();
|
const seenNames = new Set<string>();
|
||||||
|
|
@ -216,7 +209,7 @@ export class TeamMemberResolver {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const members: ResolvedTeamMember[] = [];
|
const members: TeamMemberSnapshot[] = [];
|
||||||
for (const name of names) {
|
for (const name of names) {
|
||||||
const ownedTasks = tasks.filter((task) => task.owner === name);
|
const ownedTasks = tasks.filter((task) => task.owner === name);
|
||||||
const currentTask =
|
const currentTask =
|
||||||
|
|
@ -226,21 +219,15 @@ export class TeamMemberResolver {
|
||||||
task.reviewState !== 'approved' &&
|
task.reviewState !== 'approved' &&
|
||||||
task.kanbanColumn !== 'approved'
|
task.kanbanColumn !== 'approved'
|
||||||
) ?? null;
|
) ?? null;
|
||||||
const memberMessages = messages.filter((message) => message.from === name);
|
|
||||||
const latestMessage = memberMessages[0] ?? null;
|
|
||||||
const status = this.resolveStatus(latestMessage, currentTask !== null);
|
|
||||||
const configMember = configMemberMap.get(name);
|
const configMember = configMemberMap.get(name);
|
||||||
const metaMember = metaMemberMap.get(name);
|
const metaMember = metaMemberMap.get(name);
|
||||||
const agentId = configMember?.agentId ?? metaMember?.agentId;
|
const agentId = configMember?.agentId ?? metaMember?.agentId;
|
||||||
members.push({
|
members.push({
|
||||||
name,
|
name,
|
||||||
agentId,
|
agentId,
|
||||||
status,
|
|
||||||
currentTaskId: currentTask?.id ?? null,
|
currentTaskId: currentTask?.id ?? null,
|
||||||
taskCount: ownedTasks.length,
|
taskCount: ownedTasks.length,
|
||||||
messageCount: memberMessages.length,
|
color: configMember?.color ?? metaMember?.color ?? getMemberColorByName(name),
|
||||||
lastActiveAt: latestMessage?.timestamp ?? null,
|
|
||||||
color: latestMessage?.color ?? configMember?.color ?? metaMember?.color,
|
|
||||||
agentType: configMember?.agentType ?? metaMember?.agentType,
|
agentType: configMember?.agentType ?? metaMember?.agentType,
|
||||||
role: configMember?.role ?? metaMember?.role,
|
role: configMember?.role ?? metaMember?.role,
|
||||||
workflow: configMember?.workflow ?? metaMember?.workflow,
|
workflow: configMember?.workflow ?? metaMember?.workflow,
|
||||||
|
|
@ -277,45 +264,4 @@ export class TeamMemberResolver {
|
||||||
});
|
});
|
||||||
return members;
|
return members;
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveStatus(message: InboxMessage | null, hasActiveTask: boolean): MemberStatus {
|
|
||||||
if (!message) {
|
|
||||||
// Member exists in config but has no messages yet —
|
|
||||||
// if they own an in_progress task they're clearly active, otherwise idle
|
|
||||||
return hasActiveTask ? 'active' : 'idle';
|
|
||||||
}
|
|
||||||
|
|
||||||
const structured = this.parseStructuredMessage(message.text);
|
|
||||||
if (structured) {
|
|
||||||
const typed = structured as { type?: string; approve?: boolean; approved?: boolean };
|
|
||||||
if (
|
|
||||||
(typed.type === 'shutdown_response' &&
|
|
||||||
(typed.approve === true || typed.approved === true)) ||
|
|
||||||
typed.type === 'shutdown_approved'
|
|
||||||
) {
|
|
||||||
return 'terminated';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const ageMs = Date.now() - Date.parse(message.timestamp);
|
|
||||||
if (Number.isNaN(ageMs)) {
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
if (ageMs < 5 * 60 * 1000) {
|
|
||||||
return 'active';
|
|
||||||
}
|
|
||||||
return 'idle';
|
|
||||||
}
|
|
||||||
|
|
||||||
private parseStructuredMessage(text: string): Record<string, unknown> | null {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(text) as unknown;
|
|
||||||
if (parsed && typeof parsed === 'object') {
|
|
||||||
return parsed as Record<string, unknown>;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore plain text.
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
408
src/main/services/team/TeamMessageFeedService.ts
Normal file
408
src/main/services/team/TeamMessageFeedService.ts
Normal file
|
|
@ -0,0 +1,408 @@
|
||||||
|
import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSemantics';
|
||||||
|
import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands';
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
|
||||||
|
import { getEffectiveInboxMessageId } from './inboxMessageIdentity';
|
||||||
|
|
||||||
|
import type { InboxMessage, TeamConfig } from '@shared/types';
|
||||||
|
|
||||||
|
const PASSIVE_USER_REPLY_LINK_WINDOW_MS = 15_000;
|
||||||
|
|
||||||
|
interface TeamMessageFeedDeps {
|
||||||
|
getConfig: (teamName: string) => Promise<TeamConfig | null>;
|
||||||
|
getInboxMessages: (teamName: string) => Promise<InboxMessage[]>;
|
||||||
|
getLeadSessionMessages: (teamName: string, config: TeamConfig) => Promise<InboxMessage[]>;
|
||||||
|
getSentMessages: (teamName: string) => Promise<InboxMessage[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TeamMessageFeedCacheEntry {
|
||||||
|
feedRevision: string;
|
||||||
|
messages: InboxMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeamNormalizedMessageFeed {
|
||||||
|
teamName: string;
|
||||||
|
feedRevision: string;
|
||||||
|
messages: InboxMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireCanonicalMessageId(message: InboxMessage): string {
|
||||||
|
const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : '';
|
||||||
|
if (messageId.length > 0) {
|
||||||
|
return messageId;
|
||||||
|
}
|
||||||
|
throw new Error('Normalized team message is missing effective messageId');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePassiveUserReplyLinkText(value: string | undefined): string {
|
||||||
|
if (typeof value !== 'string') return '';
|
||||||
|
return value
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.replace(/[.!?…]+$/g, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractPassiveUserPeerSummaryBody(text: string): string | null {
|
||||||
|
const classified = classifyIdleNotificationText(text);
|
||||||
|
if (classified?.primaryKind !== 'heartbeat' || !classified.peerSummary) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = /^\[to\s+user\]\s*(.*)$/i.exec(classified.peerSummary);
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = match[1]?.trim() ?? '';
|
||||||
|
return body.length > 0 ? body : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLeadThoughtCandidateForSlashResult(message: InboxMessage): boolean {
|
||||||
|
if (typeof message.to === 'string' && message.to.trim().length > 0) return false;
|
||||||
|
if (message.from === 'system') return false;
|
||||||
|
return message.source === 'lead_session' || message.source === 'lead_process';
|
||||||
|
}
|
||||||
|
|
||||||
|
function annotateSlashCommandResponses(messages: InboxMessage[]): void {
|
||||||
|
let pendingSlash = null as InboxMessage['slashCommand'] | null;
|
||||||
|
|
||||||
|
for (const message of messages) {
|
||||||
|
const slashCommand =
|
||||||
|
message.source === 'user_sent'
|
||||||
|
? (message.slashCommand ?? buildStandaloneSlashCommandMeta(message.text))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (slashCommand) {
|
||||||
|
pendingSlash = slashCommand;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pendingSlash) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.messageKind === 'slash_command_result') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLeadThoughtCandidateForSlashResult(message)) {
|
||||||
|
message.messageKind = 'slash_command_result';
|
||||||
|
message.commandOutput = {
|
||||||
|
stream: 'stdout',
|
||||||
|
commandLabel: pendingSlash.command,
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingSlash = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function linkPassiveUserReplySummaries(messages: InboxMessage[]): InboxMessage[] {
|
||||||
|
const canonicalReplies = messages
|
||||||
|
.map((message) => {
|
||||||
|
const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : '';
|
||||||
|
if (!messageId || message.to !== 'user') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (classifyIdleNotificationText(message.text)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const time = Date.parse(message.timestamp);
|
||||||
|
if (!Number.isFinite(time)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
messageId,
|
||||||
|
from: message.from,
|
||||||
|
time,
|
||||||
|
normalizedSummary: normalizePassiveUserReplyLinkText(message.summary),
|
||||||
|
normalizedText: normalizePassiveUserReplyLinkText(message.text),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((value): value is NonNullable<typeof value> => value !== null);
|
||||||
|
|
||||||
|
if (canonicalReplies.length === 0) {
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
let didLink = false;
|
||||||
|
const linkedMessages = messages.map((message) => {
|
||||||
|
if (
|
||||||
|
typeof message.relayOfMessageId === 'string' &&
|
||||||
|
message.relayOfMessageId.trim().length > 0
|
||||||
|
) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = extractPassiveUserPeerSummaryBody(message.text);
|
||||||
|
if (!body) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
const passiveTime = Date.parse(message.timestamp);
|
||||||
|
if (!Number.isFinite(passiveTime)) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedBody = normalizePassiveUserReplyLinkText(body);
|
||||||
|
if (!normalizedBody) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = canonicalReplies.filter((candidate) => {
|
||||||
|
if (candidate.from !== message.from) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const deltaMs = passiveTime - candidate.time;
|
||||||
|
if (deltaMs < 0 || deltaMs > PASSIVE_USER_REPLY_LINK_WINDOW_MS) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (candidate.normalizedSummary === normalizedBody) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return normalizedBody.length >= 6 && candidate.normalizedText.includes(normalizedBody);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (matches.length !== 1) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
didLink = true;
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
relayOfMessageId: matches[0].messageId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return didLink ? linkedMessages : messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeLeadProcessCopies(
|
||||||
|
messages: InboxMessage[],
|
||||||
|
leadTexts: readonly InboxMessage[]
|
||||||
|
): InboxMessage[] {
|
||||||
|
if (leadTexts.length === 0) {
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n');
|
||||||
|
const getFingerprint = (msg: Pick<InboxMessage, 'from' | 'text' | 'leadSessionId'>) =>
|
||||||
|
`${msg.leadSessionId ?? ''}\0${msg.from}\0${normalizeText(msg.text ?? '')}`;
|
||||||
|
|
||||||
|
const leadSessionFingerprints = new Set<string>();
|
||||||
|
for (const msg of leadTexts) {
|
||||||
|
if (msg.source === 'lead_session') {
|
||||||
|
leadSessionFingerprints.add(getFingerprint(msg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages.filter((message) => {
|
||||||
|
if (message.source !== 'lead_process') return true;
|
||||||
|
if (message.to) return true;
|
||||||
|
return !leadSessionFingerprints.has(getFingerprint(message));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function choosePreferredMessage(current: InboxMessage, candidate: InboxMessage): InboxMessage {
|
||||||
|
const score = (msg: InboxMessage): number => {
|
||||||
|
let value = 0;
|
||||||
|
if (msg.source !== 'lead_process') value += 4;
|
||||||
|
if (msg.read === false) value += 2;
|
||||||
|
if (msg.relayOfMessageId) value += 1;
|
||||||
|
if (msg.summary) value += 1;
|
||||||
|
if (msg.to) value += 1;
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentScore = score(current);
|
||||||
|
const candidateScore = score(candidate);
|
||||||
|
if (candidateScore !== currentScore) {
|
||||||
|
return candidateScore > currentScore ? candidate : current;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentTs = Date.parse(current.timestamp);
|
||||||
|
const candidateTs = Date.parse(candidate.timestamp);
|
||||||
|
if (Number.isFinite(currentTs) && Number.isFinite(candidateTs) && candidateTs !== currentTs) {
|
||||||
|
return candidateTs > currentTs ? candidate : current;
|
||||||
|
}
|
||||||
|
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeByMessageId(messages: InboxMessage[]): InboxMessage[] {
|
||||||
|
const dedupedById = new Map<string, InboxMessage>();
|
||||||
|
const dedupedWithoutId: InboxMessage[] = [];
|
||||||
|
|
||||||
|
for (const message of messages) {
|
||||||
|
const id = typeof message.messageId === 'string' ? message.messageId.trim() : '';
|
||||||
|
if (!id) {
|
||||||
|
dedupedWithoutId.push(message);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const existing = dedupedById.get(id);
|
||||||
|
if (!existing) {
|
||||||
|
dedupedById.set(id, message);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
dedupedById.set(id, choosePreferredMessage(existing, message));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...dedupedWithoutId, ...dedupedById.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureEffectiveMessageIds(messages: InboxMessage[]): InboxMessage[] {
|
||||||
|
let changed = false;
|
||||||
|
const normalized = messages.map((message) => {
|
||||||
|
const effectiveMessageId = getEffectiveInboxMessageId(message);
|
||||||
|
if (!effectiveMessageId || effectiveMessageId === message.messageId) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
changed = true;
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
messageId: effectiveMessageId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return changed ? normalized : messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachLeadSessionIds(config: TeamConfig, messages: InboxMessage[]): void {
|
||||||
|
if (!config.leadSessionId && !messages.some((message) => message.leadSessionId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
|
||||||
|
const anchors: { time: number; sessionId: string }[] = [];
|
||||||
|
for (const message of messages) {
|
||||||
|
if (message.leadSessionId) {
|
||||||
|
anchors.push({ time: Date.parse(message.timestamp), sessionId: message.leadSessionId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anchors.length > 0) {
|
||||||
|
for (const message of messages) {
|
||||||
|
if (message.leadSessionId) continue;
|
||||||
|
const messageTime = Date.parse(message.timestamp);
|
||||||
|
let best = anchors[0];
|
||||||
|
let bestDistance = Math.abs(messageTime - best.time);
|
||||||
|
for (const anchor of anchors) {
|
||||||
|
const distance = Math.abs(messageTime - anchor.time);
|
||||||
|
if (distance < bestDistance) {
|
||||||
|
bestDistance = distance;
|
||||||
|
best = anchor;
|
||||||
|
} else if (distance > bestDistance && anchor.time > messageTime) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
message.leadSessionId = best.sessionId;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.leadSessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const message of messages) {
|
||||||
|
message.leadSessionId = config.leadSessionId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toFeedRevision(messages: readonly InboxMessage[]): string {
|
||||||
|
const stableMessages = messages.map((message) => ({
|
||||||
|
messageId: message.messageId ?? null,
|
||||||
|
relayOfMessageId: message.relayOfMessageId ?? null,
|
||||||
|
from: message.from,
|
||||||
|
to: message.to ?? null,
|
||||||
|
text: message.text,
|
||||||
|
timestamp: message.timestamp,
|
||||||
|
read: message.read,
|
||||||
|
summary: message.summary ?? null,
|
||||||
|
color: message.color ?? null,
|
||||||
|
source: message.source ?? null,
|
||||||
|
attachments: message.attachments ?? null,
|
||||||
|
leadSessionId: message.leadSessionId ?? null,
|
||||||
|
conversationId: message.conversationId ?? null,
|
||||||
|
replyToConversationId: message.replyToConversationId ?? null,
|
||||||
|
toolSummary: message.toolSummary ?? null,
|
||||||
|
toolCalls: message.toolCalls ?? null,
|
||||||
|
messageKind: message.messageKind ?? null,
|
||||||
|
slashCommand: message.slashCommand ?? null,
|
||||||
|
commandOutput: message.commandOutput ?? null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return createHash('sha256').update(JSON.stringify(stableMessages)).digest('hex').slice(0, 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TeamMessageFeedService {
|
||||||
|
private readonly cacheByTeam = new Map<string, TeamMessageFeedCacheEntry>();
|
||||||
|
private readonly dirtyTeams = new Set<string>();
|
||||||
|
|
||||||
|
constructor(private readonly deps: TeamMessageFeedDeps) {}
|
||||||
|
|
||||||
|
invalidate(teamName: string): void {
|
||||||
|
this.dirtyTeams.add(teamName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFeed(teamName: string): Promise<TeamNormalizedMessageFeed> {
|
||||||
|
const cached = this.cacheByTeam.get(teamName);
|
||||||
|
if (cached && !this.dirtyTeams.has(teamName)) {
|
||||||
|
return {
|
||||||
|
teamName,
|
||||||
|
feedRevision: cached.feedRevision,
|
||||||
|
messages: cached.messages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await this.deps.getConfig(teamName);
|
||||||
|
if (!config) {
|
||||||
|
const emptyEntry = { feedRevision: toFeedRevision([]), messages: [] };
|
||||||
|
this.cacheByTeam.set(teamName, emptyEntry);
|
||||||
|
this.dirtyTeams.delete(teamName);
|
||||||
|
return { teamName, ...emptyEntry };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [inboxMessages, leadTexts, sentMessages] = await Promise.all([
|
||||||
|
this.deps.getInboxMessages(teamName).catch(() => [] as InboxMessage[]),
|
||||||
|
this.deps.getLeadSessionMessages(teamName, config).catch(() => [] as InboxMessage[]),
|
||||||
|
this.deps.getSentMessages(teamName).catch(() => [] as InboxMessage[]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let messages = [...inboxMessages, ...leadTexts, ...sentMessages];
|
||||||
|
messages = dedupeLeadProcessCopies(messages, leadTexts);
|
||||||
|
messages = ensureEffectiveMessageIds(messages);
|
||||||
|
messages = dedupeByMessageId(messages);
|
||||||
|
messages = linkPassiveUserReplySummaries(messages);
|
||||||
|
attachLeadSessionIds(config, messages);
|
||||||
|
annotateSlashCommandResponses(messages);
|
||||||
|
|
||||||
|
messages.sort((left, right) => {
|
||||||
|
const diff = Date.parse(right.timestamp) - Date.parse(left.timestamp);
|
||||||
|
if (diff !== 0) return diff;
|
||||||
|
return requireCanonicalMessageId(left).localeCompare(requireCanonicalMessageId(right));
|
||||||
|
});
|
||||||
|
|
||||||
|
const feedRevision = toFeedRevision(messages);
|
||||||
|
const nextEntry =
|
||||||
|
cached?.feedRevision === feedRevision
|
||||||
|
? cached
|
||||||
|
: {
|
||||||
|
feedRevision,
|
||||||
|
messages,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.cacheByTeam.set(teamName, nextEntry);
|
||||||
|
this.dirtyTeams.delete(teamName);
|
||||||
|
return {
|
||||||
|
teamName,
|
||||||
|
feedRevision: nextEntry.feedRevision,
|
||||||
|
messages: nextEntry.messages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,4 +1,12 @@
|
||||||
import { encodePath, extractBaseDir, getProjectsBasePath } from '@main/utils/pathDecoder';
|
import { atomicWriteAsync } from '@main/utils/atomicWrite';
|
||||||
|
import { extractCwd } from '@main/utils/jsonl';
|
||||||
|
import {
|
||||||
|
encodePath,
|
||||||
|
extractBaseDir,
|
||||||
|
getProjectsBasePath,
|
||||||
|
getTeamsBasePath,
|
||||||
|
} from '@main/utils/pathDecoder';
|
||||||
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { createReadStream, type Dirent } from 'fs';
|
import { createReadStream, type Dirent } from 'fs';
|
||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
|
|
@ -15,6 +23,33 @@ const SESSION_DISCOVERY_CACHE_TTL = 30_000;
|
||||||
const TEAM_AFFINITY_SCAN_LINES = 40;
|
const TEAM_AFFINITY_SCAN_LINES = 40;
|
||||||
const ROOT_DISCOVERY_CONCURRENCY = 12;
|
const ROOT_DISCOVERY_CONCURRENCY = 12;
|
||||||
|
|
||||||
|
type ProjectEvidenceSource =
|
||||||
|
| 'projectPath'
|
||||||
|
| 'projectPathHistory'
|
||||||
|
| 'leadCwd'
|
||||||
|
| 'memberCwd'
|
||||||
|
| 'projectsScan';
|
||||||
|
|
||||||
|
interface ProjectPathCandidate {
|
||||||
|
projectPath: string;
|
||||||
|
source: Exclude<ProjectEvidenceSource, 'projectsScan'>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectDirCandidate {
|
||||||
|
projectPath: string;
|
||||||
|
projectDir: string;
|
||||||
|
projectId: string;
|
||||||
|
source: ProjectEvidenceSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionProjectMatch extends ProjectDirCandidate {
|
||||||
|
matchedSessionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScannedSessionProjectMatch = Omit<SessionProjectMatch, 'projectPath'> & {
|
||||||
|
projectPath?: string;
|
||||||
|
};
|
||||||
|
|
||||||
function trimTrailingSlashes(value: string): string {
|
function trimTrailingSlashes(value: string): string {
|
||||||
let end = value.length;
|
let end = value.length;
|
||||||
while (end > 0) {
|
while (end > 0) {
|
||||||
|
|
@ -32,6 +67,17 @@ function isSessionDirectoryName(name: string): boolean {
|
||||||
return name !== 'memory' && !name.startsWith('.');
|
return name !== 'memory' && !name.startsWith('.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeProjectPathCandidate(value: unknown): string | null {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return trimTrailingSlashes(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
function extractTextContent(entry: Record<string, unknown>): string | null {
|
function extractTextContent(entry: Record<string, unknown>): string | null {
|
||||||
if (typeof entry.content === 'string') {
|
if (typeof entry.content === 'string') {
|
||||||
return entry.content;
|
return entry.content;
|
||||||
|
|
@ -71,6 +117,9 @@ function lineMentionsTeam(text: string, teamName: string): boolean {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
|
normalizedText.includes(`team name: ${normalizedTeam}`) ||
|
||||||
|
normalizedText.includes(`team name "${normalizedTeam}"`) ||
|
||||||
|
normalizedText.includes(`team name '${normalizedTeam}'`) ||
|
||||||
normalizedText.includes(`on team "${normalizedTeam}"`) ||
|
normalizedText.includes(`on team "${normalizedTeam}"`) ||
|
||||||
normalizedText.includes(`on team '${normalizedTeam}'`) ||
|
normalizedText.includes(`on team '${normalizedTeam}'`) ||
|
||||||
normalizedText.includes(`team "${normalizedTeam}"`) ||
|
normalizedText.includes(`team "${normalizedTeam}"`) ||
|
||||||
|
|
@ -79,6 +128,28 @@ function lineMentionsTeam(text: string, teamName: string): boolean {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function entryContainsNestedTeamName(value: unknown, teamName: string, depth: number = 0): boolean {
|
||||||
|
if (!value || depth > 8 || typeof value !== 'object') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.some((item) => entryContainsNestedTeamName(item, teamName, depth + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = value as Record<string, unknown>;
|
||||||
|
if (typeof entry.teamName === 'string' && entry.teamName.trim().toLowerCase() === teamName) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.entries(entry).some(([key, nested]) => {
|
||||||
|
if (key === 'teamName') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return entryContainsNestedTeamName(nested, teamName, depth + 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function collectKnownSessionIds(config: TeamConfig): string[] {
|
function collectKnownSessionIds(config: TeamConfig): string[] {
|
||||||
const knownSessionIds = new Set<string>();
|
const knownSessionIds = new Set<string>();
|
||||||
const push = (value: unknown): void => {
|
const push = (value: unknown): void => {
|
||||||
|
|
@ -93,7 +164,8 @@ function collectKnownSessionIds(config: TeamConfig): string[] {
|
||||||
|
|
||||||
push(config.leadSessionId);
|
push(config.leadSessionId);
|
||||||
if (Array.isArray(config.sessionHistory)) {
|
if (Array.isArray(config.sessionHistory)) {
|
||||||
for (const sessionId of config.sessionHistory) {
|
for (let index = config.sessionHistory.length - 1; index >= 0; index -= 1) {
|
||||||
|
const sessionId = config.sessionHistory[index];
|
||||||
push(sessionId);
|
push(sessionId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -130,13 +202,40 @@ export class TeamTranscriptProjectResolver {
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = await this.configReader.getConfig(teamName);
|
const config = await this.configReader.getConfig(teamName);
|
||||||
if (!config?.projectPath) {
|
if (!config) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { projectDir, projectId } = await this.resolveProjectDirectory(config);
|
const resolution = await this.resolveProjectDirectory(teamName, config);
|
||||||
const sessionIds = await this.discoverSessionIds(teamName, projectDir, config);
|
if (!resolution) {
|
||||||
const value = { projectDir, projectId, config, sessionIds };
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedConfig =
|
||||||
|
resolution.effectiveProjectPath &&
|
||||||
|
trimTrailingSlashes(resolution.effectiveProjectPath) !==
|
||||||
|
trimTrailingSlashes(config.projectPath ?? '')
|
||||||
|
? {
|
||||||
|
...config,
|
||||||
|
projectPath: resolution.effectiveProjectPath,
|
||||||
|
projectPathHistory: this.buildRepairedProjectPathHistory(
|
||||||
|
config.projectPath,
|
||||||
|
config.projectPathHistory,
|
||||||
|
resolution.effectiveProjectPath
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: config;
|
||||||
|
const sessionIds = await this.discoverSessionIds(
|
||||||
|
teamName,
|
||||||
|
resolution.projectDir,
|
||||||
|
resolvedConfig
|
||||||
|
);
|
||||||
|
const value = {
|
||||||
|
projectDir: resolution.projectDir,
|
||||||
|
projectId: resolution.projectId,
|
||||||
|
config: resolvedConfig,
|
||||||
|
sessionIds,
|
||||||
|
};
|
||||||
this.contextCache.set(teamName, {
|
this.contextCache.set(teamName, {
|
||||||
value,
|
value,
|
||||||
expiresAt: Date.now() + SESSION_DISCOVERY_CACHE_TTL,
|
expiresAt: Date.now() + SESSION_DISCOVERY_CACHE_TTL,
|
||||||
|
|
@ -145,47 +244,377 @@ export class TeamTranscriptProjectResolver {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async resolveProjectDirectory(
|
private async resolveProjectDirectory(
|
||||||
|
teamName: string,
|
||||||
config: TeamConfig
|
config: TeamConfig
|
||||||
): Promise<{ projectDir: string; projectId: string }> {
|
): Promise<{ projectDir: string; projectId: string; effectiveProjectPath?: string } | null> {
|
||||||
const normalizedProjectPath = trimTrailingSlashes(config.projectPath ?? '');
|
const sessionIds = collectKnownSessionIds(config);
|
||||||
let projectId = encodePath(normalizedProjectPath);
|
const pathCandidates = this.collectProjectPathCandidates(config);
|
||||||
let projectDir = path.join(getProjectsBasePath(), extractBaseDir(projectId));
|
const currentCandidate = pathCandidates[0] ?? null;
|
||||||
|
if (sessionIds.length === 0) {
|
||||||
|
return this.buildFallbackResolution(teamName, pathCandidates);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
const rankBySessionId = new Map(sessionIds.map((sessionId, index) => [sessionId, index]));
|
||||||
const stat = await fs.stat(projectDir);
|
const getMatchRank = (match: { matchedSessionId: string } | null): number =>
|
||||||
if (!stat.isDirectory()) {
|
match
|
||||||
throw new Error('not a directory');
|
? (rankBySessionId.get(match.matchedSessionId) ?? Number.POSITIVE_INFINITY)
|
||||||
}
|
: Number.POSITIVE_INFINITY;
|
||||||
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 toResolution = (
|
||||||
const projectEntries = await fs.readdir(getProjectsBasePath(), { withFileTypes: true });
|
match: Pick<ProjectDirCandidate, 'projectDir' | 'projectId'> & { projectPath?: string }
|
||||||
for (const entry of projectEntries) {
|
): { projectDir: string; projectId: string; effectiveProjectPath?: string } => ({
|
||||||
if (!entry.isDirectory()) continue;
|
projectDir: match.projectDir,
|
||||||
const candidateDir = path.join(getProjectsBasePath(), entry.name);
|
projectId: match.projectId,
|
||||||
try {
|
...(match.projectPath ? { effectiveProjectPath: match.projectPath } : {}),
|
||||||
await fs.access(path.join(candidateDir, `${leadSessionId}.jsonl`));
|
});
|
||||||
projectDir = candidateDir;
|
|
||||||
projectId = entry.name;
|
let currentMatch: SessionProjectMatch | null = null;
|
||||||
break;
|
if (currentCandidate) {
|
||||||
} catch {
|
const resolvedCurrentMatch = await this.findMatchInProjectPathCandidate(
|
||||||
// not this project
|
currentCandidate,
|
||||||
}
|
sessionIds
|
||||||
}
|
);
|
||||||
} catch {
|
if (resolvedCurrentMatch && getMatchRank(resolvedCurrentMatch) === 0) {
|
||||||
// best-effort fallback
|
return toResolution(resolvedCurrentMatch);
|
||||||
|
}
|
||||||
|
if (resolvedCurrentMatch) {
|
||||||
|
currentMatch = resolvedCurrentMatch;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { projectDir, projectId };
|
const configuredMatches =
|
||||||
|
pathCandidates.length > 1
|
||||||
|
? await this.findMatchesInProjectPathCandidates(pathCandidates.slice(1), sessionIds)
|
||||||
|
: [];
|
||||||
|
const scannedMatches = await this.findMatchesByScanningProjects(sessionIds);
|
||||||
|
|
||||||
|
const candidateMatchesByProjectDir = new Map<
|
||||||
|
string,
|
||||||
|
SessionProjectMatch | ScannedSessionProjectMatch
|
||||||
|
>();
|
||||||
|
for (const match of configuredMatches) {
|
||||||
|
if (match.projectDir === currentMatch?.projectDir) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
candidateMatchesByProjectDir.set(match.projectDir, match);
|
||||||
|
}
|
||||||
|
for (const match of scannedMatches) {
|
||||||
|
if (match.projectDir === currentMatch?.projectDir) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!candidateMatchesByProjectDir.has(match.projectDir)) {
|
||||||
|
candidateMatchesByProjectDir.set(match.projectDir, match);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const alternateMatches = [...candidateMatchesByProjectDir.values()];
|
||||||
|
const bestAlternateRank = alternateMatches.reduce(
|
||||||
|
(best, match) => Math.min(best, getMatchRank(match)),
|
||||||
|
Number.POSITIVE_INFINITY
|
||||||
|
);
|
||||||
|
const currentRank = getMatchRank(currentMatch);
|
||||||
|
|
||||||
|
if (currentMatch && currentRank <= bestAlternateRank) {
|
||||||
|
return toResolution(currentMatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestAlternateRank !== Number.POSITIVE_INFINITY) {
|
||||||
|
const bestAlternates = alternateMatches.filter(
|
||||||
|
(match) => getMatchRank(match) === bestAlternateRank
|
||||||
|
);
|
||||||
|
if (bestAlternates.length === 1) {
|
||||||
|
const winner = bestAlternates[0];
|
||||||
|
if (winner.projectPath) {
|
||||||
|
await this.persistResolvedProjectPath(teamName, config, winner.projectPath);
|
||||||
|
}
|
||||||
|
return toResolution(winner);
|
||||||
|
}
|
||||||
|
logger.warn(
|
||||||
|
`[${teamName}] Transcript project resolution ambiguous across exact-session candidates; keeping current path`
|
||||||
|
);
|
||||||
|
return currentMatch
|
||||||
|
? toResolution(currentMatch)
|
||||||
|
: this.buildFallbackResolution(teamName, pathCandidates);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentMatch) {
|
||||||
|
return toResolution(currentMatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.buildFallbackResolution(teamName, pathCandidates);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildFallbackResolution(
|
||||||
|
teamName: string,
|
||||||
|
candidates: readonly ProjectPathCandidate[]
|
||||||
|
): Promise<{ projectDir: string; projectId: string; effectiveProjectPath?: string } | null> {
|
||||||
|
let firstResolution: {
|
||||||
|
projectDir: string;
|
||||||
|
projectId: string;
|
||||||
|
effectiveProjectPath?: string;
|
||||||
|
} | null = null;
|
||||||
|
let firstExistingResolution: {
|
||||||
|
projectDir: string;
|
||||||
|
projectId: string;
|
||||||
|
effectiveProjectPath?: string;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
for (const dirCandidate of this.buildProjectDirCandidates(candidate.projectPath)) {
|
||||||
|
const resolution = {
|
||||||
|
projectDir: dirCandidate.projectDir,
|
||||||
|
projectId: dirCandidate.projectId,
|
||||||
|
effectiveProjectPath: candidate.projectPath,
|
||||||
|
};
|
||||||
|
if (!firstResolution) {
|
||||||
|
firstResolution = resolution;
|
||||||
|
}
|
||||||
|
if (!(await this.projectDirExists(dirCandidate.projectDir))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!firstExistingResolution) {
|
||||||
|
firstExistingResolution = resolution;
|
||||||
|
}
|
||||||
|
const teamRootSessionIds = await this.listTeamRootSessionIds(
|
||||||
|
dirCandidate.projectDir,
|
||||||
|
teamName
|
||||||
|
);
|
||||||
|
if (teamRootSessionIds.length > 0) {
|
||||||
|
return resolution;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return firstExistingResolution ?? firstResolution;
|
||||||
|
}
|
||||||
|
|
||||||
|
private collectProjectPathCandidates(config: TeamConfig): ProjectPathCandidate[] {
|
||||||
|
const candidates: ProjectPathCandidate[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const push = (value: unknown, source: Exclude<ProjectEvidenceSource, 'projectsScan'>): void => {
|
||||||
|
const normalized = normalizeProjectPathCandidate(value);
|
||||||
|
if (!normalized || seen.has(normalized)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seen.add(normalized);
|
||||||
|
candidates.push({ projectPath: normalized, source });
|
||||||
|
};
|
||||||
|
|
||||||
|
push(config.projectPath, 'projectPath');
|
||||||
|
|
||||||
|
if (Array.isArray(config.projectPathHistory)) {
|
||||||
|
for (let index = config.projectPathHistory.length - 1; index >= 0; index -= 1) {
|
||||||
|
push(config.projectPathHistory[index], 'projectPathHistory');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const leadCwd = (config.members ?? []).find((member) => isLeadMember(member))?.cwd;
|
||||||
|
push(leadCwd, 'leadCwd');
|
||||||
|
|
||||||
|
const distinctMemberCwds = Array.from(
|
||||||
|
new Set(
|
||||||
|
(config.members ?? [])
|
||||||
|
.map((member) => normalizeProjectPathCandidate(member.cwd))
|
||||||
|
.filter((cwd): cwd is string => Boolean(cwd))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (distinctMemberCwds.length === 1) {
|
||||||
|
push(distinctMemberCwds[0], 'memberCwd');
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildProjectDirCandidates(projectPath: string): ProjectDirCandidate[] {
|
||||||
|
const normalizedProjectPath = trimTrailingSlashes(projectPath);
|
||||||
|
const projectId = extractBaseDir(encodePath(normalizedProjectPath));
|
||||||
|
const baseCandidates = [
|
||||||
|
{ projectDir: path.join(getProjectsBasePath(), projectId), projectId },
|
||||||
|
...(projectId.includes('_')
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
projectDir: path.join(getProjectsBasePath(), projectId.replace(/_/g, '-')),
|
||||||
|
projectId: projectId.replace(/_/g, '-'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return baseCandidates
|
||||||
|
.filter((candidate) => {
|
||||||
|
if (seen.has(candidate.projectDir)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
seen.add(candidate.projectDir);
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map((candidate) => ({
|
||||||
|
projectPath: normalizedProjectPath,
|
||||||
|
projectDir: candidate.projectDir,
|
||||||
|
projectId: candidate.projectId,
|
||||||
|
source: 'projectPath' as const,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findMatchInProjectPathCandidate(
|
||||||
|
candidate: ProjectPathCandidate,
|
||||||
|
sessionIds: string[]
|
||||||
|
): Promise<SessionProjectMatch | null> {
|
||||||
|
const rankBySessionId = new Map(sessionIds.map((sessionId, index) => [sessionId, index]));
|
||||||
|
let bestMatch: SessionProjectMatch | null = null;
|
||||||
|
|
||||||
|
for (const projectCandidate of this.buildProjectDirCandidates(candidate.projectPath)) {
|
||||||
|
const matchedSessionId = await this.findMatchingSessionId(
|
||||||
|
projectCandidate.projectDir,
|
||||||
|
sessionIds
|
||||||
|
);
|
||||||
|
if (!matchedSessionId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const match = {
|
||||||
|
...projectCandidate,
|
||||||
|
source: candidate.source,
|
||||||
|
matchedSessionId,
|
||||||
|
};
|
||||||
|
const matchRank = rankBySessionId.get(match.matchedSessionId) ?? Number.POSITIVE_INFINITY;
|
||||||
|
const bestRank = bestMatch
|
||||||
|
? (rankBySessionId.get(bestMatch.matchedSessionId) ?? Number.POSITIVE_INFINITY)
|
||||||
|
: Number.POSITIVE_INFINITY;
|
||||||
|
if (!bestMatch || matchRank < bestRank) {
|
||||||
|
bestMatch = match;
|
||||||
|
}
|
||||||
|
if (matchRank === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findMatchesInProjectPathCandidates(
|
||||||
|
candidates: ProjectPathCandidate[],
|
||||||
|
sessionIds: string[]
|
||||||
|
): Promise<SessionProjectMatch[]> {
|
||||||
|
const matches: SessionProjectMatch[] = [];
|
||||||
|
const seenProjectDirs = new Set<string>();
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const match = await this.findMatchInProjectPathCandidate(candidate, sessionIds);
|
||||||
|
if (!match || seenProjectDirs.has(match.projectDir)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seenProjectDirs.add(match.projectDir);
|
||||||
|
matches.push(match);
|
||||||
|
}
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findMatchingSessionId(
|
||||||
|
projectDir: string,
|
||||||
|
sessionIds: string[]
|
||||||
|
): Promise<string | null> {
|
||||||
|
for (const sessionId of sessionIds) {
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(path.join(projectDir, `${sessionId}.jsonl`));
|
||||||
|
if (stat.isFile()) {
|
||||||
|
return sessionId;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findMatchesByScanningProjects(
|
||||||
|
sessionIds: string[]
|
||||||
|
): Promise<ScannedSessionProjectMatch[]> {
|
||||||
|
let projectEntries: Dirent[];
|
||||||
|
try {
|
||||||
|
projectEntries = await fs.readdir(getProjectsBasePath(), { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const directories = projectEntries.filter((entry) => entry.isDirectory());
|
||||||
|
const matches: ScannedSessionProjectMatch[] = [];
|
||||||
|
let nextIndex = 0;
|
||||||
|
|
||||||
|
const worker = async (): Promise<void> => {
|
||||||
|
while (nextIndex < directories.length) {
|
||||||
|
const index = nextIndex++;
|
||||||
|
const entry = directories[index];
|
||||||
|
const projectDir = path.join(getProjectsBasePath(), entry.name);
|
||||||
|
const matchedSessionId = await this.findMatchingSessionId(projectDir, sessionIds);
|
||||||
|
if (!matchedSessionId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const jsonlPath = path.join(projectDir, `${matchedSessionId}.jsonl`);
|
||||||
|
const cwd = await extractCwd(jsonlPath);
|
||||||
|
matches.push({
|
||||||
|
projectPath: cwd ?? undefined,
|
||||||
|
projectDir,
|
||||||
|
projectId: entry.name,
|
||||||
|
source: 'projectsScan',
|
||||||
|
matchedSessionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: Math.min(ROOT_DISCOVERY_CONCURRENCY, directories.length) }, () =>
|
||||||
|
worker()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const deduped = new Map<string, ScannedSessionProjectMatch>();
|
||||||
|
for (const match of matches) {
|
||||||
|
if (!deduped.has(match.projectDir)) {
|
||||||
|
deduped.set(match.projectDir, match);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...deduped.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persistResolvedProjectPath(
|
||||||
|
teamName: string,
|
||||||
|
config: TeamConfig,
|
||||||
|
nextProjectPath: string
|
||||||
|
): Promise<void> {
|
||||||
|
const normalizedNextPath = normalizeProjectPathCandidate(nextProjectPath);
|
||||||
|
if (!normalizedNextPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentProjectPath = normalizeProjectPathCandidate(config.projectPath);
|
||||||
|
if (currentProjectPath === normalizedNextPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(configPath, 'utf8');
|
||||||
|
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||||
|
const rawProjectPath =
|
||||||
|
normalizeProjectPathCandidate(parsed.projectPath) ?? currentProjectPath ?? null;
|
||||||
|
|
||||||
|
parsed.projectPath = normalizedNextPath;
|
||||||
|
|
||||||
|
parsed.projectPathHistory = this.buildRepairedProjectPathHistory(
|
||||||
|
rawProjectPath,
|
||||||
|
parsed.projectPathHistory,
|
||||||
|
normalizedNextPath
|
||||||
|
);
|
||||||
|
await atomicWriteAsync(configPath, JSON.stringify(parsed, null, 2));
|
||||||
|
logger.info(
|
||||||
|
`[${teamName}] Repaired transcript projectPath via exact session match: ${normalizedNextPath}`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
`[${teamName}] Failed to persist repaired transcript projectPath: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async discoverSessionIds(
|
private async discoverSessionIds(
|
||||||
|
|
@ -199,42 +628,95 @@ export class TeamTranscriptProjectResolver {
|
||||||
this.listSessionDirIds(projectDir),
|
this.listSessionDirIds(projectDir),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return Array.from(new Set([...knownSessionIds, ...teamRootSessionIds, ...sessionDirIds])).sort(
|
const orderedSessionIds: string[] = [];
|
||||||
(left, right) => left.localeCompare(right)
|
const seen = new Set<string>();
|
||||||
);
|
const push = (sessionId: string): void => {
|
||||||
|
if (seen.has(sessionId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seen.add(sessionId);
|
||||||
|
orderedSessionIds.push(sessionId);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const sessionId of knownSessionIds) {
|
||||||
|
push(sessionId);
|
||||||
|
}
|
||||||
|
for (const sessionId of [...teamRootSessionIds, ...sessionDirIds].sort((left, right) =>
|
||||||
|
left.localeCompare(right)
|
||||||
|
)) {
|
||||||
|
push(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return orderedSessionIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildRepairedProjectPathHistory(
|
||||||
|
currentProjectPath: unknown,
|
||||||
|
rawProjectPathHistory: unknown,
|
||||||
|
nextProjectPath: string
|
||||||
|
): string[] {
|
||||||
|
const normalizedNextPath = normalizeProjectPathCandidate(nextProjectPath);
|
||||||
|
const history: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const pushHistory = (value: unknown): void => {
|
||||||
|
const normalized = normalizeProjectPathCandidate(value);
|
||||||
|
if (!normalized || normalized === normalizedNextPath || seen.has(normalized)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seen.add(normalized);
|
||||||
|
history.push(normalized);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (Array.isArray(rawProjectPathHistory)) {
|
||||||
|
for (const value of rawProjectPathHistory) {
|
||||||
|
pushHistory(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pushHistory(currentProjectPath);
|
||||||
|
|
||||||
|
return history.slice(-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async projectDirExists(projectDir: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(projectDir);
|
||||||
|
return stat.isDirectory();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readProjectDirEntries(projectDir: string): Promise<Dirent[] | null> {
|
||||||
|
try {
|
||||||
|
return await fs.readdir(projectDir, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
logger.debug(`Cannot read transcript project dir: ${projectDir}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async listSessionDirIds(projectDir: string): Promise<string[]> {
|
private async listSessionDirIds(projectDir: string): Promise<string[]> {
|
||||||
try {
|
const dirEntries = await this.readProjectDirEntries(projectDir);
|
||||||
const dirEntries = await fs.readdir(projectDir, { withFileTypes: true });
|
if (!dirEntries) {
|
||||||
return dirEntries
|
|
||||||
.filter((entry) => entry.isDirectory() && isSessionDirectoryName(entry.name))
|
|
||||||
.map((entry) => entry.name);
|
|
||||||
} catch {
|
|
||||||
logger.debug(`Cannot read transcript project dir: ${projectDir}`);
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return dirEntries
|
||||||
|
.filter((entry) => entry.isDirectory() && isSessionDirectoryName(entry.name))
|
||||||
|
.map((entry) => entry.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async listTeamRootSessionIds(projectDir: string, teamName: string): Promise<string[]> {
|
private async collectRootJsonlSessionIds(
|
||||||
let dirEntries: Dirent[];
|
rootJsonlEntries: Dirent[],
|
||||||
try {
|
projectDir: string,
|
||||||
dirEntries = await fs.readdir(projectDir, { withFileTypes: true });
|
teamName: string
|
||||||
} catch {
|
): Promise<string[]> {
|
||||||
logger.debug(`Cannot read transcript project dir: ${projectDir}`);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const rootJsonlEntries = dirEntries.filter(
|
|
||||||
(entry) => entry.isFile() && entry.name.endsWith('.jsonl')
|
|
||||||
);
|
|
||||||
const discovered = new Set<string>();
|
const discovered = new Set<string>();
|
||||||
let nextIndex = 0;
|
let nextIndex = 0;
|
||||||
|
|
||||||
const worker = async (): Promise<void> => {
|
const scanNextRootEntry = async (): Promise<void> => {
|
||||||
while (nextIndex < rootJsonlEntries.length) {
|
while (nextIndex < rootJsonlEntries.length) {
|
||||||
const index = nextIndex++;
|
const entry = rootJsonlEntries[nextIndex++];
|
||||||
const entry = rootJsonlEntries[index];
|
|
||||||
const filePath = path.join(projectDir, entry.name);
|
const filePath = path.join(projectDir, entry.name);
|
||||||
if (!(await this.fileBelongsToTeam(filePath, teamName))) {
|
if (!(await this.fileBelongsToTeam(filePath, teamName))) {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -245,13 +727,25 @@ export class TeamTranscriptProjectResolver {
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
Array.from({ length: Math.min(ROOT_DISCOVERY_CONCURRENCY, rootJsonlEntries.length) }, () =>
|
Array.from({ length: Math.min(ROOT_DISCOVERY_CONCURRENCY, rootJsonlEntries.length) }, () =>
|
||||||
worker()
|
scanNextRootEntry()
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
return [...discovered];
|
return [...discovered];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async listTeamRootSessionIds(projectDir: string, teamName: string): Promise<string[]> {
|
||||||
|
const dirEntries = await this.readProjectDirEntries(projectDir);
|
||||||
|
if (!dirEntries) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootJsonlEntries = dirEntries.filter(
|
||||||
|
(entry) => entry.isFile() && entry.name.endsWith('.jsonl')
|
||||||
|
);
|
||||||
|
return this.collectRootJsonlSessionIds(rootJsonlEntries, projectDir, teamName);
|
||||||
|
}
|
||||||
|
|
||||||
private async fileBelongsToTeam(filePath: string, teamName: string): Promise<boolean> {
|
private async fileBelongsToTeam(filePath: string, teamName: string): Promise<boolean> {
|
||||||
const stream = createReadStream(filePath, { encoding: 'utf8' });
|
const stream = createReadStream(filePath, { encoding: 'utf8' });
|
||||||
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
||||||
|
|
@ -272,6 +766,9 @@ export class TeamTranscriptProjectResolver {
|
||||||
if (directTeamName === normalizedTeam) {
|
if (directTeamName === normalizedTeam) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (entryContainsNestedTeamName(entry, normalizedTeam)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const textContent = extractTextContent(entry);
|
const textContent = extractTextContent(entry);
|
||||||
if (textContent && lineMentionsTeam(textContent, normalizedTeam)) {
|
if (textContent && lineMentionsTeam(textContent, normalizedTeam)) {
|
||||||
|
|
|
||||||
|
|
@ -41,3 +41,17 @@ export function getCliFlavorUiOptions(flavor: CliFlavor): CliFlavorUiOptions {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getCliFlavorCommandLabel(flavor: CliFlavor): string {
|
||||||
|
switch (flavor) {
|
||||||
|
case 'agent_teams_orchestrator':
|
||||||
|
return 'orchestrator-cli';
|
||||||
|
case 'claude':
|
||||||
|
default:
|
||||||
|
return 'claude';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConfiguredCliCommandLabel(): string {
|
||||||
|
return getCliFlavorCommandLabel(getConfiguredCliFlavor());
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,9 @@
|
||||||
|
export {
|
||||||
|
AutoResumeService,
|
||||||
|
clearAutoResumeService,
|
||||||
|
getAutoResumeService,
|
||||||
|
initializeAutoResumeService,
|
||||||
|
} from './AutoResumeService';
|
||||||
export { BranchStatusService } from './BranchStatusService';
|
export { BranchStatusService } from './BranchStatusService';
|
||||||
export { CascadeGuard } from './CascadeGuard';
|
export { CascadeGuard } from './CascadeGuard';
|
||||||
export { ChangeExtractorService } from './ChangeExtractorService';
|
export { ChangeExtractorService } from './ChangeExtractorService';
|
||||||
|
|
|
||||||
73
src/main/services/team/mergeLiveLeadProcessMessages.ts
Normal file
73
src/main/services/team/mergeLiveLeadProcessMessages.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import type { InboxMessage } from '@shared/types';
|
||||||
|
|
||||||
|
export function getLiveLeadProcessMessageKey(message: {
|
||||||
|
messageId?: string;
|
||||||
|
timestamp: string;
|
||||||
|
from: string;
|
||||||
|
text: string;
|
||||||
|
}): string {
|
||||||
|
if (typeof message.messageId === 'string' && message.messageId.trim().length > 0) {
|
||||||
|
return message.messageId;
|
||||||
|
}
|
||||||
|
return `${message.timestamp}\0${message.from}\0${(message.text ?? '').slice(0, 80)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeLiveLeadProcessMessages(
|
||||||
|
durableMessages: InboxMessage[],
|
||||||
|
liveMessages: InboxMessage[]
|
||||||
|
): InboxMessage[] {
|
||||||
|
if (liveMessages.length === 0) {
|
||||||
|
return durableMessages;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n');
|
||||||
|
const isLeadThoughtLike = (msg: { source?: unknown; to?: string }): boolean =>
|
||||||
|
!msg.to && (msg.source === 'lead_process' || msg.source === 'lead_session');
|
||||||
|
const getLeadThoughtFingerprint = (msg: {
|
||||||
|
from: string;
|
||||||
|
text: string;
|
||||||
|
leadSessionId?: string;
|
||||||
|
}): string => `${msg.leadSessionId ?? ''}\0${msg.from}\0${normalizeText(msg.text)}`;
|
||||||
|
|
||||||
|
const existingTextFingerprints = new Set<string>();
|
||||||
|
for (const msg of durableMessages) {
|
||||||
|
if (typeof msg.from !== 'string' || typeof msg.text !== 'string') continue;
|
||||||
|
if (!isLeadThoughtLike(msg)) continue;
|
||||||
|
existingTextFingerprints.add(getLeadThoughtFingerprint(msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
const leadProcessTextFingerprints = new Set<string>();
|
||||||
|
const contentSeen = new Map<string, number>();
|
||||||
|
const merged: InboxMessage[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const msg of [...durableMessages, ...liveMessages]) {
|
||||||
|
if (msg.source === 'lead_process' && !msg.to) {
|
||||||
|
const fp = getLeadThoughtFingerprint(msg);
|
||||||
|
if (existingTextFingerprints.has(fp) || leadProcessTextFingerprints.has(fp)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
leadProcessTextFingerprints.add(fp);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof msg.to === 'string' && msg.to.trim().length > 0) {
|
||||||
|
const contentFp = `${msg.from}\0${msg.to}\0${(msg.text ?? '').replace(/\s+/g, ' ').slice(0, 100)}`;
|
||||||
|
const msgMs = Date.parse(msg.timestamp);
|
||||||
|
const existingMs = contentSeen.get(contentFp);
|
||||||
|
if (existingMs !== undefined && Math.abs(msgMs - existingMs) <= 5000) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
contentSeen.set(contentFp, msgMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = getLiveLeadProcessMessageKey(msg);
|
||||||
|
if (seen.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
merged.push(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
merged.sort((left, right) => Date.parse(right.timestamp) - Date.parse(left.timestamp));
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
52
src/main/services/team/progressPayload.ts
Normal file
52
src/main/services/team/progressPayload.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
/**
|
||||||
|
* Helpers that shape provisioning progress payloads before they are emitted
|
||||||
|
* to the renderer over IPC.
|
||||||
|
*
|
||||||
|
* Rationale: the renderer only renders a small "tail" preview of CLI logs
|
||||||
|
* and assistant output in ProvisioningProgressBlock / CliLogsRichView. Sending
|
||||||
|
* the full accumulated history on every throttled progress tick (≈ every
|
||||||
|
* second under load) serialized a multi-megabyte string over IPC and forced
|
||||||
|
* Zustand to produce a new immutable state object — which triggered renderer
|
||||||
|
* V8 OOM crashes for users with long-running teams. These helpers keep the
|
||||||
|
* hot emission path bounded while leaving the full history in-process for
|
||||||
|
* diagnostics and completion-time reports.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const PROGRESS_LOG_TAIL_LINES = 200;
|
||||||
|
export const PROGRESS_OUTPUT_TAIL_PARTS = 20;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the trailing `maxLines` of a line-buffered CLI log, joined with "\n"
|
||||||
|
* and trimmed. Returns `undefined` when the tail is empty so callers can
|
||||||
|
* skip emitting a noop update.
|
||||||
|
*/
|
||||||
|
export function buildProgressLogsTail(
|
||||||
|
lines: readonly string[],
|
||||||
|
maxLines: number = PROGRESS_LOG_TAIL_LINES
|
||||||
|
): string | undefined {
|
||||||
|
if (lines.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const effectiveMax = Math.max(1, maxLines);
|
||||||
|
const tail = lines.length > effectiveMax ? lines.slice(-effectiveMax) : lines;
|
||||||
|
const joined = tail.join('\n').trim();
|
||||||
|
return joined.length === 0 ? undefined : joined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the trailing `maxParts` of assistant output parts joined with a
|
||||||
|
* blank line, matching the renderer's rendering contract. Returns `undefined`
|
||||||
|
* when no parts are available.
|
||||||
|
*/
|
||||||
|
export function buildProgressAssistantOutput(
|
||||||
|
parts: readonly string[],
|
||||||
|
maxParts: number = PROGRESS_OUTPUT_TAIL_PARTS
|
||||||
|
): string | undefined {
|
||||||
|
if (parts.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const effectiveMax = Math.max(1, maxParts);
|
||||||
|
const tail = parts.length > effectiveMax ? parts.slice(-effectiveMax) : parts;
|
||||||
|
const joined = tail.join('\n\n');
|
||||||
|
return joined.trim().length === 0 ? undefined : joined;
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import { extractToolCalls, extractToolResults } from '@main/utils/toolExtraction';
|
import { extractToolCalls, extractToolResults } from '@main/utils/toolExtraction';
|
||||||
|
import { isLeadMember as isLeadMemberCheck } from '@shared/utils/leadDetection';
|
||||||
|
|
||||||
|
import { TeamTaskReader } from '../../TeamTaskReader';
|
||||||
import { BoardTaskActivityRecordSource } from '../activity/BoardTaskActivityRecordSource';
|
import { BoardTaskActivityRecordSource } from '../activity/BoardTaskActivityRecordSource';
|
||||||
|
import { TeamTranscriptSourceLocator } from '../discovery/TeamTranscriptSourceLocator';
|
||||||
import { BoardTaskExactLogChunkBuilder } from '../exact/BoardTaskExactLogChunkBuilder';
|
import { BoardTaskExactLogChunkBuilder } from '../exact/BoardTaskExactLogChunkBuilder';
|
||||||
import { BoardTaskExactLogDetailSelector } from '../exact/BoardTaskExactLogDetailSelector';
|
import { BoardTaskExactLogDetailSelector } from '../exact/BoardTaskExactLogDetailSelector';
|
||||||
import { BoardTaskExactLogStrictParser } from '../exact/BoardTaskExactLogStrictParser';
|
import { BoardTaskExactLogStrictParser } from '../exact/BoardTaskExactLogStrictParser';
|
||||||
|
|
@ -16,12 +19,15 @@ import type {
|
||||||
BoardTaskLogParticipant,
|
BoardTaskLogParticipant,
|
||||||
BoardTaskLogSegment,
|
BoardTaskLogSegment,
|
||||||
BoardTaskLogStreamResponse,
|
BoardTaskLogStreamResponse,
|
||||||
|
BoardTaskLogStreamSummary,
|
||||||
|
TeamTask,
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
|
|
||||||
interface StreamSlice {
|
interface StreamSlice {
|
||||||
id: string;
|
id: string;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
filePath: string;
|
filePath: string;
|
||||||
|
sortOrder?: number;
|
||||||
participantKey: string;
|
participantKey: string;
|
||||||
actor: BoardTaskLogActor;
|
actor: BoardTaskLogActor;
|
||||||
actionCategory?: BoardTaskActivityCategory;
|
actionCategory?: BoardTaskActivityCategory;
|
||||||
|
|
@ -37,6 +43,22 @@ interface MergedMessageAccumulator {
|
||||||
toolUseResults: ToolUseResultData[];
|
toolUseResults: ToolUseResultData[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TimeWindow {
|
||||||
|
startMs: number;
|
||||||
|
endMs: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StreamLayout {
|
||||||
|
participants: BoardTaskLogParticipant[];
|
||||||
|
visibleSlices: StreamSlice[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const BOARD_MCP_TOOL_PREFIXES = ['mcp__agent-teams__', 'mcp__agent_teams__'] as const;
|
||||||
|
const INFERRED_WINDOW_GRACE_BEFORE_MS = 30_000;
|
||||||
|
const INFERRED_WINDOW_GRACE_AFTER_MS = 15_000;
|
||||||
|
const INFERRED_RECORD_RANGE_BEFORE_MS = 5 * 60_000;
|
||||||
|
const INFERRED_RECORD_RANGE_AFTER_MS = 60_000;
|
||||||
|
|
||||||
function emptyResponse(): BoardTaskLogStreamResponse {
|
function emptyResponse(): BoardTaskLogStreamResponse {
|
||||||
return {
|
return {
|
||||||
participants: [],
|
participants: [],
|
||||||
|
|
@ -45,10 +67,22 @@ function emptyResponse(): BoardTaskLogStreamResponse {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function emptySummary(): BoardTaskLogStreamSummary {
|
||||||
|
return {
|
||||||
|
segmentCount: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeMemberName(value: string): string {
|
function normalizeMemberName(value: string): string {
|
||||||
return value.trim().toLowerCase();
|
return value.trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isBoardMcpToolName(toolName: string | undefined): boolean {
|
||||||
|
if (!toolName) return false;
|
||||||
|
const normalized = toolName.trim().toLowerCase();
|
||||||
|
return BOARD_MCP_TOOL_PREFIXES.some((prefix) => normalized.startsWith(prefix));
|
||||||
|
}
|
||||||
|
|
||||||
function toStreamActor(detail: BoardTaskExactLogDetailCandidate['actor']): BoardTaskLogActor {
|
function toStreamActor(detail: BoardTaskExactLogDetailCandidate['actor']): BoardTaskLogActor {
|
||||||
return {
|
return {
|
||||||
...(detail.memberName ? { memberName: detail.memberName } : {}),
|
...(detail.memberName ? { memberName: detail.memberName } : {}),
|
||||||
|
|
@ -139,14 +173,47 @@ function extractBoardToolOutputText(
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizedToolName = toolName.trim().toLowerCase();
|
||||||
const payload = parsedPayload as Record<string, unknown>;
|
const payload = parsedPayload as Record<string, unknown>;
|
||||||
if (toolName === 'task_add_comment' || toolName === 'task_get_comment') {
|
if (normalizedToolName === 'task_add_comment' || normalizedToolName === 'task_get_comment') {
|
||||||
const comment = payload.comment as Record<string, unknown> | undefined;
|
const comment = payload.comment as Record<string, unknown> | undefined;
|
||||||
if (typeof comment?.text === 'string' && comment.text.trim().length > 0) {
|
if (typeof comment?.text === 'string' && comment.text.trim().length > 0) {
|
||||||
return comment.text;
|
return comment.text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (normalizedToolName === 'sendmessage') {
|
||||||
|
const routing = payload.routing as Record<string, unknown> | undefined;
|
||||||
|
const deliveryMessage =
|
||||||
|
typeof payload.message === 'string' && payload.message.trim().length > 0
|
||||||
|
? payload.message.trim()
|
||||||
|
: null;
|
||||||
|
const summary =
|
||||||
|
typeof routing?.summary === 'string' && routing.summary.trim().length > 0
|
||||||
|
? routing.summary.trim()
|
||||||
|
: null;
|
||||||
|
const target =
|
||||||
|
typeof routing?.target === 'string' && routing.target.trim().length > 0
|
||||||
|
? routing.target.trim()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (deliveryMessage && summary) {
|
||||||
|
return `${deliveryMessage} - ${summary}`;
|
||||||
|
}
|
||||||
|
if (summary && target) {
|
||||||
|
return `Message sent to ${target} - ${summary}`;
|
||||||
|
}
|
||||||
|
if (summary) {
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
if (deliveryMessage) {
|
||||||
|
return deliveryMessage;
|
||||||
|
}
|
||||||
|
if (target) {
|
||||||
|
return `Message sent to ${target}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -267,12 +334,67 @@ function sanitizeToolResultContent(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeToolResultPayloadValue(
|
||||||
|
value: string | unknown[],
|
||||||
|
canonicalToolName?: string
|
||||||
|
): string | unknown[] {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const parsedPayload = parseJsonLikeString(value);
|
||||||
|
const extractedText = extractBoardToolOutputText(canonicalToolName, parsedPayload);
|
||||||
|
if (typeof extractedText === 'string') {
|
||||||
|
return extractedText;
|
||||||
|
}
|
||||||
|
return parsedPayload ? '' : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonText = collectTextBlockText(value);
|
||||||
|
const parsedPayload = parseJsonLikeString(jsonText);
|
||||||
|
const extractedText = extractBoardToolOutputText(canonicalToolName, parsedPayload);
|
||||||
|
if (typeof extractedText === 'string') {
|
||||||
|
return extractedText;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizedChildren = value
|
||||||
|
.map((child) => {
|
||||||
|
if (
|
||||||
|
typeof child === 'object' &&
|
||||||
|
child !== null &&
|
||||||
|
'type' in child &&
|
||||||
|
child.type === 'text' &&
|
||||||
|
'text' in child &&
|
||||||
|
typeof child.text === 'string'
|
||||||
|
) {
|
||||||
|
return looksLikeJsonPayload(child.text) ? null : { ...child };
|
||||||
|
}
|
||||||
|
return child;
|
||||||
|
})
|
||||||
|
.filter((child) => child !== null);
|
||||||
|
|
||||||
|
if (parsedPayload && sanitizedChildren.length === value.length) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitizedChildren.length > 0 ? sanitizedChildren : '';
|
||||||
|
}
|
||||||
|
|
||||||
function sanitizeJsonLikeToolResultPayloads(
|
function sanitizeJsonLikeToolResultPayloads(
|
||||||
messages: ParsedMessage[],
|
messages: ParsedMessage[],
|
||||||
canonicalToolName?: string
|
canonicalToolName?: string
|
||||||
): ParsedMessage[] {
|
): ParsedMessage[] {
|
||||||
return messages.map((message) => {
|
return messages.map((message) => {
|
||||||
let nextMessage = message;
|
let nextMessage = message;
|
||||||
|
let toolResultsChanged = false;
|
||||||
|
const nextToolResults = message.toolResults.map((toolResult) => {
|
||||||
|
const nextContent = sanitizeToolResultPayloadValue(toolResult.content, canonicalToolName);
|
||||||
|
if (JSON.stringify(nextContent) !== JSON.stringify(toolResult.content)) {
|
||||||
|
toolResultsChanged = true;
|
||||||
|
return {
|
||||||
|
...toolResult,
|
||||||
|
content: nextContent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return toolResult;
|
||||||
|
});
|
||||||
|
|
||||||
const rawToolUseResult = message.toolUseResult as unknown;
|
const rawToolUseResult = message.toolUseResult as unknown;
|
||||||
if (
|
if (
|
||||||
|
|
@ -366,12 +488,20 @@ function sanitizeJsonLikeToolResultPayloads(
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!changed) {
|
if (!changed) {
|
||||||
return nextMessage;
|
if (!toolResultsChanged) {
|
||||||
|
return nextMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...nextMessage,
|
||||||
|
toolResults: nextToolResults,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...nextMessage,
|
...nextMessage,
|
||||||
content: nextContent,
|
content: nextContent,
|
||||||
|
toolResults: toolResultsChanged ? nextToolResults : nextMessage.toolResults,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -691,23 +821,382 @@ function buildSegmentId(participantKey: string, slices: StreamSlice[]): string {
|
||||||
return `${participantKey}:${first?.id ?? 'start'}:${last?.id ?? 'end'}`;
|
return `${participantKey}:${first?.id ?? 'start'}:${last?.id ?? 'end'}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildToolNameByUseId(
|
||||||
|
parsedMessagesByFile: Map<string, ParsedMessage[]>
|
||||||
|
): Map<string, string> {
|
||||||
|
const toolNameByUseId = new Map<string, string>();
|
||||||
|
|
||||||
|
for (const messages of parsedMessagesByFile.values()) {
|
||||||
|
for (const message of messages) {
|
||||||
|
for (const toolCall of message.toolCalls) {
|
||||||
|
toolNameByUseId.set(toolCall.id, toolCall.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return toolNameByUseId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTaskTimeWindows(task: TeamTask, recordTimestamps: number[]): TimeWindow[] {
|
||||||
|
const windowsFromIntervals = (Array.isArray(task.workIntervals) ? task.workIntervals : [])
|
||||||
|
.map((interval) => {
|
||||||
|
const startedAt = Date.parse(interval.startedAt);
|
||||||
|
if (!Number.isFinite(startedAt)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const completedAt =
|
||||||
|
typeof interval.completedAt === 'string' ? Date.parse(interval.completedAt) : Number.NaN;
|
||||||
|
return {
|
||||||
|
startMs: startedAt - INFERRED_WINDOW_GRACE_BEFORE_MS,
|
||||||
|
endMs: Number.isFinite(completedAt) ? completedAt + INFERRED_WINDOW_GRACE_AFTER_MS : null,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((window): window is TimeWindow => window !== null);
|
||||||
|
|
||||||
|
if (windowsFromIntervals.length > 0) {
|
||||||
|
return windowsFromIntervals;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdAtMs = typeof task.createdAt === 'string' ? Date.parse(task.createdAt) : Number.NaN;
|
||||||
|
const updatedAtMs = typeof task.updatedAt === 'string' ? Date.parse(task.updatedAt) : Number.NaN;
|
||||||
|
if (Number.isFinite(createdAtMs) || Number.isFinite(updatedAtMs)) {
|
||||||
|
const startMs = Number.isFinite(createdAtMs) ? createdAtMs : updatedAtMs;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
startMs: startMs - INFERRED_WINDOW_GRACE_BEFORE_MS,
|
||||||
|
endMs: Number.isFinite(updatedAtMs) ? updatedAtMs + INFERRED_WINDOW_GRACE_AFTER_MS : null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const finiteRecordTimestamps = recordTimestamps.filter((timestamp) => Number.isFinite(timestamp));
|
||||||
|
if (finiteRecordTimestamps.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
startMs: Math.min(...finiteRecordTimestamps) - INFERRED_RECORD_RANGE_BEFORE_MS,
|
||||||
|
endMs: Math.max(...finiteRecordTimestamps) + INFERRED_RECORD_RANGE_AFTER_MS,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWithinTimeWindows(timestamp: Date, windows: TimeWindow[]): boolean {
|
||||||
|
const messageTime = timestamp.getTime();
|
||||||
|
if (!Number.isFinite(messageTime)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (windows.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
return windows.some((window) => {
|
||||||
|
const endMs = window.endMs ?? now;
|
||||||
|
return messageTime >= window.startMs && messageTime <= endMs;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectExplicitMessageIds(records: { source: { messageUuid: string } }[]): Set<string> {
|
||||||
|
return new Set(records.map((record) => record.source.messageUuid));
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectExplicitToolUseIds(
|
||||||
|
records: {
|
||||||
|
source: { toolUseId?: string };
|
||||||
|
action?: { toolUseId?: string };
|
||||||
|
}[]
|
||||||
|
): Set<string> {
|
||||||
|
const toolUseIds = new Set<string>();
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
const sourceToolUseId = record.source.toolUseId?.trim();
|
||||||
|
if (sourceToolUseId) {
|
||||||
|
toolUseIds.add(sourceToolUseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionToolUseId = record.action?.toolUseId?.trim();
|
||||||
|
if (actionToolUseId) {
|
||||||
|
toolUseIds.add(actionToolUseId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return toolUseIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectAllowedMemberNames(
|
||||||
|
task: TeamTask,
|
||||||
|
records: { actor: { memberName?: string } }[]
|
||||||
|
): Set<string> {
|
||||||
|
const allowedNames = new Set<string>();
|
||||||
|
|
||||||
|
if (typeof task.owner === 'string' && task.owner.trim().length > 0) {
|
||||||
|
allowedNames.add(normalizeMemberName(task.owner));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
if (typeof record.actor.memberName === 'string' && record.actor.memberName.trim().length > 0) {
|
||||||
|
allowedNames.add(normalizeMemberName(record.actor.memberName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allowedNames;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractMessageToolUseIds(message: ParsedMessage): Set<string> {
|
||||||
|
const toolUseIds = new Set<string>();
|
||||||
|
|
||||||
|
for (const toolCall of message.toolCalls) {
|
||||||
|
if (typeof toolCall.id === 'string' && toolCall.id.trim().length > 0) {
|
||||||
|
toolUseIds.add(toolCall.id.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const toolResult of message.toolResults) {
|
||||||
|
if (typeof toolResult.toolUseId === 'string' && toolResult.toolUseId.trim().length > 0) {
|
||||||
|
toolUseIds.add(toolResult.toolUseId.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof message.sourceToolUseID === 'string' && message.sourceToolUseID.trim().length > 0) {
|
||||||
|
toolUseIds.add(message.sourceToolUseID.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
return toolUseIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageHasNonBoardToolActivity(
|
||||||
|
message: ParsedMessage,
|
||||||
|
toolNameByUseId: Map<string, string>
|
||||||
|
): boolean {
|
||||||
|
for (const toolCall of message.toolCalls) {
|
||||||
|
if (!isBoardMcpToolName(toolCall.name)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const toolResult of message.toolResults) {
|
||||||
|
if (!isBoardMcpToolName(toolNameByUseId.get(toolResult.toolUseId))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.sourceToolUseID) {
|
||||||
|
const sourceToolName = toolNameByUseId.get(message.sourceToolUseID);
|
||||||
|
if (sourceToolName && !isBoardMcpToolName(sourceToolName)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInferredActor(message: ParsedMessage, leadName: string): BoardTaskLogActor | null {
|
||||||
|
const sessionId = message.sessionId?.trim();
|
||||||
|
if (!sessionId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberName =
|
||||||
|
typeof message.agentName === 'string' && message.agentName.trim().length > 0
|
||||||
|
? message.agentName.trim()
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const isLead =
|
||||||
|
memberName != null && normalizeMemberName(memberName) === normalizeMemberName(leadName);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(memberName ? { memberName } : {}),
|
||||||
|
role: isLead ? 'lead' : memberName ? 'member' : message.isSidechain ? 'member' : 'unknown',
|
||||||
|
sessionId,
|
||||||
|
...(message.agentId ? { agentId: message.agentId } : {}),
|
||||||
|
isSidechain: message.isSidechain,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareSlices(left: StreamSlice, right: StreamSlice): number {
|
||||||
|
const leftTs = Date.parse(left.timestamp);
|
||||||
|
const rightTs = Date.parse(right.timestamp);
|
||||||
|
if (Number.isFinite(leftTs) && Number.isFinite(rightTs) && leftTs !== rightTs) {
|
||||||
|
return leftTs - rightTs;
|
||||||
|
}
|
||||||
|
if (left.filePath !== right.filePath) {
|
||||||
|
return left.filePath.localeCompare(right.filePath);
|
||||||
|
}
|
||||||
|
if ((left.sortOrder ?? 0) !== (right.sortOrder ?? 0)) {
|
||||||
|
return (left.sortOrder ?? 0) - (right.sortOrder ?? 0);
|
||||||
|
}
|
||||||
|
return left.id.localeCompare(right.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOrderedParticipants(visibleSlices: StreamSlice[]): BoardTaskLogParticipant[] {
|
||||||
|
const participantsByKey = new Map<string, BoardTaskLogParticipant>();
|
||||||
|
const participantOrder: string[] = [];
|
||||||
|
|
||||||
|
for (const slice of visibleSlices) {
|
||||||
|
if (participantsByKey.has(slice.participantKey)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
participantsByKey.set(
|
||||||
|
slice.participantKey,
|
||||||
|
buildParticipant(slice.actor, slice.participantKey)
|
||||||
|
);
|
||||||
|
participantOrder.push(slice.participantKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
return participantOrder
|
||||||
|
.map((key) => participantsByKey.get(key))
|
||||||
|
.filter((participant): participant is BoardTaskLogParticipant => Boolean(participant))
|
||||||
|
.sort((left, right) => {
|
||||||
|
if (left.isLead && !right.isLead) return 1;
|
||||||
|
if (!left.isLead && right.isLead) return -1;
|
||||||
|
return participantOrder.indexOf(left.key) - participantOrder.indexOf(right.key);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function countSegmentsFromSlices(visibleSlices: StreamSlice[]): number {
|
||||||
|
if (visibleSlices.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let segmentCount = 1;
|
||||||
|
for (let index = 1; index < visibleSlices.length; index += 1) {
|
||||||
|
if (visibleSlices[index]?.participantKey !== visibleSlices[index - 1]?.participantKey) {
|
||||||
|
segmentCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return segmentCount;
|
||||||
|
}
|
||||||
|
|
||||||
export class BoardTaskLogStreamService {
|
export class BoardTaskLogStreamService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly recordSource: BoardTaskActivityRecordSource = new BoardTaskActivityRecordSource(),
|
private readonly recordSource: BoardTaskActivityRecordSource = new BoardTaskActivityRecordSource(),
|
||||||
private readonly summarySelector: BoardTaskExactLogSummarySelector = new BoardTaskExactLogSummarySelector(),
|
private readonly summarySelector: BoardTaskExactLogSummarySelector = new BoardTaskExactLogSummarySelector(),
|
||||||
private readonly strictParser: BoardTaskExactLogStrictParser = new BoardTaskExactLogStrictParser(),
|
private readonly strictParser: BoardTaskExactLogStrictParser = new BoardTaskExactLogStrictParser(),
|
||||||
private readonly detailSelector: BoardTaskExactLogDetailSelector = new BoardTaskExactLogDetailSelector(),
|
private readonly detailSelector: BoardTaskExactLogDetailSelector = new BoardTaskExactLogDetailSelector(),
|
||||||
private readonly chunkBuilder: BoardTaskExactLogChunkBuilder = new BoardTaskExactLogChunkBuilder()
|
private readonly chunkBuilder: BoardTaskExactLogChunkBuilder = new BoardTaskExactLogChunkBuilder(),
|
||||||
|
private readonly taskReader: TeamTaskReader = new TeamTaskReader(),
|
||||||
|
private readonly transcriptSourceLocator: TeamTranscriptSourceLocator = new TeamTranscriptSourceLocator()
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getTaskLogStream(teamName: string, taskId: string): Promise<BoardTaskLogStreamResponse> {
|
private async buildInferredExecutionSlices(
|
||||||
|
teamName: string,
|
||||||
|
taskId: string,
|
||||||
|
records: Awaited<ReturnType<BoardTaskActivityRecordSource['getTaskRecords']>>,
|
||||||
|
parsedMessagesByFile: Map<string, ParsedMessage[]>
|
||||||
|
): Promise<StreamSlice[]> {
|
||||||
|
if (records.some((record) => record.linkKind === 'execution')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const [activeTasks, deletedTasks, transcriptContext] = await Promise.all([
|
||||||
|
this.taskReader.getTasks(teamName),
|
||||||
|
this.taskReader.getDeletedTasks(teamName),
|
||||||
|
this.transcriptSourceLocator.getContext(teamName),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const task = [...activeTasks, ...deletedTasks].find((candidate) => candidate.id === taskId);
|
||||||
|
if (!task) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const transcriptFiles = transcriptContext?.transcriptFiles ?? [];
|
||||||
|
const missingFiles = transcriptFiles.filter((filePath) => !parsedMessagesByFile.has(filePath));
|
||||||
|
let mergedParsedMessagesByFile = parsedMessagesByFile;
|
||||||
|
if (missingFiles.length > 0) {
|
||||||
|
const additionalParsedMessages = await this.strictParser.parseFiles(missingFiles);
|
||||||
|
mergedParsedMessagesByFile = new Map([
|
||||||
|
...parsedMessagesByFile.entries(),
|
||||||
|
...additionalParsedMessages.entries(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolNameByUseId = buildToolNameByUseId(mergedParsedMessagesByFile);
|
||||||
|
const recordTimestamps = records.map((record) => Date.parse(record.timestamp));
|
||||||
|
const taskTimeWindows = buildTaskTimeWindows(task, recordTimestamps);
|
||||||
|
if (taskTimeWindows.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const explicitMessageIds = collectExplicitMessageIds(records);
|
||||||
|
const explicitToolUseIds = collectExplicitToolUseIds(records);
|
||||||
|
const allowedMemberNames = collectAllowedMemberNames(task, records);
|
||||||
|
const leadName =
|
||||||
|
transcriptContext?.config.members
|
||||||
|
?.find((member) => isLeadMemberCheck(member))
|
||||||
|
?.name?.trim() || 'team-lead';
|
||||||
|
|
||||||
|
const inferredSlices: StreamSlice[] = [];
|
||||||
|
for (const [filePath, messages] of mergedParsedMessagesByFile.entries()) {
|
||||||
|
for (let index = 0; index < messages.length; index += 1) {
|
||||||
|
const message = messages[index];
|
||||||
|
if (explicitMessageIds.has(message.uuid)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!isWithinTimeWindows(message.timestamp, taskTimeWindows)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actor = buildInferredActor(message, leadName);
|
||||||
|
if (!actor || !actor.memberName) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
allowedMemberNames.size > 0 &&
|
||||||
|
!allowedMemberNames.has(normalizeMemberName(actor.memberName))
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageToolUseIds = extractMessageToolUseIds(message);
|
||||||
|
if ([...messageToolUseIds].some((toolUseId) => explicitToolUseIds.has(toolUseId))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!messageHasNonBoardToolActivity(message, toolNameByUseId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inferredToolName = [...messageToolUseIds]
|
||||||
|
.map((toolUseId) => toolNameByUseId.get(toolUseId))
|
||||||
|
.find((toolName): toolName is string => typeof toolName === 'string');
|
||||||
|
const sanitizedMessages = sanitizeJsonLikeToolResultPayloads([message], inferredToolName);
|
||||||
|
const prunedMessages = pruneEmptyInternalToolResultMessages(sanitizedMessages);
|
||||||
|
if (prunedMessages.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
inferredSlices.push({
|
||||||
|
id: `inferred:${filePath}:${message.uuid}`,
|
||||||
|
timestamp: message.timestamp.toISOString(),
|
||||||
|
filePath,
|
||||||
|
sortOrder: index,
|
||||||
|
participantKey: buildParticipantKey(actor),
|
||||||
|
actor,
|
||||||
|
filteredMessages: prunedMessages,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return inferredSlices.sort(compareSlices);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildStreamLayout(teamName: string, taskId: string): Promise<StreamLayout> {
|
||||||
if (!isBoardTaskExactLogsReadEnabled()) {
|
if (!isBoardTaskExactLogsReadEnabled()) {
|
||||||
return emptyResponse();
|
return {
|
||||||
|
participants: [],
|
||||||
|
visibleSlices: [],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const records = await this.recordSource.getTaskRecords(teamName, taskId);
|
const records = await this.recordSource.getTaskRecords(teamName, taskId);
|
||||||
if (records.length === 0) {
|
if (records.length === 0) {
|
||||||
return emptyResponse();
|
return {
|
||||||
|
participants: [],
|
||||||
|
visibleSlices: [],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileVersionsByPath = await getBoardTaskExactLogFileVersions(
|
const fileVersionsByPath = await getBoardTaskExactLogFileVersions(
|
||||||
|
|
@ -723,7 +1212,10 @@ export class BoardTaskLogStreamService {
|
||||||
.sort(compareCandidates);
|
.sort(compareCandidates);
|
||||||
|
|
||||||
if (candidates.length === 0) {
|
if (candidates.length === 0) {
|
||||||
return emptyResponse();
|
return {
|
||||||
|
participants: [],
|
||||||
|
visibleSlices: [],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsedMessagesByFile = await this.strictParser.parseFiles(
|
const parsedMessagesByFile = await this.strictParser.parseFiles(
|
||||||
|
|
@ -762,6 +1254,7 @@ export class BoardTaskLogStreamService {
|
||||||
id: detail.id,
|
id: detail.id,
|
||||||
timestamp: detail.timestamp,
|
timestamp: detail.timestamp,
|
||||||
filePath: detail.source.filePath,
|
filePath: detail.source.filePath,
|
||||||
|
sortOrder: detail.source.sourceOrder,
|
||||||
participantKey: buildParticipantKey(actor),
|
participantKey: buildParticipantKey(actor),
|
||||||
actor,
|
actor,
|
||||||
actionCategory: candidate.actionCategory,
|
actionCategory: candidate.actionCategory,
|
||||||
|
|
@ -770,10 +1263,20 @@ export class BoardTaskLogStreamService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (slices.length === 0) {
|
if (slices.length === 0) {
|
||||||
return emptyResponse();
|
return {
|
||||||
|
participants: [],
|
||||||
|
visibleSlices: [],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const deNoisedSlices = filterReadOnlySlices(slices);
|
const inferredExecutionSlices = await this.buildInferredExecutionSlices(
|
||||||
|
teamName,
|
||||||
|
taskId,
|
||||||
|
records,
|
||||||
|
parsedMessagesByFile
|
||||||
|
);
|
||||||
|
const combinedSlices = [...slices, ...inferredExecutionSlices].sort(compareSlices);
|
||||||
|
const deNoisedSlices = filterReadOnlySlices(combinedSlices);
|
||||||
|
|
||||||
const namedParticipantSlices = deNoisedSlices.filter((slice) =>
|
const namedParticipantSlices = deNoisedSlices.filter((slice) =>
|
||||||
hasNamedParticipant(slice.actor)
|
hasNamedParticipant(slice.actor)
|
||||||
|
|
@ -781,27 +1284,31 @@ export class BoardTaskLogStreamService {
|
||||||
const visibleSlices =
|
const visibleSlices =
|
||||||
namedParticipantSlices.length > 0 ? namedParticipantSlices : deNoisedSlices;
|
namedParticipantSlices.length > 0 ? namedParticipantSlices : deNoisedSlices;
|
||||||
|
|
||||||
const participantsByKey = new Map<string, BoardTaskLogParticipant>();
|
return {
|
||||||
const participantOrder: string[] = [];
|
participants: buildOrderedParticipants(visibleSlices),
|
||||||
for (const slice of visibleSlices) {
|
visibleSlices,
|
||||||
if (participantsByKey.has(slice.participantKey)) {
|
};
|
||||||
continue;
|
}
|
||||||
}
|
|
||||||
participantsByKey.set(
|
async getTaskLogStreamSummary(
|
||||||
slice.participantKey,
|
teamName: string,
|
||||||
buildParticipant(slice.actor, slice.participantKey)
|
taskId: string
|
||||||
);
|
): Promise<BoardTaskLogStreamSummary> {
|
||||||
participantOrder.push(slice.participantKey);
|
const layout = await this.buildStreamLayout(teamName, taskId);
|
||||||
|
if (layout.visibleSlices.length === 0) {
|
||||||
|
return emptySummary();
|
||||||
}
|
}
|
||||||
|
|
||||||
const orderedParticipants = participantOrder
|
return {
|
||||||
.map((key) => participantsByKey.get(key))
|
segmentCount: countSegmentsFromSlices(layout.visibleSlices),
|
||||||
.filter((participant): participant is BoardTaskLogParticipant => Boolean(participant))
|
};
|
||||||
.sort((left, right) => {
|
}
|
||||||
if (left.isLead && !right.isLead) return 1;
|
|
||||||
if (!left.isLead && right.isLead) return -1;
|
async getTaskLogStream(teamName: string, taskId: string): Promise<BoardTaskLogStreamResponse> {
|
||||||
return participantOrder.indexOf(left.key) - participantOrder.indexOf(right.key);
|
const layout = await this.buildStreamLayout(teamName, taskId);
|
||||||
});
|
if (layout.visibleSlices.length === 0) {
|
||||||
|
return emptyResponse();
|
||||||
|
}
|
||||||
|
|
||||||
const segments: BoardTaskLogSegment[] = [];
|
const segments: BoardTaskLogSegment[] = [];
|
||||||
let currentSegmentSlices: StreamSlice[] = [];
|
let currentSegmentSlices: StreamSlice[] = [];
|
||||||
|
|
@ -835,7 +1342,7 @@ export class BoardTaskLogStreamService {
|
||||||
currentSegmentSlices = [];
|
currentSegmentSlices = [];
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const slice of visibleSlices) {
|
for (const slice of layout.visibleSlices) {
|
||||||
if (
|
if (
|
||||||
currentSegmentSlices.length > 0 &&
|
currentSegmentSlices.length > 0 &&
|
||||||
currentSegmentSlices[0].participantKey !== slice.participantKey
|
currentSegmentSlices[0].participantKey !== slice.participantKey
|
||||||
|
|
@ -846,11 +1353,11 @@ export class BoardTaskLogStreamService {
|
||||||
}
|
}
|
||||||
flushSegment();
|
flushSegment();
|
||||||
|
|
||||||
const namedParticipants = orderedParticipants.filter((participant) => !participant.isLead);
|
const namedParticipants = layout.participants.filter((participant) => !participant.isLead);
|
||||||
const defaultFilter = namedParticipants.length === 1 ? namedParticipants[0].key : 'all';
|
const defaultFilter = namedParticipants.length === 1 ? namedParticipants[0].key : 'all';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
participants: orderedParticipants,
|
participants: layout.participants,
|
||||||
defaultFilter,
|
defaultFilter,
|
||||||
segments,
|
segments,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,12 @@
|
||||||
* Shared request/response types for the team-data-worker thread.
|
* Shared request/response types for the team-data-worker thread.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { MemberLogSummary, TeamData } from '@shared/types';
|
import type {
|
||||||
|
MemberLogSummary,
|
||||||
|
MessagesPage,
|
||||||
|
TeamMemberActivityMeta,
|
||||||
|
TeamViewSnapshot,
|
||||||
|
} from '@shared/types';
|
||||||
|
|
||||||
// ── Payloads ──
|
// ── Payloads ──
|
||||||
|
|
||||||
|
|
@ -10,6 +15,18 @@ export interface GetTeamDataPayload {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GetMessagesPagePayload {
|
||||||
|
teamName: string;
|
||||||
|
options: {
|
||||||
|
cursor?: string | null;
|
||||||
|
limit: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetMemberActivityMetaPayload {
|
||||||
|
teamName: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface FindLogsForTaskPayload {
|
export interface FindLogsForTaskPayload {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
taskId: string;
|
taskId: string;
|
||||||
|
|
@ -25,8 +42,14 @@ export interface FindLogsForTaskPayload {
|
||||||
|
|
||||||
export type TeamDataWorkerRequest =
|
export type TeamDataWorkerRequest =
|
||||||
| { id: string; op: 'getTeamData'; payload: GetTeamDataPayload }
|
| { id: string; op: 'getTeamData'; payload: GetTeamDataPayload }
|
||||||
|
| { id: string; op: 'getMessagesPage'; payload: GetMessagesPagePayload }
|
||||||
|
| { id: string; op: 'getMemberActivityMeta'; payload: GetMemberActivityMetaPayload }
|
||||||
| { id: string; op: 'findLogsForTask'; payload: FindLogsForTaskPayload };
|
| { id: string; op: 'findLogsForTask'; payload: FindLogsForTaskPayload };
|
||||||
|
|
||||||
export type TeamDataWorkerResponse =
|
export type TeamDataWorkerResponse =
|
||||||
| { id: string; ok: true; result: TeamData | MemberLogSummary[] }
|
| {
|
||||||
|
id: string;
|
||||||
|
ok: true;
|
||||||
|
result: TeamViewSnapshot | MessagesPage | TeamMemberActivityMeta | MemberLogSummary[];
|
||||||
|
}
|
||||||
| { id: string; ok: false; error: string };
|
| { id: string; ok: false; error: string };
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/**
|
/**
|
||||||
* Standalone (non-Electron) entry point for Claude Agent Teams UI.
|
* Standalone (non-Electron) entry point for Agent Teams UI.
|
||||||
*
|
*
|
||||||
* Runs the HTTP server + API without Electron, suitable for Docker
|
* Runs the HTTP server + API without Electron, suitable for Docker
|
||||||
* or any headless/remote environment. The renderer is served as
|
* or any headless/remote environment. The renderer is served as
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/**
|
/**
|
||||||
* Chunk and visualization types for Claude Agent Teams UI.
|
* Chunk and visualization types for Agent Teams UI.
|
||||||
*
|
*
|
||||||
* This module contains:
|
* This module contains:
|
||||||
* - Chunk types (UserChunk, AIChunk, SystemChunk, CompactChunk)
|
* - Chunk types (UserChunk, AIChunk, SystemChunk, CompactChunk)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/**
|
/**
|
||||||
* Domain/business entity types for Claude Agent Teams UI.
|
* Domain/business entity types for Agent Teams UI.
|
||||||
*
|
*
|
||||||
* These types represent the application's domain model:
|
* These types represent the application's domain model:
|
||||||
* - Projects and sessions
|
* - Projects and sessions
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,21 @@ export interface UsageMetadata {
|
||||||
output_tokens: number;
|
output_tokens: number;
|
||||||
cache_read_input_tokens?: number;
|
cache_read_input_tokens?: number;
|
||||||
cache_creation_input_tokens?: number;
|
cache_creation_input_tokens?: number;
|
||||||
|
input_tokens_details?: {
|
||||||
|
cached_tokens?: number;
|
||||||
|
};
|
||||||
|
output_tokens_details?: {
|
||||||
|
reasoning_tokens?: number;
|
||||||
|
};
|
||||||
|
prompt_tokens?: number;
|
||||||
|
completion_tokens?: number;
|
||||||
|
total_tokens?: number;
|
||||||
|
prompt_tokens_details?: {
|
||||||
|
cached_tokens?: number;
|
||||||
|
};
|
||||||
|
completion_tokens_details?: {
|
||||||
|
reasoning_tokens?: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -130,6 +145,7 @@ interface ConversationalEntry extends BaseEntry {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
version: string;
|
version: string;
|
||||||
gitBranch: string;
|
gitBranch: string;
|
||||||
|
agentName?: string;
|
||||||
slug?: string;
|
slug?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/**
|
/**
|
||||||
* Parsed message types and type guards for Claude Agent Teams UI.
|
* Parsed message types and type guards for Agent Teams UI.
|
||||||
*
|
*
|
||||||
* ParsedMessage is the application's internal representation after parsing
|
* ParsedMessage is the application's internal representation after parsing
|
||||||
* raw JSONL entries. This module also contains type guards for classifying
|
* raw JSONL entries. This module also contains type guards for classifying
|
||||||
|
|
@ -80,10 +80,14 @@ export interface ParsedMessage {
|
||||||
// Metadata
|
// Metadata
|
||||||
/** Current working directory when message was created */
|
/** Current working directory when message was created */
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
|
/** Root/session identifier from transcript */
|
||||||
|
sessionId?: string;
|
||||||
/** Git branch context */
|
/** Git branch context */
|
||||||
gitBranch?: string;
|
gitBranch?: string;
|
||||||
/** Agent ID for subagent messages */
|
/** Agent ID for subagent messages */
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
|
/** Human-readable agent/member name from transcript */
|
||||||
|
agentName?: string;
|
||||||
/** Whether this is a sidechain message */
|
/** Whether this is a sidechain message */
|
||||||
isSidechain: boolean;
|
isSidechain: boolean;
|
||||||
/** Whether this is a meta message */
|
/** Whether this is a meta message */
|
||||||
|
|
|
||||||
|
|
@ -240,8 +240,10 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null {
|
||||||
let model: string | undefined;
|
let model: string | undefined;
|
||||||
let requestId: string | undefined;
|
let requestId: string | undefined;
|
||||||
let cwd: string | undefined;
|
let cwd: string | undefined;
|
||||||
|
let sessionId: string | undefined;
|
||||||
let gitBranch: string | undefined;
|
let gitBranch: string | undefined;
|
||||||
let agentId: string | undefined;
|
let agentId: string | undefined;
|
||||||
|
let agentName: string | undefined;
|
||||||
let isSidechain = false;
|
let isSidechain = false;
|
||||||
let isMeta = false;
|
let isMeta = false;
|
||||||
let userType: string | undefined;
|
let userType: string | undefined;
|
||||||
|
|
@ -255,10 +257,12 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null {
|
||||||
if (isConversationalEntry(entry)) {
|
if (isConversationalEntry(entry)) {
|
||||||
// Common properties from ConversationalEntry base
|
// Common properties from ConversationalEntry base
|
||||||
cwd = entry.cwd;
|
cwd = entry.cwd;
|
||||||
|
sessionId = entry.sessionId;
|
||||||
gitBranch = entry.gitBranch;
|
gitBranch = entry.gitBranch;
|
||||||
isSidechain = entry.isSidechain ?? false;
|
isSidechain = entry.isSidechain ?? false;
|
||||||
userType = entry.userType;
|
userType = entry.userType;
|
||||||
parentUuid = entry.parentUuid ?? null;
|
parentUuid = entry.parentUuid ?? null;
|
||||||
|
agentName = entry.agentName;
|
||||||
|
|
||||||
// Type-specific properties
|
// Type-specific properties
|
||||||
if (entry.type === 'user') {
|
if (entry.type === 'user') {
|
||||||
|
|
@ -298,8 +302,10 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null {
|
||||||
model,
|
model,
|
||||||
// Metadata
|
// Metadata
|
||||||
cwd,
|
cwd,
|
||||||
|
sessionId,
|
||||||
gitBranch,
|
gitBranch,
|
||||||
agentId,
|
agentId,
|
||||||
|
agentName,
|
||||||
isSidechain,
|
isSidechain,
|
||||||
isMeta,
|
isMeta,
|
||||||
userType,
|
userType,
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,19 @@ parentPort?.on('message', async (msg: TeamDataWorkerRequest) => {
|
||||||
respond({ id: msg.id, ok: true, result });
|
respond({ id: msg.id, ok: true, result });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'getMessagesPage': {
|
||||||
|
const result = await teamDataService.getMessagesPage(
|
||||||
|
msg.payload.teamName,
|
||||||
|
msg.payload.options
|
||||||
|
);
|
||||||
|
respond({ id: msg.id, ok: true, result });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'getMemberActivityMeta': {
|
||||||
|
const result = await teamDataService.getMemberActivityMeta(msg.payload.teamName);
|
||||||
|
respond({ id: msg.id, ok: true, result });
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'findLogsForTask': {
|
case 'findLogsForTask': {
|
||||||
const { teamName, taskId, options } = msg.payload;
|
const { teamName, taskId, options } = msg.payload;
|
||||||
const intervalsKey = options?.intervals
|
const intervalsKey = options?.intervals
|
||||||
|
|
|
||||||
|
|
@ -219,6 +219,9 @@ export const TEAM_SET_CHANGE_PRESENCE_TRACKING = 'team:setChangePresenceTracking
|
||||||
/** Enable or disable live teammate tool activity tracking for a visible team tab */
|
/** Enable or disable live teammate tool activity tracking for a visible team tab */
|
||||||
export const TEAM_SET_TOOL_ACTIVITY_TRACKING = 'team:setToolActivityTracking';
|
export const TEAM_SET_TOOL_ACTIVITY_TRACKING = 'team:setToolActivityTracking';
|
||||||
|
|
||||||
|
/** Enable or disable task log stream invalidation tracking for an open task log panel */
|
||||||
|
export const TEAM_SET_TASK_LOG_STREAM_TRACKING = 'team:setTaskLogStreamTracking';
|
||||||
|
|
||||||
/** Get buffered Claude CLI logs (paged, newest-first) */
|
/** Get buffered Claude CLI logs (paged, newest-first) */
|
||||||
export const TEAM_GET_CLAUDE_LOGS = 'team:getClaudeLogs';
|
export const TEAM_GET_CLAUDE_LOGS = 'team:getClaudeLogs';
|
||||||
|
|
||||||
|
|
@ -234,6 +237,9 @@ export const TEAM_SEND_MESSAGE = 'team:sendMessage';
|
||||||
/** Paginated messages for timeline/messages panel */
|
/** Paginated messages for timeline/messages panel */
|
||||||
export const TEAM_GET_MESSAGES_PAGE = 'team:getMessagesPage';
|
export const TEAM_GET_MESSAGES_PAGE = 'team:getMessagesPage';
|
||||||
|
|
||||||
|
/** Lightweight message-derived member activity facts */
|
||||||
|
export const TEAM_GET_MEMBER_ACTIVITY_META = 'team:getMemberActivityMeta';
|
||||||
|
|
||||||
/** Request review for task */
|
/** Request review for task */
|
||||||
export const TEAM_REQUEST_REVIEW = 'team:requestReview';
|
export const TEAM_REQUEST_REVIEW = 'team:requestReview';
|
||||||
|
|
||||||
|
|
@ -310,6 +316,9 @@ export const TEAM_GET_TASK_ACTIVITY_DETAIL = 'team:getTaskActivityDetail';
|
||||||
/** Get one task-scoped log stream derived from explicit board-task activity */
|
/** Get one task-scoped log stream derived from explicit board-task activity */
|
||||||
export const TEAM_GET_TASK_LOG_STREAM = 'team:getTaskLogStream';
|
export const TEAM_GET_TASK_LOG_STREAM = 'team:getTaskLogStream';
|
||||||
|
|
||||||
|
/** Get lightweight task log stream summary for header badges/live counters */
|
||||||
|
export const TEAM_GET_TASK_LOG_STREAM_SUMMARY = 'team:getTaskLogStreamSummary';
|
||||||
|
|
||||||
/** Get exact task-log summaries derived from explicit board-task activity records */
|
/** Get exact task-log summaries derived from explicit board-task activity records */
|
||||||
export const TEAM_GET_TASK_EXACT_LOG_SUMMARIES = 'team:getTaskExactLogSummaries';
|
export const TEAM_GET_TASK_EXACT_LOG_SUMMARIES = 'team:getTaskExactLogSummaries';
|
||||||
|
|
||||||
|
|
@ -370,6 +379,12 @@ export const TEAM_LEAD_CONTEXT = 'team:leadContext';
|
||||||
/** Get per-member spawn statuses for a team */
|
/** Get per-member spawn statuses for a team */
|
||||||
export const TEAM_MEMBER_SPAWN_STATUSES = 'team:memberSpawnStatuses';
|
export const TEAM_MEMBER_SPAWN_STATUSES = 'team:memberSpawnStatuses';
|
||||||
|
|
||||||
|
/** Get live per-agent runtime stats for a team */
|
||||||
|
export const TEAM_GET_AGENT_RUNTIME = 'team:getAgentRuntime';
|
||||||
|
|
||||||
|
/** Restart a specific teammate runtime */
|
||||||
|
export const TEAM_RESTART_MEMBER = 'team:restartMember';
|
||||||
|
|
||||||
/** Soft-delete a task (set status to 'deleted' with deletedAt timestamp) */
|
/** Soft-delete a task (set status to 'deleted' with deletedAt timestamp) */
|
||||||
export const TEAM_SOFT_DELETE_TASK = 'team:softDeleteTask';
|
export const TEAM_SOFT_DELETE_TASK = 'team:softDeleteTask';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,7 @@ import {
|
||||||
TEAM_GET_DATA,
|
TEAM_GET_DATA,
|
||||||
TEAM_GET_DELETED_TASKS,
|
TEAM_GET_DELETED_TASKS,
|
||||||
TEAM_GET_LOGS_FOR_TASK,
|
TEAM_GET_LOGS_FOR_TASK,
|
||||||
|
TEAM_GET_MEMBER_ACTIVITY_META,
|
||||||
TEAM_GET_MEMBER_LOGS,
|
TEAM_GET_MEMBER_LOGS,
|
||||||
TEAM_GET_MEMBER_STATS,
|
TEAM_GET_MEMBER_STATS,
|
||||||
TEAM_GET_MESSAGES_PAGE,
|
TEAM_GET_MESSAGES_PAGE,
|
||||||
|
|
@ -139,12 +140,14 @@ import {
|
||||||
TEAM_GET_TASK_EXACT_LOG_DETAIL,
|
TEAM_GET_TASK_EXACT_LOG_DETAIL,
|
||||||
TEAM_GET_TASK_EXACT_LOG_SUMMARIES,
|
TEAM_GET_TASK_EXACT_LOG_SUMMARIES,
|
||||||
TEAM_GET_TASK_LOG_STREAM,
|
TEAM_GET_TASK_LOG_STREAM,
|
||||||
|
TEAM_GET_TASK_LOG_STREAM_SUMMARY,
|
||||||
TEAM_KILL_PROCESS,
|
TEAM_KILL_PROCESS,
|
||||||
TEAM_LAUNCH,
|
TEAM_LAUNCH,
|
||||||
TEAM_LEAD_ACTIVITY,
|
TEAM_LEAD_ACTIVITY,
|
||||||
TEAM_LEAD_CONTEXT,
|
TEAM_LEAD_CONTEXT,
|
||||||
TEAM_LIST,
|
TEAM_LIST,
|
||||||
TEAM_MEMBER_SPAWN_STATUSES,
|
TEAM_MEMBER_SPAWN_STATUSES,
|
||||||
|
TEAM_GET_AGENT_RUNTIME,
|
||||||
TEAM_PERMANENTLY_DELETE,
|
TEAM_PERMANENTLY_DELETE,
|
||||||
TEAM_PREPARE_PROVISIONING,
|
TEAM_PREPARE_PROVISIONING,
|
||||||
TEAM_PROCESS_ALIVE,
|
TEAM_PROCESS_ALIVE,
|
||||||
|
|
@ -156,11 +159,13 @@ import {
|
||||||
TEAM_REMOVE_TASK_RELATIONSHIP,
|
TEAM_REMOVE_TASK_RELATIONSHIP,
|
||||||
TEAM_REPLACE_MEMBERS,
|
TEAM_REPLACE_MEMBERS,
|
||||||
TEAM_REQUEST_REVIEW,
|
TEAM_REQUEST_REVIEW,
|
||||||
|
TEAM_RESTART_MEMBER,
|
||||||
TEAM_RESTORE,
|
TEAM_RESTORE,
|
||||||
TEAM_RESTORE_TASK,
|
TEAM_RESTORE_TASK,
|
||||||
TEAM_SAVE_TASK_ATTACHMENT,
|
TEAM_SAVE_TASK_ATTACHMENT,
|
||||||
TEAM_SEND_MESSAGE,
|
TEAM_SEND_MESSAGE,
|
||||||
TEAM_SET_CHANGE_PRESENCE_TRACKING,
|
TEAM_SET_CHANGE_PRESENCE_TRACKING,
|
||||||
|
TEAM_SET_TASK_LOG_STREAM_TRACKING,
|
||||||
TEAM_SET_PROJECT_BRANCH_TRACKING,
|
TEAM_SET_PROJECT_BRANCH_TRACKING,
|
||||||
TEAM_SET_TASK_CLARIFICATION,
|
TEAM_SET_TASK_CLARIFICATION,
|
||||||
TEAM_SET_TOOL_ACTIVITY_TRACKING,
|
TEAM_SET_TOOL_ACTIVITY_TRACKING,
|
||||||
|
|
@ -240,6 +245,7 @@ import type {
|
||||||
BoardTaskExactLogDetailResult,
|
BoardTaskExactLogDetailResult,
|
||||||
BoardTaskExactLogSummariesResponse,
|
BoardTaskExactLogSummariesResponse,
|
||||||
BoardTaskLogStreamResponse,
|
BoardTaskLogStreamResponse,
|
||||||
|
BoardTaskLogStreamSummary,
|
||||||
ChangeStats,
|
ChangeStats,
|
||||||
ClaudeRootFolderSelection,
|
ClaudeRootFolderSelection,
|
||||||
ClaudeRootInfo,
|
ClaudeRootInfo,
|
||||||
|
|
@ -264,6 +270,7 @@ import type {
|
||||||
LeadContextUsageSnapshot,
|
LeadContextUsageSnapshot,
|
||||||
MemberFullStats,
|
MemberFullStats,
|
||||||
MemberLogSummary,
|
MemberLogSummary,
|
||||||
|
TeamAgentRuntimeSnapshot,
|
||||||
MemberSpawnStatusesSnapshot,
|
MemberSpawnStatusesSnapshot,
|
||||||
MessagesPage,
|
MessagesPage,
|
||||||
NotificationTrigger,
|
NotificationTrigger,
|
||||||
|
|
@ -293,9 +300,9 @@ import type {
|
||||||
TeamCreateConfigRequest,
|
TeamCreateConfigRequest,
|
||||||
TeamCreateRequest,
|
TeamCreateRequest,
|
||||||
TeamCreateResponse,
|
TeamCreateResponse,
|
||||||
TeamData,
|
|
||||||
TeamLaunchRequest,
|
TeamLaunchRequest,
|
||||||
TeamLaunchResponse,
|
TeamLaunchResponse,
|
||||||
|
TeamMemberActivityMeta,
|
||||||
TeamMessageNotificationData,
|
TeamMessageNotificationData,
|
||||||
TeamProvisioningPrepareResult,
|
TeamProvisioningPrepareResult,
|
||||||
TeamProvisioningProgress,
|
TeamProvisioningProgress,
|
||||||
|
|
@ -303,6 +310,7 @@ import type {
|
||||||
TeamTask,
|
TeamTask,
|
||||||
TeamTaskStatus,
|
TeamTaskStatus,
|
||||||
TeamUpdateConfigRequest,
|
TeamUpdateConfigRequest,
|
||||||
|
TeamViewSnapshot,
|
||||||
ToolApprovalEvent,
|
ToolApprovalEvent,
|
||||||
ToolApprovalFileContent,
|
ToolApprovalFileContent,
|
||||||
ToolApprovalSettings,
|
ToolApprovalSettings,
|
||||||
|
|
@ -823,7 +831,7 @@ const electronAPI: ElectronAPI = {
|
||||||
return invokeIpcWithResult<TeamSummary[]>(TEAM_LIST);
|
return invokeIpcWithResult<TeamSummary[]>(TEAM_LIST);
|
||||||
},
|
},
|
||||||
getData: async (teamName: string) => {
|
getData: async (teamName: string) => {
|
||||||
return invokeIpcWithResult<TeamData>(TEAM_GET_DATA, teamName);
|
return invokeIpcWithResult<TeamViewSnapshot>(TEAM_GET_DATA, teamName);
|
||||||
},
|
},
|
||||||
getTaskChangePresence: async (teamName: string) => {
|
getTaskChangePresence: async (teamName: string) => {
|
||||||
return invokeIpcWithResult<Record<string, TaskChangePresenceState>>(
|
return invokeIpcWithResult<Record<string, TaskChangePresenceState>>(
|
||||||
|
|
@ -834,6 +842,9 @@ const electronAPI: ElectronAPI = {
|
||||||
setChangePresenceTracking: async (teamName: string, enabled: boolean) => {
|
setChangePresenceTracking: async (teamName: string, enabled: boolean) => {
|
||||||
return invokeIpcWithResult<void>(TEAM_SET_CHANGE_PRESENCE_TRACKING, teamName, enabled);
|
return invokeIpcWithResult<void>(TEAM_SET_CHANGE_PRESENCE_TRACKING, teamName, enabled);
|
||||||
},
|
},
|
||||||
|
setTaskLogStreamTracking: async (teamName: string, enabled: boolean) => {
|
||||||
|
return invokeIpcWithResult<void>(TEAM_SET_TASK_LOG_STREAM_TRACKING, teamName, enabled);
|
||||||
|
},
|
||||||
setToolActivityTracking: async (teamName: string, enabled: boolean) => {
|
setToolActivityTracking: async (teamName: string, enabled: boolean) => {
|
||||||
return invokeIpcWithResult<void>(TEAM_SET_TOOL_ACTIVITY_TRACKING, teamName, enabled);
|
return invokeIpcWithResult<void>(TEAM_SET_TOOL_ACTIVITY_TRACKING, teamName, enabled);
|
||||||
},
|
},
|
||||||
|
|
@ -888,10 +899,13 @@ const electronAPI: ElectronAPI = {
|
||||||
},
|
},
|
||||||
getMessagesPage: async (
|
getMessagesPage: async (
|
||||||
teamName: string,
|
teamName: string,
|
||||||
options?: { beforeTimestamp?: string; limit?: number }
|
options?: { cursor?: string | null; limit?: number }
|
||||||
) => {
|
) => {
|
||||||
return invokeIpcWithResult<MessagesPage>(TEAM_GET_MESSAGES_PAGE, teamName, options);
|
return invokeIpcWithResult<MessagesPage>(TEAM_GET_MESSAGES_PAGE, teamName, options);
|
||||||
},
|
},
|
||||||
|
getMemberActivityMeta: async (teamName: string) => {
|
||||||
|
return invokeIpcWithResult<TeamMemberActivityMeta>(TEAM_GET_MEMBER_ACTIVITY_META, teamName);
|
||||||
|
},
|
||||||
createTask: async (teamName: string, request: CreateTaskRequest) => {
|
createTask: async (teamName: string, request: CreateTaskRequest) => {
|
||||||
return invokeIpcWithResult<TeamTask>(TEAM_CREATE_TASK, teamName, request);
|
return invokeIpcWithResult<TeamTask>(TEAM_CREATE_TASK, teamName, request);
|
||||||
},
|
},
|
||||||
|
|
@ -986,6 +1000,13 @@ const electronAPI: ElectronAPI = {
|
||||||
activityId
|
activityId
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
getTaskLogStreamSummary: async (teamName: string, taskId: string) => {
|
||||||
|
return invokeIpcWithResult<BoardTaskLogStreamSummary>(
|
||||||
|
TEAM_GET_TASK_LOG_STREAM_SUMMARY,
|
||||||
|
teamName,
|
||||||
|
taskId
|
||||||
|
);
|
||||||
|
},
|
||||||
getTaskLogStream: async (teamName: string, taskId: string) => {
|
getTaskLogStream: async (teamName: string, taskId: string) => {
|
||||||
return invokeIpcWithResult<BoardTaskLogStreamResponse>(
|
return invokeIpcWithResult<BoardTaskLogStreamResponse>(
|
||||||
TEAM_GET_TASK_LOG_STREAM,
|
TEAM_GET_TASK_LOG_STREAM,
|
||||||
|
|
@ -1059,6 +1080,12 @@ const electronAPI: ElectronAPI = {
|
||||||
getMemberSpawnStatuses: async (teamName: string) => {
|
getMemberSpawnStatuses: async (teamName: string) => {
|
||||||
return invokeIpcWithResult<MemberSpawnStatusesSnapshot>(TEAM_MEMBER_SPAWN_STATUSES, teamName);
|
return invokeIpcWithResult<MemberSpawnStatusesSnapshot>(TEAM_MEMBER_SPAWN_STATUSES, teamName);
|
||||||
},
|
},
|
||||||
|
getTeamAgentRuntime: async (teamName: string) => {
|
||||||
|
return invokeIpcWithResult<TeamAgentRuntimeSnapshot>(TEAM_GET_AGENT_RUNTIME, teamName);
|
||||||
|
},
|
||||||
|
restartMember: async (teamName: string, memberName: string) => {
|
||||||
|
return invokeIpcWithResult<void>(TEAM_RESTART_MEMBER, teamName, memberName);
|
||||||
|
},
|
||||||
softDeleteTask: async (teamName: string, taskId: string) => {
|
softDeleteTask: async (teamName: string, taskId: string) => {
|
||||||
return invokeIpcWithResult<void>(TEAM_SOFT_DELETE_TASK, teamName, taskId);
|
return invokeIpcWithResult<void>(TEAM_SOFT_DELETE_TASK, teamName, taskId);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import type {
|
||||||
BoardTaskExactLogDetailResult,
|
BoardTaskExactLogDetailResult,
|
||||||
BoardTaskExactLogSummariesResponse,
|
BoardTaskExactLogSummariesResponse,
|
||||||
BoardTaskLogStreamResponse,
|
BoardTaskLogStreamResponse,
|
||||||
|
BoardTaskLogStreamSummary,
|
||||||
ClaudeMdFileInfo,
|
ClaudeMdFileInfo,
|
||||||
ClaudeRootFolderSelection,
|
ClaudeRootFolderSelection,
|
||||||
ClaudeRootInfo,
|
ClaudeRootInfo,
|
||||||
|
|
@ -58,15 +59,16 @@ import type {
|
||||||
TeamClaudeLogsResponse,
|
TeamClaudeLogsResponse,
|
||||||
TeamCreateRequest,
|
TeamCreateRequest,
|
||||||
TeamCreateResponse,
|
TeamCreateResponse,
|
||||||
TeamData,
|
|
||||||
TeamLaunchRequest,
|
TeamLaunchRequest,
|
||||||
TeamLaunchResponse,
|
TeamLaunchResponse,
|
||||||
|
TeamMemberActivityMeta,
|
||||||
TeamProvisioningPrepareResult,
|
TeamProvisioningPrepareResult,
|
||||||
TeamProvisioningProgress,
|
TeamProvisioningProgress,
|
||||||
TeamsAPI,
|
TeamsAPI,
|
||||||
TeamSummary,
|
TeamSummary,
|
||||||
TeamTask,
|
TeamTask,
|
||||||
TeamTaskStatus,
|
TeamTaskStatus,
|
||||||
|
TeamViewSnapshot,
|
||||||
TmuxAPI,
|
TmuxAPI,
|
||||||
TmuxStatus,
|
TmuxStatus,
|
||||||
TriggerTestResult,
|
TriggerTestResult,
|
||||||
|
|
@ -677,7 +679,7 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
console.warn('[HttpAPIClient] teams API is not available in browser mode');
|
console.warn('[HttpAPIClient] teams API is not available in browser mode');
|
||||||
return [];
|
return [];
|
||||||
},
|
},
|
||||||
getData: async (_teamName: string): Promise<TeamData> => {
|
getData: async (_teamName: string): Promise<TeamViewSnapshot> => {
|
||||||
throw new Error('Teams detail is not available in browser mode');
|
throw new Error('Teams detail is not available in browser mode');
|
||||||
},
|
},
|
||||||
getTaskChangePresence: async (): Promise<
|
getTaskChangePresence: async (): Promise<
|
||||||
|
|
@ -688,6 +690,9 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
setChangePresenceTracking: async (): Promise<void> => {
|
setChangePresenceTracking: async (): Promise<void> => {
|
||||||
// Not available in browser mode — no-op.
|
// Not available in browser mode — no-op.
|
||||||
},
|
},
|
||||||
|
setTaskLogStreamTracking: async (): Promise<void> => {
|
||||||
|
// Not available in browser mode — no-op.
|
||||||
|
},
|
||||||
setToolActivityTracking: async (): Promise<void> => {
|
setToolActivityTracking: async (): Promise<void> => {
|
||||||
// Not available in browser mode — no-op.
|
// Not available in browser mode — no-op.
|
||||||
},
|
},
|
||||||
|
|
@ -742,7 +747,15 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
throw new Error('Team messaging is not available in browser mode');
|
throw new Error('Team messaging is not available in browser mode');
|
||||||
},
|
},
|
||||||
getMessagesPage: async () => {
|
getMessagesPage: async () => {
|
||||||
return { messages: [], nextCursor: null, hasMore: false };
|
return { messages: [], nextCursor: null, hasMore: false, feedRevision: 'empty' };
|
||||||
|
},
|
||||||
|
getMemberActivityMeta: async (_teamName: string): Promise<TeamMemberActivityMeta> => {
|
||||||
|
return {
|
||||||
|
teamName: _teamName,
|
||||||
|
computedAt: new Date(0).toISOString(),
|
||||||
|
members: {},
|
||||||
|
feedRevision: 'empty',
|
||||||
|
};
|
||||||
},
|
},
|
||||||
createTask: async (_teamName: string, _request: CreateTaskRequest): Promise<TeamTask> => {
|
createTask: async (_teamName: string, _request: CreateTaskRequest): Promise<TeamTask> => {
|
||||||
throw new Error('Team task creation is not available in browser mode');
|
throw new Error('Team task creation is not available in browser mode');
|
||||||
|
|
@ -824,6 +837,10 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
console.warn('[HttpAPIClient] getTaskActivityDetail is not available in browser mode');
|
console.warn('[HttpAPIClient] getTaskActivityDetail is not available in browser mode');
|
||||||
return { status: 'missing' };
|
return { status: 'missing' };
|
||||||
},
|
},
|
||||||
|
getTaskLogStreamSummary: async (): Promise<BoardTaskLogStreamSummary> => {
|
||||||
|
console.warn('[HttpAPIClient] getTaskLogStreamSummary is not available in browser mode');
|
||||||
|
return { segmentCount: 0 };
|
||||||
|
},
|
||||||
getTaskLogStream: async (): Promise<BoardTaskLogStreamResponse> => {
|
getTaskLogStream: async (): Promise<BoardTaskLogStreamResponse> => {
|
||||||
console.warn('[HttpAPIClient] getTaskLogStream is not available in browser mode');
|
console.warn('[HttpAPIClient] getTaskLogStream is not available in browser mode');
|
||||||
return {
|
return {
|
||||||
|
|
@ -905,6 +922,17 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
getMemberSpawnStatuses: async () => {
|
getMemberSpawnStatuses: async () => {
|
||||||
return { statuses: {}, runId: null };
|
return { statuses: {}, runId: null };
|
||||||
},
|
},
|
||||||
|
getTeamAgentRuntime: async (teamName: string) => {
|
||||||
|
return {
|
||||||
|
teamName,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
runId: null,
|
||||||
|
members: {},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
restartMember: async (): Promise<void> => {
|
||||||
|
throw new Error('Member restart is not available in browser mode');
|
||||||
|
},
|
||||||
softDeleteTask: async (_teamName: string, _taskId: string): Promise<void> => {
|
softDeleteTask: async (_teamName: string, _taskId: string): Promise<void> => {
|
||||||
// Not available via HTTP client — no-op
|
// Not available via HTTP client — no-op
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -14,17 +14,15 @@ import { SessionContextPanel } from './SessionContextPanel/index';
|
||||||
/** Pixels from bottom considered "near bottom" for scroll-button visibility and auto-scroll. */
|
/** Pixels from bottom considered "near bottom" for scroll-button visibility and auto-scroll. */
|
||||||
const SCROLL_THRESHOLD = 300;
|
const SCROLL_THRESHOLD = 300;
|
||||||
|
|
||||||
import {
|
import { computeRemainingContext, sumContextInjectionTokens } from '@renderer/utils/contextMath';
|
||||||
computeRemainingContext,
|
import { deriveContextMetrics } from '@shared/utils/contextMetrics';
|
||||||
formatPercentOfTotal,
|
|
||||||
sumContextInjectionTokens,
|
|
||||||
} from '@renderer/utils/contextMath';
|
|
||||||
|
|
||||||
import { ChatHistoryEmptyState } from './ChatHistoryEmptyState';
|
import { ChatHistoryEmptyState } from './ChatHistoryEmptyState';
|
||||||
import { ChatHistoryItem } from './ChatHistoryItem';
|
import { ChatHistoryItem } from './ChatHistoryItem';
|
||||||
import { ChatHistoryLoadingState } from './ChatHistoryLoadingState';
|
import { ChatHistoryLoadingState } from './ChatHistoryLoadingState';
|
||||||
|
|
||||||
import type { ContextInjection } from '@renderer/types/contextInjection';
|
import type { ContextInjection } from '@renderer/types/contextInjection';
|
||||||
|
import type { ContextUsageLike } from '@shared/utils/contextMetrics';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Waits for two requestAnimationFrame cycles, allowing the virtualizer to render.
|
* Waits for two requestAnimationFrame cycles, allowing the virtualizer to render.
|
||||||
|
|
@ -129,6 +127,7 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
|
||||||
const pendingNavigation = thisTab?.pendingNavigation;
|
const pendingNavigation = thisTab?.pendingNavigation;
|
||||||
|
|
||||||
const teamBySessionId = useStore(useShallow((s) => s.teamBySessionId));
|
const teamBySessionId = useStore(useShallow((s) => s.teamBySessionId));
|
||||||
|
const leadContextByTeam = useStore(useShallow((s) => s.leadContextByTeam));
|
||||||
|
|
||||||
// Look up whether this session belongs to a team
|
// Look up whether this session belongs to a team
|
||||||
const sessionTeam = useMemo(() => {
|
const sessionTeam = useMemo(() => {
|
||||||
|
|
@ -138,9 +137,13 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
|
||||||
}, [teamBySessionId, sessionDetail?.session?.id]);
|
}, [teamBySessionId, sessionDetail?.session?.id]);
|
||||||
|
|
||||||
// Compute all accumulated context injections (phase-aware)
|
// Compute all accumulated context injections (phase-aware)
|
||||||
const { allContextInjections, lastAiGroupTotalTokens } = useMemo(() => {
|
const { allContextInjections, lastAssistantUsage, lastAssistantModelName } = useMemo(() => {
|
||||||
if (!sessionContextStats || !conversation?.items.length) {
|
if (!sessionContextStats || !conversation?.items.length) {
|
||||||
return { allContextInjections: [] as ContextInjection[], lastAiGroupTotalTokens: undefined };
|
return {
|
||||||
|
allContextInjections: [] as ContextInjection[],
|
||||||
|
lastAssistantUsage: null as ContextUsageLike | null,
|
||||||
|
lastAssistantModelName: undefined as string | undefined,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine which phase to show
|
// Determine which phase to show
|
||||||
|
|
@ -161,7 +164,8 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
|
||||||
if (lastAiItem?.type !== 'ai') {
|
if (lastAiItem?.type !== 'ai') {
|
||||||
return {
|
return {
|
||||||
allContextInjections: [] as ContextInjection[],
|
allContextInjections: [] as ContextInjection[],
|
||||||
lastAiGroupTotalTokens: undefined,
|
lastAssistantUsage: null,
|
||||||
|
lastAssistantModelName: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
targetAiGroupId = lastAiItem.group.id;
|
targetAiGroupId = lastAiItem.group.id;
|
||||||
|
|
@ -170,9 +174,8 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
|
||||||
const stats = sessionContextStats.get(targetAiGroupId);
|
const stats = sessionContextStats.get(targetAiGroupId);
|
||||||
const injections = stats?.accumulatedInjections ?? [];
|
const injections = stats?.accumulatedInjections ?? [];
|
||||||
|
|
||||||
// Get total INPUT tokens from the target AI group (excluding output tokens,
|
let lastUsage: ContextUsageLike | null = null;
|
||||||
// since visible context is part of input only)
|
let lastModelName: string | undefined;
|
||||||
let totalTokens: number | undefined;
|
|
||||||
const targetItem = conversation.items.find(
|
const targetItem = conversation.items.find(
|
||||||
(item) => item.type === 'ai' && item.group.id === targetAiGroupId
|
(item) => item.type === 'ai' && item.group.id === targetAiGroupId
|
||||||
);
|
);
|
||||||
|
|
@ -181,27 +184,51 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
|
||||||
for (let i = responses.length - 1; i >= 0; i--) {
|
for (let i = responses.length - 1; i >= 0; i--) {
|
||||||
const msg = responses[i];
|
const msg = responses[i];
|
||||||
if (msg.type === 'assistant' && msg.usage) {
|
if (msg.type === 'assistant' && msg.usage) {
|
||||||
const usage = msg.usage;
|
lastUsage = msg.usage;
|
||||||
totalTokens =
|
lastModelName = msg.model;
|
||||||
(usage.input_tokens ?? 0) +
|
|
||||||
(usage.cache_read_input_tokens ?? 0) +
|
|
||||||
(usage.cache_creation_input_tokens ?? 0);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { allContextInjections: injections, lastAiGroupTotalTokens: totalTokens };
|
return {
|
||||||
|
allContextInjections: injections,
|
||||||
|
lastAssistantUsage: lastUsage,
|
||||||
|
lastAssistantModelName: lastModelName,
|
||||||
|
};
|
||||||
}, [sessionContextStats, conversation, selectedContextPhase, sessionPhaseInfo]);
|
}, [sessionContextStats, conversation, selectedContextPhase, sessionPhaseInfo]);
|
||||||
|
const visibleContextTokens = useMemo(
|
||||||
const visibleContextPercentLabel = useMemo(() => {
|
() => sumContextInjectionTokens(allContextInjections),
|
||||||
const visibleTokens = sumContextInjectionTokens(allContextInjections);
|
[allContextInjections]
|
||||||
return formatPercentOfTotal(visibleTokens, lastAiGroupTotalTokens);
|
);
|
||||||
}, [allContextInjections, lastAiGroupTotalTokens]);
|
const sessionLeadContext = sessionTeam ? (leadContextByTeam[sessionTeam.teamName] ?? null) : null;
|
||||||
|
const contextMetrics = useMemo(
|
||||||
|
() =>
|
||||||
|
deriveContextMetrics({
|
||||||
|
usage: lastAssistantUsage,
|
||||||
|
modelName: lastAssistantModelName,
|
||||||
|
contextWindowTokens: sessionLeadContext?.contextWindowTokens ?? null,
|
||||||
|
visibleContextTokens,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
lastAssistantModelName,
|
||||||
|
lastAssistantUsage,
|
||||||
|
sessionLeadContext?.contextWindowTokens,
|
||||||
|
visibleContextTokens,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const contextUsedPercentLabel = useMemo(() => {
|
||||||
|
const percent = contextMetrics.contextUsedPercentOfContextWindow;
|
||||||
|
return percent === null ? null : `${percent.toFixed(1)}%`;
|
||||||
|
}, [contextMetrics.contextUsedPercentOfContextWindow]);
|
||||||
|
|
||||||
const remainingContext = useMemo(
|
const remainingContext = useMemo(
|
||||||
() => computeRemainingContext(lastAiGroupTotalTokens),
|
() =>
|
||||||
[lastAiGroupTotalTokens]
|
computeRemainingContext(
|
||||||
|
contextMetrics.contextUsedTokens ?? undefined,
|
||||||
|
contextMetrics.contextWindowTokens ?? undefined
|
||||||
|
),
|
||||||
|
[contextMetrics.contextUsedTokens, contextMetrics.contextWindowTokens]
|
||||||
);
|
);
|
||||||
|
|
||||||
// State for navigation highlight (blue, used for Turn navigation from CLAUDE.md panel)
|
// State for navigation highlight (blue, used for Turn navigation from CLAUDE.md panel)
|
||||||
|
|
@ -839,7 +866,7 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
|
||||||
onNavigateToTurn={handleNavigateToTurn}
|
onNavigateToTurn={handleNavigateToTurn}
|
||||||
onNavigateToTool={handleNavigateToTool}
|
onNavigateToTool={handleNavigateToTool}
|
||||||
onNavigateToUserGroup={handleNavigateToUserGroup}
|
onNavigateToUserGroup={handleNavigateToUserGroup}
|
||||||
totalSessionTokens={lastAiGroupTotalTokens}
|
contextMetrics={contextMetrics}
|
||||||
sessionMetrics={sessionDetail?.metrics}
|
sessionMetrics={sessionDetail?.metrics}
|
||||||
subagentCostUsd={subagentCostUsd}
|
subagentCostUsd={subagentCostUsd}
|
||||||
onViewReport={effectiveTabId ? () => openSessionReport(effectiveTabId) : undefined}
|
onViewReport={effectiveTabId ? () => openSessionReport(effectiveTabId) : undefined}
|
||||||
|
|
@ -877,9 +904,9 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
|
||||||
: 'var(--color-text-secondary)',
|
: 'var(--color-text-secondary)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{visibleContextPercentLabel ? (
|
{contextUsedPercentLabel ? (
|
||||||
<>
|
<>
|
||||||
{visibleContextPercentLabel}
|
{contextUsedPercentLabel}
|
||||||
{remainingContext && remainingContext.urgency !== 'normal' && (
|
{remainingContext && remainingContext.urgency !== 'normal' && (
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import {
|
||||||
COLOR_TEXT_MUTED,
|
COLOR_TEXT_MUTED,
|
||||||
COLOR_TEXT_SECONDARY,
|
COLOR_TEXT_SECONDARY,
|
||||||
} from '@renderer/constants/cssVariables';
|
} from '@renderer/constants/cssVariables';
|
||||||
import { formatPercentOfTotal } from '@renderer/utils/contextMath';
|
|
||||||
import { formatCostUsd } from '@shared/utils/costFormatting';
|
import { formatCostUsd } from '@shared/utils/costFormatting';
|
||||||
import { ArrowDownWideNarrow, FileText, LayoutList, X } from 'lucide-react';
|
import { ArrowDownWideNarrow, FileText, LayoutList, X } from 'lucide-react';
|
||||||
|
|
||||||
|
|
@ -23,11 +22,12 @@ import { SessionContextHelpTooltip } from './SessionContextHelpTooltip';
|
||||||
import type { ContextViewMode } from '../types';
|
import type { ContextViewMode } from '../types';
|
||||||
import type { ContextPhaseInfo } from '@renderer/types/contextInjection';
|
import type { ContextPhaseInfo } from '@renderer/types/contextInjection';
|
||||||
import type { SessionMetrics } from '@shared/types';
|
import type { SessionMetrics } from '@shared/types';
|
||||||
|
import type { DerivedContextMetrics } from '@shared/utils/contextMetrics';
|
||||||
|
|
||||||
interface SessionContextHeaderProps {
|
interface SessionContextHeaderProps {
|
||||||
injectionCount: number;
|
injectionCount: number;
|
||||||
totalTokens: number;
|
totalTokens: number;
|
||||||
totalSessionTokens?: number;
|
contextMetrics?: DerivedContextMetrics;
|
||||||
sessionMetrics?: SessionMetrics;
|
sessionMetrics?: SessionMetrics;
|
||||||
subagentCostUsd?: number;
|
subagentCostUsd?: number;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
|
|
@ -42,7 +42,7 @@ interface SessionContextHeaderProps {
|
||||||
export const SessionContextHeader = ({
|
export const SessionContextHeader = ({
|
||||||
injectionCount,
|
injectionCount,
|
||||||
totalTokens,
|
totalTokens,
|
||||||
totalSessionTokens,
|
contextMetrics,
|
||||||
sessionMetrics,
|
sessionMetrics,
|
||||||
subagentCostUsd,
|
subagentCostUsd,
|
||||||
onClose,
|
onClose,
|
||||||
|
|
@ -53,6 +53,45 @@ export const SessionContextHeader = ({
|
||||||
viewMode,
|
viewMode,
|
||||||
onViewModeChange,
|
onViewModeChange,
|
||||||
}: Readonly<SessionContextHeaderProps>): React.ReactElement => {
|
}: Readonly<SessionContextHeaderProps>): React.ReactElement => {
|
||||||
|
const formatPercentLabel = (percent: number | null, suffix: string): string | null => {
|
||||||
|
if (percent === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return `${percent.toFixed(1)}% ${suffix}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderMetricValue = (
|
||||||
|
label: string,
|
||||||
|
tokens: number | null,
|
||||||
|
percentLabel: string | null,
|
||||||
|
options?: {
|
||||||
|
approximate?: boolean;
|
||||||
|
unavailableLabel?: string;
|
||||||
|
}
|
||||||
|
): React.ReactElement => (
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between gap-3 rounded px-2 py-1.5"
|
||||||
|
style={{ backgroundColor: COLOR_SURFACE_OVERLAY }}
|
||||||
|
>
|
||||||
|
<span style={{ color: COLOR_TEXT_MUTED }}>{label}</span>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="font-medium tabular-nums" style={{ color: COLOR_TEXT_SECONDARY }}>
|
||||||
|
{tokens === null
|
||||||
|
? (options?.unavailableLabel ?? 'Unavailable')
|
||||||
|
: `${options?.approximate ? '~' : ''}${formatTokens(tokens)}`}
|
||||||
|
</div>
|
||||||
|
{percentLabel && (
|
||||||
|
<div className="text-[10px] tabular-nums" style={{ color: COLOR_TEXT_MUTED }}>
|
||||||
|
{percentLabel}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const codexTelemetryUnavailable =
|
||||||
|
contextMetrics?.providerId === 'codex' && contextMetrics.promptInputSource === 'unavailable';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shrink-0 px-4 py-3" style={{ borderBottom: `1px solid ${COLOR_BORDER}` }}>
|
<div className="shrink-0 px-4 py-3" style={{ borderBottom: `1px solid ${COLOR_BORDER}` }}>
|
||||||
{/* Title row */}
|
{/* Title row */}
|
||||||
|
|
@ -60,7 +99,7 @@ export const SessionContextHeader = ({
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<FileText size={16} style={{ color: COLOR_TEXT_SECONDARY }} />
|
<FileText size={16} style={{ color: COLOR_TEXT_SECONDARY }} />
|
||||||
<h2 className="text-sm font-semibold" style={{ color: COLOR_TEXT }}>
|
<h2 className="text-sm font-semibold" style={{ color: COLOR_TEXT }}>
|
||||||
Visible Context
|
Context
|
||||||
</h2>
|
</h2>
|
||||||
<span
|
<span
|
||||||
className="rounded px-1.5 py-0.5 text-xs"
|
className="rounded px-1.5 py-0.5 text-xs"
|
||||||
|
|
@ -87,43 +126,51 @@ export const SessionContextHeader = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Token comparison stats */}
|
{/* Primary metrics */}
|
||||||
<div
|
<div
|
||||||
className="mt-2 flex items-center justify-between pt-2 text-xs"
|
className="mt-2 space-y-1.5 pt-2 text-xs"
|
||||||
style={{ borderTop: `1px solid ${COLOR_BORDER_SUBTLE}` }}
|
style={{ borderTop: `1px solid ${COLOR_BORDER_SUBTLE}` }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-4">
|
{renderMetricValue(
|
||||||
{/* Visible Context tokens */}
|
'Context Used',
|
||||||
<div>
|
contextMetrics?.contextUsedTokens ?? null,
|
||||||
<span style={{ color: COLOR_TEXT_MUTED }}>Visible: </span>
|
formatPercentLabel(
|
||||||
<span className="font-medium tabular-nums" style={{ color: COLOR_TEXT_SECONDARY }}>
|
contextMetrics?.contextUsedPercentOfContextWindow ?? null,
|
||||||
~{formatTokens(totalTokens)}
|
'of context'
|
||||||
</span>
|
)
|
||||||
</div>
|
)}
|
||||||
{/* Total Input tokens (if provided) */}
|
{renderMetricValue(
|
||||||
{totalSessionTokens !== undefined && totalSessionTokens > 0 && (
|
'Prompt Input',
|
||||||
<div>
|
contextMetrics?.promptInputTokens ?? null,
|
||||||
<span style={{ color: COLOR_TEXT_MUTED }}>Input: </span>
|
formatPercentLabel(
|
||||||
<span className="font-medium tabular-nums" style={{ color: COLOR_TEXT_SECONDARY }}>
|
contextMetrics?.promptInputPercentOfContextWindow ?? null,
|
||||||
{formatTokens(totalSessionTokens)}
|
'of context'
|
||||||
</span>
|
)
|
||||||
</div>
|
)}
|
||||||
)}
|
{renderMetricValue(
|
||||||
</div>
|
'Visible Context',
|
||||||
{/* Percentage of total */}
|
totalTokens,
|
||||||
{formatPercentOfTotal(totalTokens, totalSessionTokens) && (
|
formatPercentLabel(
|
||||||
<span
|
contextMetrics?.visibleContextPercentOfPromptInput ?? null,
|
||||||
className="rounded px-1.5 py-0.5 tabular-nums"
|
'of prompt'
|
||||||
style={{
|
),
|
||||||
backgroundColor: COLOR_SURFACE_OVERLAY,
|
{ approximate: true }
|
||||||
color: COLOR_TEXT_MUTED,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formatPercentOfTotal(totalTokens, totalSessionTokens)}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{codexTelemetryUnavailable && (
|
||||||
|
<div
|
||||||
|
className="mt-2 rounded px-2 py-1.5 text-[10px]"
|
||||||
|
style={{
|
||||||
|
border: `1px solid ${COLOR_BORDER_SUBTLE}`,
|
||||||
|
color: COLOR_TEXT_MUTED,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Codex prompt-side usage is not exposed by the current runtime telemetry yet, so Prompt
|
||||||
|
Input and Context Used stay unavailable instead of showing a fake zero.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Session Metrics Breakdown */}
|
{/* Session Metrics Breakdown */}
|
||||||
{sessionMetrics && (
|
{sessionMetrics && (
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/**
|
/**
|
||||||
* SessionContextHelpTooltip - Help tooltip explaining Visible Context vs Total Tokens.
|
* SessionContextHelpTooltip - Help tooltip explaining context metrics.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
|
@ -116,64 +116,45 @@ export const SessionContextHelpTooltip = (): React.ReactElement => {
|
||||||
<div style={arrowStyle} />
|
<div style={arrowStyle} />
|
||||||
|
|
||||||
<div className="space-y-3 text-xs">
|
<div className="space-y-3 text-xs">
|
||||||
{/* What is Visible Context */}
|
{/* Metric definitions */}
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-1 font-semibold" style={{ color: 'var(--color-text)' }}>
|
<div className="mb-1 font-semibold" style={{ color: 'var(--color-text)' }}>
|
||||||
What is Visible Context?
|
Context Used
|
||||||
</div>
|
</div>
|
||||||
<p style={{ color: 'var(--color-text-secondary)', lineHeight: 1.5 }}>
|
<p style={{ color: 'var(--color-text-secondary)', lineHeight: 1.5 }}>
|
||||||
Tokens consumed by file reads, tool outputs, and configuration files (CLAUDE.md)
|
Prompt input plus output tokens currently occupying the model's context
|
||||||
that are injected into the conversation.
|
window.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Difference with Total */}
|
|
||||||
<div className="pt-2" style={{ borderTop: '1px solid var(--color-border-subtle)' }}>
|
<div className="pt-2" style={{ borderTop: '1px solid var(--color-border-subtle)' }}>
|
||||||
<div className="mb-1 font-semibold" style={{ color: 'var(--color-text)' }}>
|
<div className="mb-1 font-semibold" style={{ color: 'var(--color-text)' }}>
|
||||||
Total Context vs Visible Context
|
Prompt Input
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="space-y-2"
|
|
||||||
style={{ color: 'var(--color-text-secondary)', lineHeight: 1.5 }}
|
|
||||||
>
|
|
||||||
<div className="flex">
|
|
||||||
<span
|
|
||||||
className="min-w-[74px] text-left"
|
|
||||||
style={{ color: 'var(--color-text-muted)' }}
|
|
||||||
>
|
|
||||||
Total:
|
|
||||||
</span>
|
|
||||||
<span className="flex-1 leading-snug">
|
|
||||||
Total tokens that are injected into the conversation
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex">
|
|
||||||
<span
|
|
||||||
className="min-w-[74px] text-left"
|
|
||||||
style={{ color: 'var(--color-text-muted)' }}
|
|
||||||
>
|
|
||||||
Visible:
|
|
||||||
</span>
|
|
||||||
<span className="flex-1 leading-snug">
|
|
||||||
Subset of tokens that you can optimize & debug
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p style={{ color: 'var(--color-text-secondary)', lineHeight: 1.5 }}>
|
||||||
|
Tokens sent to the model before generation. For Claude this includes `input_tokens
|
||||||
|
+ cache_creation_input_tokens + cache_read_input_tokens`.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tips */}
|
|
||||||
<div className="pt-2" style={{ borderTop: '1px solid var(--color-border-subtle)' }}>
|
<div className="pt-2" style={{ borderTop: '1px solid var(--color-border-subtle)' }}>
|
||||||
<div className="mb-1 font-semibold" style={{ color: 'var(--color-text)' }}>
|
<div className="mb-1 font-semibold" style={{ color: 'var(--color-text)' }}>
|
||||||
Optimization Tips
|
Visible Context
|
||||||
</div>
|
</div>
|
||||||
<ul
|
<p style={{ color: 'var(--color-text-secondary)', lineHeight: 1.5 }}>
|
||||||
className="space-y-1 pl-3"
|
The inspectable subset of prompt input: files, CLAUDE.md, tool outputs, user
|
||||||
style={{ color: 'var(--color-text-secondary)', lineHeight: 1.5 }}
|
messages, and similar injections that you can optimize directly.
|
||||||
>
|
</p>
|
||||||
<li className="list-disc">Shorten large CLAUDE.md files</li>
|
</div>
|
||||||
<li className="list-disc">Split large @-mentioned files</li>
|
|
||||||
<li className="list-disc">Adjust MCP tool output verbosity</li>
|
<div className="pt-2" style={{ borderTop: '1px solid var(--color-border-subtle)' }}>
|
||||||
</ul>
|
<div className="mb-1 font-semibold" style={{ color: 'var(--color-text)' }}>
|
||||||
|
Availability
|
||||||
|
</div>
|
||||||
|
<p style={{ color: 'var(--color-text-secondary)', lineHeight: 1.5 }}>
|
||||||
|
If a provider runtime does not expose prompt-side usage yet, the panel shows
|
||||||
|
metrics as unavailable instead of pretending they are zero.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ export const SessionContextPanel = ({
|
||||||
onNavigateToTurn,
|
onNavigateToTurn,
|
||||||
onNavigateToTool,
|
onNavigateToTool,
|
||||||
onNavigateToUserGroup,
|
onNavigateToUserGroup,
|
||||||
totalSessionTokens,
|
contextMetrics,
|
||||||
sessionMetrics,
|
sessionMetrics,
|
||||||
subagentCostUsd,
|
subagentCostUsd,
|
||||||
onViewReport,
|
onViewReport,
|
||||||
|
|
@ -193,7 +193,7 @@ export const SessionContextPanel = ({
|
||||||
<SessionContextHeader
|
<SessionContextHeader
|
||||||
injectionCount={injections.length}
|
injectionCount={injections.length}
|
||||||
totalTokens={totalTokens}
|
totalTokens={totalTokens}
|
||||||
totalSessionTokens={totalSessionTokens}
|
contextMetrics={contextMetrics}
|
||||||
sessionMetrics={sessionMetrics}
|
sessionMetrics={sessionMetrics}
|
||||||
subagentCostUsd={subagentCostUsd}
|
subagentCostUsd={subagentCostUsd}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
import type { ClaudeMdSource } from '@renderer/types/claudeMd';
|
import type { ClaudeMdSource } from '@renderer/types/claudeMd';
|
||||||
import type { ContextInjection, ContextPhaseInfo } from '@renderer/types/contextInjection';
|
import type { ContextInjection, ContextPhaseInfo } from '@renderer/types/contextInjection';
|
||||||
import type { SessionMetrics } from '@shared/types';
|
import type { SessionMetrics } from '@shared/types';
|
||||||
|
import type { DerivedContextMetrics } from '@shared/utils/contextMetrics';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Props Interface
|
// Props Interface
|
||||||
|
|
@ -23,8 +24,8 @@ export interface SessionContextPanelProps {
|
||||||
onNavigateToTool?: (turnIndex: number, toolUseId: string) => void;
|
onNavigateToTool?: (turnIndex: number, toolUseId: string) => void;
|
||||||
/** Navigate to the user message group preceding the AI group at turnIndex */
|
/** Navigate to the user message group preceding the AI group at turnIndex */
|
||||||
onNavigateToUserGroup?: (turnIndex: number) => void;
|
onNavigateToUserGroup?: (turnIndex: number) => void;
|
||||||
/** Total session tokens (input + output + cache) for comparison */
|
/** Unified context metrics for the selected AI group */
|
||||||
totalSessionTokens?: number;
|
contextMetrics?: DerivedContextMetrics;
|
||||||
/** Full session metrics (input, output, cache tokens, cost) */
|
/** Full session metrics (input, output, cache tokens, cost) */
|
||||||
sessionMetrics?: SessionMetrics;
|
sessionMetrics?: SessionMetrics;
|
||||||
/** Combined cost of all subagent processes */
|
/** Combined cost of all subagent processes */
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'
|
||||||
import { useTabUI } from '@renderer/hooks/useTabUI';
|
import { useTabUI } from '@renderer/hooks/useTabUI';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
|
||||||
import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins';
|
import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins';
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify';
|
import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify';
|
||||||
|
|
@ -398,7 +399,7 @@ const UserChatGroupInner = ({ userGroup }: Readonly<UserChatGroupProps>): React.
|
||||||
// Get team members for @mention highlighting and team names for @team linkification
|
// Get team members for @mention highlighting and team names for @team linkification
|
||||||
const { members, teams } = useStore(
|
const { members, teams } = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
members: s.selectedTeamData?.members,
|
members: selectResolvedMembersForTeamName(s, s.selectedTeamName),
|
||||||
teams: s.teams,
|
teams: s.teams,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
|
||||||
import { detectOperationalNoise } from '@renderer/utils/agentMessageFormatting';
|
import { detectOperationalNoise } from '@renderer/utils/agentMessageFormatting';
|
||||||
import { formatTokensCompact } from '@renderer/utils/formatters';
|
import { formatTokensCompact } from '@renderer/utils/formatters';
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
|
|
@ -86,7 +87,9 @@ export const TeammateMessageItem: React.FC<TeammateMessageItemProps> = ({
|
||||||
const { isLight } = useTheme();
|
const { isLight } = useTheme();
|
||||||
|
|
||||||
// Get team members for @mention highlighting
|
// Get team members for @mention highlighting
|
||||||
const members = useStore(useShallow((s) => s.selectedTeamData?.members));
|
const members = useStore(
|
||||||
|
useShallow((s) => selectResolvedMembersForTeamName(s, s.selectedTeamName))
|
||||||
|
);
|
||||||
const memberColorMap = useMemo(
|
const memberColorMap = useMemo(
|
||||||
() => (members ? buildMemberColorMap(members) : new Map<string, string>()),
|
() => (members ? buildMemberColorMap(members) : new Map<string, string>()),
|
||||||
[members]
|
[members]
|
||||||
|
|
|
||||||
|
|
@ -71,10 +71,21 @@ interface MarkdownViewerProps {
|
||||||
onTeamClick?: (teamName: string) => void;
|
onTeamClick?: (teamName: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CompactMarkdownPreviewProps {
|
||||||
|
content: string;
|
||||||
|
className?: string;
|
||||||
|
/** Optional precomputed team color map to avoid subscribing to the full team list. */
|
||||||
|
teamColorByName?: ReadonlyMap<string, string>;
|
||||||
|
/** Optional team click handler to avoid subscribing to store in leaf renderers. */
|
||||||
|
onTeamClick?: (teamName: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
const EMPTY_TEAMS: { teamName?: string; displayName?: string; color?: string }[] = [];
|
const EMPTY_TEAMS: { teamName?: string; displayName?: string; color?: string }[] = [];
|
||||||
const EMPTY_TEAM_COLOR_MAP = new Map<string, string>();
|
const EMPTY_TEAM_COLOR_MAP = new Map<string, string>();
|
||||||
const NOOP_TEAM_CLICK = (): void => undefined;
|
const NOOP_TEAM_CLICK = (): void => undefined;
|
||||||
|
|
||||||
|
type ViewerMarkdownMode = 'default' | 'compact-preview';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Helpers
|
// Helpers
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -322,53 +333,89 @@ function createViewerMarkdownComponents(
|
||||||
isLight = false,
|
isLight = false,
|
||||||
teamColorByName: ReadonlyMap<string, string> = new Map(),
|
teamColorByName: ReadonlyMap<string, string> = new Map(),
|
||||||
onTeamClick?: (teamName: string) => void,
|
onTeamClick?: (teamName: string) => void,
|
||||||
copyCodeBlocks: boolean = false
|
copyCodeBlocks: boolean = false,
|
||||||
|
mode: ViewerMarkdownMode = 'default'
|
||||||
): Components {
|
): Components {
|
||||||
const hl = (children: React.ReactNode): React.ReactNode =>
|
const hl = (children: React.ReactNode): React.ReactNode =>
|
||||||
searchCtx ? highlightSearchInChildren(children, searchCtx) : children;
|
searchCtx ? highlightSearchInChildren(children, searchCtx) : children;
|
||||||
|
const isCompactPreview = mode === 'compact-preview';
|
||||||
|
|
||||||
|
const renderCompactInline = (
|
||||||
|
children: React.ReactNode,
|
||||||
|
className: string,
|
||||||
|
style: React.CSSProperties
|
||||||
|
): React.ReactElement => (
|
||||||
|
<span className={className} style={style}>
|
||||||
|
{hl(children)}{' '}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Headings
|
// Headings
|
||||||
h1: ({ children }) => (
|
h1: ({ children }) =>
|
||||||
<h1 className="mb-2 mt-4 text-xl font-semibold first:mt-0" style={{ color: PROSE_HEADING }}>
|
isCompactPreview ? (
|
||||||
{hl(children)}
|
renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING })
|
||||||
</h1>
|
) : (
|
||||||
),
|
<h1 className="mb-2 mt-4 text-xl font-semibold first:mt-0" style={{ color: PROSE_HEADING }}>
|
||||||
h2: ({ children }) => (
|
{hl(children)}
|
||||||
<h2 className="mb-2 mt-4 text-lg font-semibold first:mt-0" style={{ color: PROSE_HEADING }}>
|
</h1>
|
||||||
{hl(children)}
|
),
|
||||||
</h2>
|
h2: ({ children }) =>
|
||||||
),
|
isCompactPreview ? (
|
||||||
h3: ({ children }) => (
|
renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING })
|
||||||
<h3 className="mb-2 mt-3 text-base font-semibold first:mt-0" style={{ color: PROSE_HEADING }}>
|
) : (
|
||||||
{hl(children)}
|
<h2 className="mb-2 mt-4 text-lg font-semibold first:mt-0" style={{ color: PROSE_HEADING }}>
|
||||||
</h3>
|
{hl(children)}
|
||||||
),
|
</h2>
|
||||||
h4: ({ children }) => (
|
),
|
||||||
<h4 className="mb-1 mt-3 text-sm font-semibold first:mt-0" style={{ color: PROSE_HEADING }}>
|
h3: ({ children }) =>
|
||||||
{hl(children)}
|
isCompactPreview ? (
|
||||||
</h4>
|
renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING })
|
||||||
),
|
) : (
|
||||||
h5: ({ children }) => (
|
<h3
|
||||||
<h5 className="mb-1 mt-2 text-sm font-medium first:mt-0" style={{ color: PROSE_HEADING }}>
|
className="mb-2 mt-3 text-base font-semibold first:mt-0"
|
||||||
{hl(children)}
|
style={{ color: PROSE_HEADING }}
|
||||||
</h5>
|
>
|
||||||
),
|
{hl(children)}
|
||||||
h6: ({ children }) => (
|
</h3>
|
||||||
<h6 className="mb-1 mt-2 text-xs font-medium first:mt-0" style={{ color: PROSE_HEADING }}>
|
),
|
||||||
{hl(children)}
|
h4: ({ children }) =>
|
||||||
</h6>
|
isCompactPreview ? (
|
||||||
),
|
renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING })
|
||||||
|
) : (
|
||||||
|
<h4 className="mb-1 mt-3 text-sm font-semibold first:mt-0" style={{ color: PROSE_HEADING }}>
|
||||||
|
{hl(children)}
|
||||||
|
</h4>
|
||||||
|
),
|
||||||
|
h5: ({ children }) =>
|
||||||
|
isCompactPreview ? (
|
||||||
|
renderCompactInline(children, 'font-medium', { color: PROSE_HEADING })
|
||||||
|
) : (
|
||||||
|
<h5 className="mb-1 mt-2 text-sm font-medium first:mt-0" style={{ color: PROSE_HEADING }}>
|
||||||
|
{hl(children)}
|
||||||
|
</h5>
|
||||||
|
),
|
||||||
|
h6: ({ children }) =>
|
||||||
|
isCompactPreview ? (
|
||||||
|
renderCompactInline(children, 'font-medium', { color: PROSE_HEADING })
|
||||||
|
) : (
|
||||||
|
<h6 className="mb-1 mt-2 text-xs font-medium first:mt-0" style={{ color: PROSE_HEADING }}>
|
||||||
|
{hl(children)}
|
||||||
|
</h6>
|
||||||
|
),
|
||||||
|
|
||||||
// Paragraphs
|
// Paragraphs
|
||||||
p: ({ children }) => (
|
p: ({ children }) =>
|
||||||
<p
|
isCompactPreview ? (
|
||||||
className="my-2 text-sm leading-relaxed first:mt-0 last:mb-0"
|
renderCompactInline(children, '', { color: PROSE_BODY })
|
||||||
style={{ color: PROSE_BODY }}
|
) : (
|
||||||
>
|
<p
|
||||||
{hl(children)}
|
className="my-2 text-sm leading-relaxed first:mt-0 last:mb-0"
|
||||||
</p>
|
style={{ color: PROSE_BODY }}
|
||||||
),
|
>
|
||||||
|
{hl(children)}
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
|
||||||
// Links — inline element, no hl(); parent block element's hl() descends here
|
// Links — inline element, no hl(); parent block element's hl() descends here
|
||||||
// task:// links render with TaskTooltip + are clickable via ancestor onClickCapture
|
// task:// links render with TaskTooltip + are clickable via ancestor onClickCapture
|
||||||
|
|
@ -570,6 +617,20 @@ function createViewerMarkdownComponents(
|
||||||
|
|
||||||
// Code blocks — intercept mermaid diagrams at the pre level
|
// Code blocks — intercept mermaid diagrams at the pre level
|
||||||
pre: ({ children, node }) => {
|
pre: ({ children, node }) => {
|
||||||
|
if (isCompactPreview) {
|
||||||
|
const compactText = extractTextFromReactNode(children).trim();
|
||||||
|
return (
|
||||||
|
<code
|
||||||
|
className="break-all rounded px-1.5 py-0.5 font-mono text-xs"
|
||||||
|
style={{
|
||||||
|
backgroundColor: PROSE_CODE_BG,
|
||||||
|
color: PROSE_CODE_TEXT,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{compactText}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
}
|
||||||
// Check if this pre contains a mermaid code block
|
// Check if this pre contains a mermaid code block
|
||||||
const codeEl = node?.children?.[0];
|
const codeEl = node?.children?.[0];
|
||||||
if (codeEl && 'tagName' in codeEl && codeEl.tagName === 'code' && 'properties' in codeEl) {
|
if (codeEl && 'tagName' in codeEl && codeEl.tagName === 'code' && 'properties' in codeEl) {
|
||||||
|
|
@ -596,74 +657,107 @@ function createViewerMarkdownComponents(
|
||||||
},
|
},
|
||||||
|
|
||||||
// Blockquotes
|
// Blockquotes
|
||||||
blockquote: ({ children }) => (
|
blockquote: ({ children }) =>
|
||||||
<blockquote
|
isCompactPreview ? (
|
||||||
className="my-3 border-l-4 pl-4 italic"
|
renderCompactInline(children, 'italic', { color: PROSE_MUTED })
|
||||||
style={{
|
) : (
|
||||||
borderColor: PROSE_BLOCKQUOTE_BORDER,
|
<blockquote
|
||||||
color: PROSE_MUTED,
|
className="my-3 border-l-4 pl-4 italic"
|
||||||
}}
|
style={{
|
||||||
>
|
borderColor: PROSE_BLOCKQUOTE_BORDER,
|
||||||
{hl(children)}
|
color: PROSE_MUTED,
|
||||||
</blockquote>
|
}}
|
||||||
),
|
>
|
||||||
|
{hl(children)}
|
||||||
|
</blockquote>
|
||||||
|
),
|
||||||
|
|
||||||
// Lists
|
// Lists
|
||||||
ul: ({ children }) => (
|
ul: ({ children }) =>
|
||||||
<ul className="my-2 list-disc space-y-1 pl-5" style={{ color: PROSE_BODY }}>
|
isCompactPreview ? (
|
||||||
{children}
|
<span>{children}</span>
|
||||||
</ul>
|
) : (
|
||||||
),
|
<ul className="my-2 list-disc space-y-1 pl-5" style={{ color: PROSE_BODY }}>
|
||||||
ol: ({ children }) => (
|
{children}
|
||||||
<ol className="my-2 list-decimal space-y-1 pl-5" style={{ color: PROSE_BODY }}>
|
</ul>
|
||||||
{children}
|
),
|
||||||
</ol>
|
ol: ({ children }) =>
|
||||||
),
|
isCompactPreview ? (
|
||||||
li: ({ children }) => (
|
<span>{children}</span>
|
||||||
<li className="text-sm" style={{ color: PROSE_BODY }}>
|
) : (
|
||||||
{hl(children)}
|
<ol className="my-2 list-decimal space-y-1 pl-5" style={{ color: PROSE_BODY }}>
|
||||||
</li>
|
{children}
|
||||||
),
|
</ol>
|
||||||
|
),
|
||||||
|
li: ({ children }) =>
|
||||||
|
isCompactPreview ? (
|
||||||
|
<span className="inline" style={{ color: PROSE_BODY }}>
|
||||||
|
• {hl(children)}{' '}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<li className="text-sm" style={{ color: PROSE_BODY }}>
|
||||||
|
{hl(children)}
|
||||||
|
</li>
|
||||||
|
),
|
||||||
|
|
||||||
// Tables
|
// Tables
|
||||||
table: ({ children }) => (
|
table: ({ children }) =>
|
||||||
<div className="my-3 overflow-x-auto">
|
isCompactPreview ? (
|
||||||
<table
|
<span>{children}</span>
|
||||||
className="min-w-full border-collapse text-sm"
|
) : (
|
||||||
style={{ borderColor: PROSE_TABLE_BORDER }}
|
<div className="my-3 overflow-x-auto">
|
||||||
|
<table
|
||||||
|
className="min-w-full border-collapse text-sm"
|
||||||
|
style={{ borderColor: PROSE_TABLE_BORDER }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
thead: ({ children }) =>
|
||||||
|
isCompactPreview ? (
|
||||||
|
<span>{children}</span>
|
||||||
|
) : (
|
||||||
|
<thead style={{ backgroundColor: PROSE_TABLE_HEADER_BG }}>{children}</thead>
|
||||||
|
),
|
||||||
|
th: ({ children }) =>
|
||||||
|
isCompactPreview ? (
|
||||||
|
renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING })
|
||||||
|
) : (
|
||||||
|
<th
|
||||||
|
className="px-3 py-2 text-left font-semibold"
|
||||||
|
style={{
|
||||||
|
border: `1px solid ${PROSE_TABLE_BORDER}`,
|
||||||
|
color: PROSE_HEADING,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{hl(children)}
|
||||||
</table>
|
</th>
|
||||||
</div>
|
),
|
||||||
),
|
td: ({ children }) =>
|
||||||
thead: ({ children }) => (
|
isCompactPreview ? (
|
||||||
<thead style={{ backgroundColor: PROSE_TABLE_HEADER_BG }}>{children}</thead>
|
renderCompactInline(children, '', { color: PROSE_BODY })
|
||||||
),
|
) : (
|
||||||
th: ({ children }) => (
|
<td
|
||||||
<th
|
className="px-3 py-2"
|
||||||
className="px-3 py-2 text-left font-semibold"
|
style={{
|
||||||
style={{
|
border: `1px solid ${PROSE_TABLE_BORDER}`,
|
||||||
border: `1px solid ${PROSE_TABLE_BORDER}`,
|
color: PROSE_BODY,
|
||||||
color: PROSE_HEADING,
|
}}
|
||||||
}}
|
>
|
||||||
>
|
{hl(children)}
|
||||||
{hl(children)}
|
</td>
|
||||||
</th>
|
),
|
||||||
),
|
|
||||||
td: ({ children }) => (
|
|
||||||
<td
|
|
||||||
className="px-3 py-2"
|
|
||||||
style={{
|
|
||||||
border: `1px solid ${PROSE_TABLE_BORDER}`,
|
|
||||||
color: PROSE_BODY,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{hl(children)}
|
|
||||||
</td>
|
|
||||||
),
|
|
||||||
|
|
||||||
// Horizontal rule
|
// Horizontal rule
|
||||||
hr: () => <hr className="my-4" style={{ borderColor: PROSE_TABLE_BORDER }} />,
|
hr: () =>
|
||||||
|
isCompactPreview ? (
|
||||||
|
<span className="mx-1" style={{ color: PROSE_TABLE_BORDER }}>
|
||||||
|
·
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<hr className="my-4" style={{ borderColor: PROSE_TABLE_BORDER }} />
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -679,6 +773,78 @@ const LARGE_PREVIEW_CHARS = 30_000;
|
||||||
// Component
|
// Component
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
|
function useResolvedViewerTeamContext(
|
||||||
|
providedTeamColorByName?: ReadonlyMap<string, string>,
|
||||||
|
providedOnTeamClick?: (teamName: string) => void
|
||||||
|
): {
|
||||||
|
teamColorByName: ReadonlyMap<string, string>;
|
||||||
|
onTeamClick?: (teamName: string) => void;
|
||||||
|
} {
|
||||||
|
const teams = useStore(useShallow((s) => (providedTeamColorByName ? EMPTY_TEAMS : s.teams)));
|
||||||
|
const openTeamTab = useStore((s) => (providedOnTeamClick ? NOOP_TEAM_CLICK : s.openTeamTab));
|
||||||
|
|
||||||
|
const fallbackTeamColorByName = React.useMemo(() => {
|
||||||
|
const result = new Map<string, string>();
|
||||||
|
for (const team of teams) {
|
||||||
|
if (team.teamName) {
|
||||||
|
result.set(team.teamName, team.color ?? '');
|
||||||
|
}
|
||||||
|
if (team.displayName) {
|
||||||
|
result.set(team.displayName, team.color ?? '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [teams]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
teamColorByName: providedTeamColorByName ?? fallbackTeamColorByName ?? EMPTY_TEAM_COLOR_MAP,
|
||||||
|
onTeamClick: providedOnTeamClick ?? openTeamTab,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CompactMarkdownPreview: React.FC<CompactMarkdownPreviewProps> = React.memo(
|
||||||
|
function CompactMarkdownPreview({
|
||||||
|
content,
|
||||||
|
className = '',
|
||||||
|
teamColorByName: providedTeamColorByName,
|
||||||
|
onTeamClick: providedOnTeamClick,
|
||||||
|
}) {
|
||||||
|
const { isLight } = useTheme();
|
||||||
|
const { teamColorByName, onTeamClick } = useResolvedViewerTeamContext(
|
||||||
|
providedTeamColorByName,
|
||||||
|
providedOnTeamClick
|
||||||
|
);
|
||||||
|
|
||||||
|
const components = React.useMemo(
|
||||||
|
() =>
|
||||||
|
createViewerMarkdownComponents(
|
||||||
|
null,
|
||||||
|
isLight,
|
||||||
|
teamColorByName,
|
||||||
|
onTeamClick,
|
||||||
|
false,
|
||||||
|
'compact-preview'
|
||||||
|
),
|
||||||
|
[isLight, onTeamClick, teamColorByName]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`min-w-0 overflow-hidden ${className}`}>
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
rehypePlugins={REHYPE_PLUGINS_NO_HIGHLIGHT}
|
||||||
|
components={components}
|
||||||
|
urlTransform={allowCustomProtocols}
|
||||||
|
allowElement={isAllowedElement}
|
||||||
|
unwrapDisallowed
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
||||||
content,
|
content,
|
||||||
maxHeight = 'max-h-96',
|
maxHeight = 'max-h-96',
|
||||||
|
|
@ -695,24 +861,10 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
||||||
const [showRaw, setShowRaw] = React.useState(false);
|
const [showRaw, setShowRaw] = React.useState(false);
|
||||||
const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS);
|
const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS);
|
||||||
const { isLight } = useTheme();
|
const { isLight } = useTheme();
|
||||||
const teams = useStore(useShallow((s) => (providedTeamColorByName ? EMPTY_TEAMS : s.teams)));
|
const { teamColorByName, onTeamClick } = useResolvedViewerTeamContext(
|
||||||
const openTeamTab = useStore((s) => (providedOnTeamClick ? NOOP_TEAM_CLICK : s.openTeamTab));
|
providedTeamColorByName,
|
||||||
|
providedOnTeamClick
|
||||||
const fallbackTeamColorByName = React.useMemo(() => {
|
);
|
||||||
const result = new Map<string, string>();
|
|
||||||
for (const team of teams) {
|
|
||||||
if (team.teamName) {
|
|
||||||
result.set(team.teamName, team.color ?? '');
|
|
||||||
}
|
|
||||||
if (team.displayName) {
|
|
||||||
result.set(team.displayName, team.color ?? '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}, [teams]);
|
|
||||||
const teamColorByName =
|
|
||||||
providedTeamColorByName ?? fallbackTeamColorByName ?? EMPTY_TEAM_COLOR_MAP;
|
|
||||||
const onTeamClick = providedOnTeamClick ?? openTeamTab;
|
|
||||||
|
|
||||||
const isTooLarge = content.length > MAX_MARKDOWN_CHARS;
|
const isTooLarge = content.length > MAX_MARKDOWN_CHARS;
|
||||||
const disableHighlight = content.length > DISABLE_HIGHLIGHT_CHARS;
|
const disableHighlight = content.length > DISABLE_HIGHLIGHT_CHARS;
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ interface OngoingIndicatorProps {
|
||||||
showLabel?: boolean;
|
showLabel?: boolean;
|
||||||
/** Custom label text */
|
/** Custom label text */
|
||||||
label?: string;
|
label?: string;
|
||||||
|
/** Accessible title/tooltip text */
|
||||||
|
title?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -24,11 +26,12 @@ export const OngoingIndicator = ({
|
||||||
size = 'sm',
|
size = 'sm',
|
||||||
showLabel = false,
|
showLabel = false,
|
||||||
label = 'Session in progress...',
|
label = 'Session in progress...',
|
||||||
|
title = label,
|
||||||
}: Readonly<OngoingIndicatorProps>): React.JSX.Element => {
|
}: Readonly<OngoingIndicatorProps>): React.JSX.Element => {
|
||||||
const dotSize = size === 'sm' ? 'h-2 w-2' : 'h-2.5 w-2.5';
|
const dotSize = size === 'sm' ? 'h-2 w-2' : 'h-2.5 w-2.5';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-2" title="Session in progress">
|
<span className="inline-flex items-center gap-2" title={title}>
|
||||||
<span className={`relative flex ${dotSize} shrink-0`}>
|
<span className={`relative flex ${dotSize} shrink-0`}>
|
||||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-green-400 opacity-75" />
|
<span className="absolute inline-flex size-full animate-ping rounded-full bg-green-400 opacity-75" />
|
||||||
<span className={`relative inline-flex rounded-full ${dotSize} bg-green-500`} />
|
<span className={`relative inline-flex rounded-full ${dotSize} bg-green-500`} />
|
||||||
|
|
|
||||||
|
|
@ -48,8 +48,6 @@ interface TokenUsageDisplayProps {
|
||||||
totalPhases?: number;
|
totalPhases?: number;
|
||||||
/** Optional USD cost for this usage */
|
/** Optional USD cost for this usage */
|
||||||
costUsd?: number;
|
costUsd?: number;
|
||||||
/** Context window size (e.g., 200000 or 1000000). When provided, shows "X% context used" instead of "X% of input". */
|
|
||||||
contextWindowSize?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -59,27 +57,22 @@ interface TokenUsageDisplayProps {
|
||||||
const SessionContextSection = ({
|
const SessionContextSection = ({
|
||||||
contextStats,
|
contextStats,
|
||||||
totalInputTokens,
|
totalInputTokens,
|
||||||
contextWindowSize,
|
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
contextStats: ContextStats;
|
contextStats: ContextStats;
|
||||||
totalInputTokens: number;
|
totalInputTokens: number;
|
||||||
contextWindowSize?: number;
|
|
||||||
}>): React.JSX.Element => {
|
}>): React.JSX.Element => {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
const { tokensByCategory } = contextStats;
|
const { tokensByCategory } = contextStats;
|
||||||
|
|
||||||
// contextStats.totalEstimatedTokens already includes all categories (CLAUDE.md, @files,
|
// contextStats.totalEstimatedTokens already includes all categories (CLAUDE.md, @files,
|
||||||
// tool outputs, thinking+text, task coordination, user messages) — no manual adjustment needed.
|
// tool outputs, thinking+text, task coordination, user messages) - no manual adjustment needed.
|
||||||
// Show context window usage % when contextWindowSize is available (more useful),
|
// Visible Context is always shown as a share of prompt-side input tokens so this section
|
||||||
// otherwise fall back to visible context / total input ratio.
|
// stays aligned with the unified context contract instead of silently switching semantics.
|
||||||
const contextPercent =
|
const contextPercent =
|
||||||
contextWindowSize && contextWindowSize > 0
|
totalInputTokens > 0
|
||||||
? Math.min((totalInputTokens / contextWindowSize) * 100, 100).toFixed(1)
|
? Math.min((contextStats.totalEstimatedTokens / totalInputTokens) * 100, 100).toFixed(1)
|
||||||
: totalInputTokens > 0
|
: '0.0';
|
||||||
? Math.min((contextStats.totalEstimatedTokens / totalInputTokens) * 100, 100).toFixed(1)
|
|
||||||
: '0.0';
|
|
||||||
const contextLabel = contextWindowSize ? 'of context' : 'of input';
|
|
||||||
|
|
||||||
// Count accumulated injections by category
|
// Count accumulated injections by category
|
||||||
const claudeMdCount = contextStats.accumulatedInjections.filter(
|
const claudeMdCount = contextStats.accumulatedInjections.filter(
|
||||||
|
|
@ -152,7 +145,7 @@ const SessionContextSection = ({
|
||||||
className="whitespace-nowrap text-[10px] tabular-nums"
|
className="whitespace-nowrap text-[10px] tabular-nums"
|
||||||
style={{ color: COLOR_TEXT_MUTED }}
|
style={{ color: COLOR_TEXT_MUTED }}
|
||||||
>
|
>
|
||||||
{formatTokens(contextStats.totalEstimatedTokens)} ({contextPercent}% {contextLabel})
|
{formatTokens(contextStats.totalEstimatedTokens)} ({contextPercent}% of prompt input)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -261,10 +254,9 @@ export const TokenUsageDisplay = ({
|
||||||
phaseNumber,
|
phaseNumber,
|
||||||
totalPhases,
|
totalPhases,
|
||||||
costUsd,
|
costUsd,
|
||||||
contextWindowSize,
|
|
||||||
}: Readonly<TokenUsageDisplayProps>): React.JSX.Element => {
|
}: Readonly<TokenUsageDisplayProps>): React.JSX.Element => {
|
||||||
const totalTokens = inputTokens + cacheReadTokens + cacheCreationTokens + outputTokens;
|
const totalTokens = inputTokens + cacheReadTokens + cacheCreationTokens + outputTokens;
|
||||||
// Total input tokens only (without output) — used as denominator for visible context %
|
// Total prompt-side tokens only (without output) - used as denominator for visible context %
|
||||||
const totalInputTokens = inputTokens + cacheReadTokens + cacheCreationTokens;
|
const totalInputTokens = inputTokens + cacheReadTokens + cacheCreationTokens;
|
||||||
const formattedTotal = formatTokens(totalTokens);
|
const formattedTotal = formatTokens(totalTokens);
|
||||||
|
|
||||||
|
|
@ -540,7 +532,6 @@ export const TokenUsageDisplay = ({
|
||||||
<SessionContextSection
|
<SessionContextSection
|
||||||
contextStats={contextStats}
|
contextStats={contextStats}
|
||||||
totalInputTokens={totalInputTokens}
|
totalInputTokens={totalInputTokens}
|
||||||
contextWindowSize={contextWindowSize}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { resolveSkillProjectPath } from './skillProjectUtils';
|
import { resolveSkillProjectPath } from './skillProjectUtils';
|
||||||
|
|
||||||
import type { SkillValidationIssue } from '@shared/types';
|
import type { SkillValidationIssue } from '@shared/types/extensions';
|
||||||
|
|
||||||
interface SkillDetailDialogProps {
|
interface SkillDetailDialogProps {
|
||||||
skillId: string | null;
|
skillId: string | null;
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ export interface SafeConfig {
|
||||||
notifyOnCrossTeamMessage: boolean;
|
notifyOnCrossTeamMessage: boolean;
|
||||||
notifyOnTeamLaunched: boolean;
|
notifyOnTeamLaunched: boolean;
|
||||||
notifyOnToolApproval: boolean;
|
notifyOnToolApproval: boolean;
|
||||||
|
autoResumeOnRateLimit: boolean;
|
||||||
statusChangeOnlySolo: boolean;
|
statusChangeOnlySolo: boolean;
|
||||||
statusChangeStatuses: string[];
|
statusChangeStatuses: string[];
|
||||||
triggers: AppConfig['notifications']['triggers'];
|
triggers: AppConfig['notifications']['triggers'];
|
||||||
|
|
@ -195,6 +196,7 @@ export function useSettingsConfig(): UseSettingsConfigReturn {
|
||||||
notifyOnCrossTeamMessage: displayConfig?.notifications?.notifyOnCrossTeamMessage ?? true,
|
notifyOnCrossTeamMessage: displayConfig?.notifications?.notifyOnCrossTeamMessage ?? true,
|
||||||
notifyOnTeamLaunched: displayConfig?.notifications?.notifyOnTeamLaunched ?? true,
|
notifyOnTeamLaunched: displayConfig?.notifications?.notifyOnTeamLaunched ?? true,
|
||||||
notifyOnToolApproval: displayConfig?.notifications?.notifyOnToolApproval ?? true,
|
notifyOnToolApproval: displayConfig?.notifications?.notifyOnToolApproval ?? true,
|
||||||
|
autoResumeOnRateLimit: displayConfig?.notifications?.autoResumeOnRateLimit ?? false,
|
||||||
statusChangeOnlySolo: displayConfig?.notifications?.statusChangeOnlySolo ?? true,
|
statusChangeOnlySolo: displayConfig?.notifications?.statusChangeOnlySolo ?? true,
|
||||||
statusChangeStatuses: displayConfig?.notifications?.statusChangeStatuses ?? [
|
statusChangeStatuses: displayConfig?.notifications?.statusChangeStatuses ?? [
|
||||||
'in_progress',
|
'in_progress',
|
||||||
|
|
|
||||||
|
|
@ -311,6 +311,7 @@ export function useSettingsHandlers({
|
||||||
notifyOnCrossTeamMessage: true,
|
notifyOnCrossTeamMessage: true,
|
||||||
notifyOnTeamLaunched: true,
|
notifyOnTeamLaunched: true,
|
||||||
notifyOnToolApproval: true,
|
notifyOnToolApproval: true,
|
||||||
|
autoResumeOnRateLimit: false,
|
||||||
statusChangeOnlySolo: true,
|
statusChangeOnlySolo: true,
|
||||||
statusChangeStatuses: ['in_progress', 'completed'],
|
statusChangeStatuses: ['in_progress', 'completed'],
|
||||||
triggers: defaultTriggers,
|
triggers: defaultTriggers,
|
||||||
|
|
|
||||||
|
|
@ -172,7 +172,7 @@ export const AdvancedSection = ({
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<p className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
|
<p className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
|
||||||
Claude Agent Teams UI
|
Agent Teams UI
|
||||||
</p>
|
</p>
|
||||||
{isElectron && (
|
{isElectron && (
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,7 @@ interface NotificationsSectionProps {
|
||||||
| 'notifyOnCrossTeamMessage'
|
| 'notifyOnCrossTeamMessage'
|
||||||
| 'notifyOnTeamLaunched'
|
| 'notifyOnTeamLaunched'
|
||||||
| 'notifyOnToolApproval'
|
| 'notifyOnToolApproval'
|
||||||
|
| 'autoResumeOnRateLimit'
|
||||||
| 'statusChangeOnlySolo',
|
| 'statusChangeOnlySolo',
|
||||||
value: boolean
|
value: boolean
|
||||||
) => void;
|
) => void;
|
||||||
|
|
@ -360,6 +361,17 @@ export const NotificationsSection = ({
|
||||||
disabled={saving || !safeConfig.notifications.enabled}
|
disabled={saving || !safeConfig.notifications.enabled}
|
||||||
/>
|
/>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
|
<SettingRow
|
||||||
|
label="Auto-resume after rate limit"
|
||||||
|
description="When Claude reports a reset time, schedule a follow-up nudge for the team lead after the limit resets"
|
||||||
|
icon={<Clock className="size-4" />}
|
||||||
|
>
|
||||||
|
<SettingsToggle
|
||||||
|
enabled={safeConfig.notifications.autoResumeOnRateLimit}
|
||||||
|
onChange={(v) => onNotificationToggle('autoResumeOnRateLimit', v)}
|
||||||
|
disabled={saving || !safeConfig.notifications.enabled}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
{/* Task Status Change Notifications — nested within team card */}
|
{/* Task Status Change Notifications — nested within team card */}
|
||||||
<div className="last:*:border-b-0">
|
<div className="last:*:border-b-0">
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
getNonEmptyTaskCategories,
|
getNonEmptyTaskCategories,
|
||||||
groupTasksByDate,
|
groupTasksByDate,
|
||||||
groupTasksByProject,
|
groupTasksByProject,
|
||||||
|
NO_PROJECT_KEY,
|
||||||
sortTasksByFreshness,
|
sortTasksByFreshness,
|
||||||
} from '@renderer/utils/taskGrouping';
|
} from '@renderer/utils/taskGrouping';
|
||||||
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
|
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
|
||||||
|
|
@ -33,6 +34,14 @@ import { AnimatedHeightReveal } from '../team/activity/AnimatedHeightReveal';
|
||||||
import { type ComboboxOption } from '../ui/combobox';
|
import { type ComboboxOption } from '../ui/combobox';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
||||||
|
|
||||||
|
import {
|
||||||
|
canProjectGroupShowLess,
|
||||||
|
canProjectGroupShowMore,
|
||||||
|
getNextProjectGroupVisibleCount,
|
||||||
|
getPreviousProjectGroupVisibleCount,
|
||||||
|
getProjectGroupVisibleCount,
|
||||||
|
syncProjectGroupVisibleCountByKey,
|
||||||
|
} from './projectGroupPagination';
|
||||||
import { SidebarTaskItem } from './SidebarTaskItem';
|
import { SidebarTaskItem } from './SidebarTaskItem';
|
||||||
import { TaskContextMenu } from './TaskContextMenu';
|
import { TaskContextMenu } from './TaskContextMenu';
|
||||||
import { TaskFiltersPopover } from './TaskFiltersPopover';
|
import { TaskFiltersPopover } from './TaskFiltersPopover';
|
||||||
|
|
@ -207,6 +216,9 @@ export const GlobalTaskList = ({
|
||||||
const [sortPopoverOpen, setSortPopoverOpen] = useState(false);
|
const [sortPopoverOpen, setSortPopoverOpen] = useState(false);
|
||||||
const [showArchived, setShowArchived] = useState(false);
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
const [renamingTaskKey, setRenamingTaskKey] = useState<string | null>(null);
|
const [renamingTaskKey, setRenamingTaskKey] = useState<string | null>(null);
|
||||||
|
const [projectRequestedVisibleCountByKey, setProjectRequestedVisibleCountByKey] = useState<
|
||||||
|
Record<string, number>
|
||||||
|
>({});
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
const hasFetchedRef = useRef(false);
|
const hasFetchedRef = useRef(false);
|
||||||
const readState = useReadStateSnapshot();
|
const readState = useReadStateSnapshot();
|
||||||
|
|
@ -221,21 +233,23 @@ export const GlobalTaskList = ({
|
||||||
return new Set<string>();
|
return new Set<string>();
|
||||||
}
|
}
|
||||||
|
|
||||||
// First load: seed all known IDs, no animations
|
// eslint-disable-next-line react-hooks/refs -- Synchronous diff is required so new rows mount with animate=true.
|
||||||
if (isInitialTaskLoadRef.current) {
|
if (isInitialTaskLoadRef.current) {
|
||||||
isInitialTaskLoadRef.current = false;
|
isInitialTaskLoadRef.current = false;
|
||||||
for (const t of globalTasks) {
|
for (const t of globalTasks) {
|
||||||
|
// eslint-disable-next-line react-hooks/refs -- Synchronous diff is required so new rows mount with animate=true.
|
||||||
knownTaskIdsRef.current.add(`${t.teamName}:${t.id}`);
|
knownTaskIdsRef.current.add(`${t.teamName}:${t.id}`);
|
||||||
}
|
}
|
||||||
return new Set<string>();
|
return new Set<string>();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subsequent updates: detect truly new tasks
|
|
||||||
const newIds = new Set<string>();
|
const newIds = new Set<string>();
|
||||||
for (const t of globalTasks) {
|
for (const t of globalTasks) {
|
||||||
const key = `${t.teamName}:${t.id}`;
|
const key = `${t.teamName}:${t.id}`;
|
||||||
|
// eslint-disable-next-line react-hooks/refs -- Synchronous diff is required so new rows mount with animate=true.
|
||||||
if (!knownTaskIdsRef.current.has(key)) {
|
if (!knownTaskIdsRef.current.has(key)) {
|
||||||
newIds.add(key);
|
newIds.add(key);
|
||||||
|
// eslint-disable-next-line react-hooks/refs -- Synchronous diff is required so new rows mount with animate=true.
|
||||||
knownTaskIdsRef.current.add(key);
|
knownTaskIdsRef.current.add(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -326,6 +340,11 @@ export const GlobalTaskList = ({
|
||||||
|
|
||||||
// Resolve project filter from filters state
|
// Resolve project filter from filters state
|
||||||
const selectedProjectPath = filters.projectPath;
|
const selectedProjectPath = filters.projectPath;
|
||||||
|
const hasArchivedTasks = useMemo(
|
||||||
|
() => globalTasks.some((t) => taskLocalState.isArchived(t.teamName, t.id)),
|
||||||
|
[globalTasks, taskLocalState]
|
||||||
|
);
|
||||||
|
const effectiveShowArchived = showArchived && hasArchivedTasks;
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
let result = globalTasks;
|
let result = globalTasks;
|
||||||
|
|
@ -345,7 +364,7 @@ export const GlobalTaskList = ({
|
||||||
}
|
}
|
||||||
result = applySearch(result, searchQuery);
|
result = applySearch(result, searchQuery);
|
||||||
// Archive filtering
|
// Archive filtering
|
||||||
if (showArchived) {
|
if (effectiveShowArchived) {
|
||||||
result = result.filter((t) => taskLocalState.isArchived(t.teamName, t.id));
|
result = result.filter((t) => taskLocalState.isArchived(t.teamName, t.id));
|
||||||
} else {
|
} else {
|
||||||
result = result.filter((t) => !taskLocalState.isArchived(t.teamName, t.id));
|
result = result.filter((t) => !taskLocalState.isArchived(t.teamName, t.id));
|
||||||
|
|
@ -353,29 +372,16 @@ export const GlobalTaskList = ({
|
||||||
return result;
|
return result;
|
||||||
}, [
|
}, [
|
||||||
globalTasks,
|
globalTasks,
|
||||||
filters.projectPath,
|
selectedProjectPath,
|
||||||
filters.statusIds,
|
filters.statusIds,
|
||||||
filters.teamName,
|
filters.teamName,
|
||||||
filters.readFilter,
|
filters.readFilter,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
readState,
|
readState,
|
||||||
showArchived,
|
effectiveShowArchived,
|
||||||
taskLocalState,
|
taskLocalState,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Check if any archived tasks exist (before archive filtering) to conditionally show the toggle
|
|
||||||
const hasArchivedTasks = useMemo(
|
|
||||||
() => globalTasks.some((t) => taskLocalState.isArchived(t.teamName, t.id)),
|
|
||||||
[globalTasks, taskLocalState]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Reset showArchived when archive becomes empty
|
|
||||||
useEffect(() => {
|
|
||||||
if (showArchived && !hasArchivedTasks) {
|
|
||||||
setShowArchived(false);
|
|
||||||
}
|
|
||||||
}, [showArchived, hasArchivedTasks]);
|
|
||||||
|
|
||||||
// Split into pinned and normal (non-pinned) tasks
|
// Split into pinned and normal (non-pinned) tasks
|
||||||
const pinnedTasks = useMemo(
|
const pinnedTasks = useMemo(
|
||||||
() => filtered.filter((t) => taskLocalState.isPinned(t.teamName, t.id)),
|
() => filtered.filter((t) => taskLocalState.isPinned(t.teamName, t.id)),
|
||||||
|
|
@ -400,6 +406,19 @@ export const GlobalTaskList = ({
|
||||||
[projectGroups]
|
[projectGroups]
|
||||||
);
|
);
|
||||||
const timeGroupKeys = useMemo(() => categories.map((c) => c), [categories]);
|
const timeGroupKeys = useMemo(() => categories.map((c) => c), [categories]);
|
||||||
|
const projectGroupVisibility = useMemo(
|
||||||
|
() =>
|
||||||
|
projectGroups.map((group) => ({
|
||||||
|
projectKey: group.projectKey,
|
||||||
|
taskCount: group.tasks.length,
|
||||||
|
})),
|
||||||
|
[projectGroups]
|
||||||
|
);
|
||||||
|
const projectVisibleCountByKey = useMemo(
|
||||||
|
() =>
|
||||||
|
syncProjectGroupVisibleCountByKey(projectRequestedVisibleCountByKey, projectGroupVisibility),
|
||||||
|
[projectRequestedVisibleCountByKey, projectGroupVisibility]
|
||||||
|
);
|
||||||
|
|
||||||
const projectCollapsed = useCollapsedGroups('project', projectGroupKeys);
|
const projectCollapsed = useCollapsedGroups('project', projectGroupKeys);
|
||||||
const timeCollapsed = useCollapsedGroups('time', timeGroupKeys);
|
const timeCollapsed = useCollapsedGroups('time', timeGroupKeys);
|
||||||
|
|
@ -412,8 +431,18 @@ export const GlobalTaskList = ({
|
||||||
? categories.length > 0
|
? categories.length > 0
|
||||||
: projectGroups.some((g) => g.tasks.length > 0));
|
: projectGroups.some((g) => g.tasks.length > 0));
|
||||||
|
|
||||||
|
const noProjectGroupColor = useMemo(
|
||||||
|
() => ({
|
||||||
|
border: 'var(--color-border)',
|
||||||
|
glow: 'transparent',
|
||||||
|
icon: 'var(--color-text-muted)',
|
||||||
|
text: 'var(--color-text-secondary)',
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex size-full min-w-0 flex-col">
|
<div className="flex size-full min-w-0 flex-col overflow-x-hidden">
|
||||||
{!hideHeader && (
|
{!hideHeader && (
|
||||||
<div
|
<div
|
||||||
className="flex shrink-0 items-center gap-2 border-b px-3 py-1.5"
|
className="flex shrink-0 items-center gap-2 border-b px-3 py-1.5"
|
||||||
|
|
@ -499,7 +528,7 @@ export const GlobalTaskList = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pinned tasks section */}
|
{/* Pinned tasks section */}
|
||||||
{pinnedTasks.length > 0 && !showArchived && (
|
{pinnedTasks.length > 0 && !effectiveShowArchived && (
|
||||||
<div className="shrink-0 border-b" style={{ borderColor: 'var(--color-border)' }}>
|
<div className="shrink-0 border-b" style={{ borderColor: 'var(--color-border)' }}>
|
||||||
<div className="flex items-center gap-1 px-2 py-1">
|
<div className="flex items-center gap-1 px-2 py-1">
|
||||||
<Pin className="size-3 text-text-muted" />
|
<Pin className="size-3 text-text-muted" />
|
||||||
|
|
@ -562,7 +591,7 @@ export const GlobalTaskList = ({
|
||||||
onClick={() => setShowArchived(!showArchived)}
|
onClick={() => setShowArchived(!showArchived)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded p-0.5 transition-colors',
|
'rounded p-0.5 transition-colors',
|
||||||
showArchived
|
effectiveShowArchived
|
||||||
? 'bg-surface-raised text-text-secondary'
|
? 'bg-surface-raised text-text-secondary'
|
||||||
: 'text-text-muted hover:text-text-secondary'
|
: 'text-text-muted hover:text-text-secondary'
|
||||||
)}
|
)}
|
||||||
|
|
@ -571,7 +600,7 @@ export const GlobalTaskList = ({
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">
|
<TooltipContent side="top">
|
||||||
{showArchived ? 'Hide archived' : 'Show archived'}
|
{effectiveShowArchived ? 'Hide archived' : 'Show archived'}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -579,7 +608,7 @@ export const GlobalTaskList = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto overflow-x-hidden">
|
||||||
{globalTasksLoading && !globalTasksInitialized && (
|
{globalTasksLoading && !globalTasksInitialized && (
|
||||||
<div className="space-y-2 p-3">
|
<div className="space-y-2 p-3">
|
||||||
{[1, 2, 3].map((i) => (
|
{[1, 2, 3].map((i) => (
|
||||||
|
|
@ -626,15 +655,31 @@ export const GlobalTaskList = ({
|
||||||
projectGroups.map((group) => {
|
projectGroups.map((group) => {
|
||||||
if (group.tasks.length === 0) return null;
|
if (group.tasks.length === 0) return null;
|
||||||
const isGroupCollapsed = projectCollapsed.isCollapsed(group.projectKey);
|
const isGroupCollapsed = projectCollapsed.isCollapsed(group.projectKey);
|
||||||
const groupColor = projectColor(group.projectLabel);
|
const isNoProjectGroup = group.projectKey === NO_PROJECT_KEY;
|
||||||
|
const groupColor = isNoProjectGroup
|
||||||
|
? noProjectGroupColor
|
||||||
|
: projectColor(group.projectLabel);
|
||||||
|
const visibleCount = getProjectGroupVisibleCount(
|
||||||
|
projectVisibleCountByKey[group.projectKey],
|
||||||
|
group.tasks.length
|
||||||
|
);
|
||||||
|
const visibleTasks = group.tasks.slice(0, visibleCount);
|
||||||
|
const showMoreVisible = canProjectGroupShowMore(visibleCount, group.tasks.length);
|
||||||
|
const showLessVisible = canProjectGroupShowLess(visibleCount, group.tasks.length);
|
||||||
let lastTeam: string | null = null;
|
let lastTeam: string | null = null;
|
||||||
return (
|
return (
|
||||||
<div key={group.projectKey}>
|
<div key={group.projectKey}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => projectCollapsed.toggle(group.projectKey)}
|
onClick={() => projectCollapsed.toggle(group.projectKey)}
|
||||||
className="hover:bg-surface-raised/40 sticky top-0 z-10 flex w-full cursor-pointer items-center gap-1 px-2 py-1.5 text-[11px] font-semibold transition-colors"
|
className="hover:bg-surface-raised/40 sticky top-0 z-10 flex w-full cursor-pointer items-center gap-1.5 p-2 transition-colors"
|
||||||
style={{ backgroundColor: 'var(--color-surface-sidebar)' }}
|
style={{
|
||||||
|
backgroundColor: 'var(--color-surface-sidebar)',
|
||||||
|
backgroundImage: isNoProjectGroup
|
||||||
|
? undefined
|
||||||
|
: `linear-gradient(90deg, ${groupColor.glow} 0%, transparent 80%)`,
|
||||||
|
boxShadow: `inset 2px 0 0 ${groupColor.border}, inset 0 -1px 0 var(--color-border)`,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{isGroupCollapsed ? (
|
{isGroupCollapsed ? (
|
||||||
<ChevronRight className="size-3 shrink-0 text-text-muted" />
|
<ChevronRight className="size-3 shrink-0 text-text-muted" />
|
||||||
|
|
@ -642,11 +687,14 @@ export const GlobalTaskList = ({
|
||||||
<ChevronDown className="size-3 shrink-0 text-text-muted" />
|
<ChevronDown className="size-3 shrink-0 text-text-muted" />
|
||||||
)}
|
)}
|
||||||
<Folder
|
<Folder
|
||||||
className="size-3 shrink-0"
|
className="size-3.5 shrink-0"
|
||||||
style={{ color: groupColor.border }}
|
style={{ color: groupColor.icon }}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<span className="truncate" style={{ color: groupColor.text }}>
|
<span
|
||||||
|
className="truncate text-[11px] font-bold leading-none"
|
||||||
|
style={{ color: groupColor.icon }}
|
||||||
|
>
|
||||||
{group.projectLabel}
|
{group.projectLabel}
|
||||||
</span>
|
</span>
|
||||||
<span className="ml-auto shrink-0 text-[10px] font-normal text-text-muted">
|
<span className="ml-auto shrink-0 text-[10px] font-normal text-text-muted">
|
||||||
|
|
@ -654,7 +702,7 @@ export const GlobalTaskList = ({
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{!isGroupCollapsed &&
|
{!isGroupCollapsed &&
|
||||||
group.tasks.map((task) => {
|
visibleTasks.map((task) => {
|
||||||
const showTeamHeader = task.teamName !== lastTeam;
|
const showTeamHeader = task.teamName !== lastTeam;
|
||||||
lastTeam = task.teamName;
|
lastTeam = task.teamName;
|
||||||
return (
|
return (
|
||||||
|
|
@ -691,6 +739,44 @@ export const GlobalTaskList = ({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{!isGroupCollapsed && (showMoreVisible || showLessVisible) && (
|
||||||
|
<div className="flex items-center gap-2 px-3 pb-2 pt-1">
|
||||||
|
{showMoreVisible && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-[11px] font-medium text-text-muted transition-colors hover:text-text"
|
||||||
|
onClick={() =>
|
||||||
|
setProjectRequestedVisibleCountByKey((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[group.projectKey]: getNextProjectGroupVisibleCount(
|
||||||
|
projectVisibleCountByKey[group.projectKey],
|
||||||
|
group.tasks.length
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Show more
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{showLessVisible && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-[11px] font-medium text-text-muted transition-colors hover:text-text"
|
||||||
|
onClick={() =>
|
||||||
|
setProjectRequestedVisibleCountByKey((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[group.projectKey]: getPreviousProjectGroupVisibleCount(
|
||||||
|
projectVisibleCountByKey[group.projectKey],
|
||||||
|
group.tasks.length
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Show less
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -144,11 +144,13 @@ export const SidebarTaskItem = ({
|
||||||
);
|
);
|
||||||
|
|
||||||
const showTeamRow = showTeamName && !hideTeamName;
|
const showTeamRow = showTeamName && !hideTeamName;
|
||||||
|
const unreadBackgroundClass =
|
||||||
|
unreadCount > 0 ? (isLight ? 'bg-blue-500/[0.03]' : 'bg-blue-500/[0.05]') : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`flex w-full cursor-pointer flex-col justify-center border-b px-2 py-1.5 text-left transition-colors hover:bg-surface-raised ${unreadCount > 0 ? (isLight ? 'bg-blue-500/[0.03]' : 'bg-blue-500/[0.08]') : ''} ${task.teamDeleted ? 'opacity-50' : ''}`}
|
className={`flex w-full cursor-pointer flex-col justify-center border-b px-2 py-1.5 text-left transition-colors hover:bg-surface-raised ${unreadBackgroundClass} ${task.teamDeleted ? 'opacity-50' : ''}`}
|
||||||
style={{ borderColor: 'var(--color-border)' }}
|
style={{ borderColor: 'var(--color-border)' }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!isRenaming) {
|
if (!isRenaming) {
|
||||||
|
|
|
||||||
89
src/renderer/components/sidebar/projectGroupPagination.ts
Normal file
89
src/renderer/components/sidebar/projectGroupPagination.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
export const PROJECT_GROUP_PAGE_SIZE = 5;
|
||||||
|
|
||||||
|
export interface ProjectGroupVisibilityDescriptor {
|
||||||
|
projectKey: string;
|
||||||
|
taskCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProjectGroupVisibleCount(
|
||||||
|
visibleCount: number | undefined,
|
||||||
|
taskCount: number
|
||||||
|
): number {
|
||||||
|
if (taskCount <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const minimumVisibleCount = Math.min(PROJECT_GROUP_PAGE_SIZE, taskCount);
|
||||||
|
if (visibleCount == null || !Number.isFinite(visibleCount)) {
|
||||||
|
return minimumVisibleCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedVisibleCount = Math.floor(visibleCount);
|
||||||
|
return Math.min(taskCount, Math.max(minimumVisibleCount, normalizedVisibleCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNextProjectGroupVisibleCount(
|
||||||
|
visibleCount: number | undefined,
|
||||||
|
taskCount: number
|
||||||
|
): number {
|
||||||
|
const currentVisibleCount = getProjectGroupVisibleCount(visibleCount, taskCount);
|
||||||
|
if (currentVisibleCount >= taskCount) {
|
||||||
|
return currentVisibleCount;
|
||||||
|
}
|
||||||
|
return Math.min(taskCount, currentVisibleCount + PROJECT_GROUP_PAGE_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPreviousProjectGroupVisibleCount(
|
||||||
|
visibleCount: number | undefined,
|
||||||
|
taskCount: number
|
||||||
|
): number {
|
||||||
|
const currentVisibleCount = getProjectGroupVisibleCount(visibleCount, taskCount);
|
||||||
|
const minimumVisibleCount = Math.min(PROJECT_GROUP_PAGE_SIZE, taskCount);
|
||||||
|
return Math.max(minimumVisibleCount, currentVisibleCount - PROJECT_GROUP_PAGE_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canProjectGroupShowMore(
|
||||||
|
visibleCount: number | undefined,
|
||||||
|
taskCount: number
|
||||||
|
): boolean {
|
||||||
|
return getProjectGroupVisibleCount(visibleCount, taskCount) < taskCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canProjectGroupShowLess(
|
||||||
|
visibleCount: number | undefined,
|
||||||
|
taskCount: number
|
||||||
|
): boolean {
|
||||||
|
if (taskCount <= PROJECT_GROUP_PAGE_SIZE) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return getProjectGroupVisibleCount(visibleCount, taskCount) > PROJECT_GROUP_PAGE_SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncProjectGroupVisibleCountByKey(
|
||||||
|
previousVisibleCountByKey: Record<string, number>,
|
||||||
|
groups: readonly ProjectGroupVisibilityDescriptor[]
|
||||||
|
): Record<string, number> {
|
||||||
|
let changed = false;
|
||||||
|
const nextVisibleCountByKey: Record<string, number> = {};
|
||||||
|
|
||||||
|
for (const group of groups) {
|
||||||
|
const nextVisibleCount = getProjectGroupVisibleCount(
|
||||||
|
previousVisibleCountByKey[group.projectKey],
|
||||||
|
group.taskCount
|
||||||
|
);
|
||||||
|
|
||||||
|
if (nextVisibleCount > 0) {
|
||||||
|
nextVisibleCountByKey[group.projectKey] = nextVisibleCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previousVisibleCountByKey[group.projectKey] !== nextVisibleCount) {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(previousVisibleCountByKey).length !== Object.keys(nextVisibleCountByKey).length) {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return changed ? nextVisibleCountByKey : previousVisibleCountByKey;
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer
|
||||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
|
||||||
import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers';
|
||||||
import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils';
|
import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils';
|
||||||
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
||||||
|
|
@ -70,14 +71,16 @@ export const TaskTooltip = ({
|
||||||
children,
|
children,
|
||||||
side = 'top',
|
side = 'top',
|
||||||
}: TaskTooltipProps): React.JSX.Element => {
|
}: TaskTooltipProps): React.JSX.Element => {
|
||||||
const { selectedTeamName, selectedTeamData, globalTasks, teamByName } = useStore(
|
const { selectedTeamName, selectedTeamData, selectedTeamMembers, globalTasks, teamByName } =
|
||||||
useShallow((s) => ({
|
useStore(
|
||||||
selectedTeamName: s.selectedTeamName,
|
useShallow((s) => ({
|
||||||
selectedTeamData: s.selectedTeamData,
|
selectedTeamName: s.selectedTeamName,
|
||||||
globalTasks: s.globalTasks,
|
selectedTeamData: s.selectedTeamData,
|
||||||
teamByName: s.teamByName,
|
selectedTeamMembers: selectResolvedMembersForTeamName(s, s.selectedTeamName),
|
||||||
}))
|
globalTasks: s.globalTasks,
|
||||||
);
|
teamByName: s.teamByName,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
const task = useMemo(() => {
|
const task = useMemo(() => {
|
||||||
if (teamName && selectedTeamName === teamName) {
|
if (teamName && selectedTeamName === teamName) {
|
||||||
|
|
@ -105,13 +108,13 @@ export const TaskTooltip = ({
|
||||||
|
|
||||||
const members = useMemo(() => {
|
const members = useMemo(() => {
|
||||||
if (teamName && selectedTeamName === teamName) {
|
if (teamName && selectedTeamName === teamName) {
|
||||||
return selectedTeamData?.members ?? [];
|
return selectedTeamMembers;
|
||||||
}
|
}
|
||||||
if (!teamName && task && selectedTeamName === (task as { teamName?: string }).teamName) {
|
if (!teamName && task && selectedTeamName === (task as { teamName?: string }).teamName) {
|
||||||
return selectedTeamData?.members ?? [];
|
return selectedTeamMembers;
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
}, [selectedTeamData, selectedTeamName, teamName, task]);
|
}, [selectedTeamMembers, selectedTeamName, teamName, task]);
|
||||||
|
|
||||||
const colorMap = useMemo(
|
const colorMap = useMemo(
|
||||||
() => (members ? buildMemberColorMap(members) : new Map<string, string>()),
|
() => (members ? buildMemberColorMap(members) : new Map<string, string>()),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,14 @@
|
||||||
import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import {
|
||||||
|
lazy,
|
||||||
|
memo,
|
||||||
|
Suspense,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useId,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
import { SessionContextPanel } from '@renderer/components/chat/SessionContextPanel/index';
|
import { SessionContextPanel } from '@renderer/components/chat/SessionContextPanel/index';
|
||||||
|
|
@ -23,9 +33,12 @@ import { useStore } from '@renderer/store';
|
||||||
import {
|
import {
|
||||||
getCurrentProvisioningProgressForTeam,
|
getCurrentProvisioningProgressForTeam,
|
||||||
isTeamProvisioningActive,
|
isTeamProvisioningActive,
|
||||||
|
selectResolvedMemberForTeamName,
|
||||||
|
selectResolvedMembersForTeamName,
|
||||||
|
selectTeamMemberSnapshotsForName,
|
||||||
} from '@renderer/store/slices/teamSlice';
|
} from '@renderer/store/slices/teamSlice';
|
||||||
import { createChipFromSelection } from '@renderer/utils/chipUtils';
|
import { createChipFromSelection } from '@renderer/utils/chipUtils';
|
||||||
import { formatPercentOfTotal, sumContextInjectionTokens } from '@renderer/utils/contextMath';
|
import { sumContextInjectionTokens } from '@renderer/utils/contextMath';
|
||||||
import { formatProjectPath } from '@renderer/utils/pathDisplay';
|
import { formatProjectPath } from '@renderer/utils/pathDisplay';
|
||||||
import { buildTaskCountsByOwner, normalizePath } from '@renderer/utils/pathNormalize';
|
import { buildTaskCountsByOwner, normalizePath } from '@renderer/utils/pathNormalize';
|
||||||
import { nameColorSet } from '@renderer/utils/projectColor';
|
import { nameColorSet } from '@renderer/utils/projectColor';
|
||||||
|
|
@ -35,6 +48,7 @@ import {
|
||||||
type TaskChangeRequestOptions,
|
type TaskChangeRequestOptions,
|
||||||
} from '@renderer/utils/taskChangeRequest';
|
} from '@renderer/utils/taskChangeRequest';
|
||||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||||
|
import { deriveContextMetrics } from '@shared/utils/contextMetrics';
|
||||||
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||||
|
|
@ -111,9 +125,11 @@ import type {
|
||||||
MemberSpawnStatusEntry,
|
MemberSpawnStatusEntry,
|
||||||
ResolvedTeamMember,
|
ResolvedTeamMember,
|
||||||
TaskRef,
|
TaskRef,
|
||||||
|
TeamAgentRuntimeEntry,
|
||||||
TeamTaskWithKanban,
|
TeamTaskWithKanban,
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
import type { EditorSelectionAction } from '@shared/types/editor';
|
import type { EditorSelectionAction } from '@shared/types/editor';
|
||||||
|
import type { ContextUsageLike } from '@shared/utils/contextMetrics';
|
||||||
|
|
||||||
interface TeamDetailViewProps {
|
interface TeamDetailViewProps {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
|
|
@ -286,7 +302,7 @@ type TeamMemberListBridgeProps = Omit<
|
||||||
};
|
};
|
||||||
type TeamMemberDetailDialogBridgeProps = Omit<
|
type TeamMemberDetailDialogBridgeProps = Omit<
|
||||||
ComponentProps<typeof MemberDetailDialog>,
|
ComponentProps<typeof MemberDetailDialog>,
|
||||||
'leadActivity' | 'spawnEntry'
|
'leadActivity' | 'spawnEntry' | 'runtimeEntry'
|
||||||
>;
|
>;
|
||||||
type TeamSidebarRailBridgeProps = Omit<
|
type TeamSidebarRailBridgeProps = Omit<
|
||||||
ComponentProps<typeof TeamSidebarRail>,
|
ComponentProps<typeof TeamSidebarRail>,
|
||||||
|
|
@ -324,6 +340,17 @@ function buildMemberSpawnStatusMap(
|
||||||
return map.size > 0 ? map : undefined;
|
return map.size > 0 ? map : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildTeamAgentRuntimeMap(
|
||||||
|
runtimeSnapshot: Record<string, TeamAgentRuntimeEntry> | undefined
|
||||||
|
): Map<string, TeamAgentRuntimeEntry> | undefined {
|
||||||
|
if (!runtimeSnapshot) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const map = new Map<string, TeamAgentRuntimeEntry>(Object.entries(runtimeSnapshot));
|
||||||
|
return map.size > 0 ? map : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const TeamSpawnStatusWatcher = memo(function TeamSpawnStatusWatcher({
|
const TeamSpawnStatusWatcher = memo(function TeamSpawnStatusWatcher({
|
||||||
teamName,
|
teamName,
|
||||||
isTeamProvisioning,
|
isTeamProvisioning,
|
||||||
|
|
@ -361,6 +388,54 @@ const TeamSpawnStatusWatcher = memo(function TeamSpawnStatusWatcher({
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const TEAM_AGENT_RUNTIME_REFRESH_MS = 5_000;
|
||||||
|
|
||||||
|
const TeamAgentRuntimeWatcher = memo(function TeamAgentRuntimeWatcher({
|
||||||
|
teamName,
|
||||||
|
isTeamProvisioning,
|
||||||
|
isTeamAlive,
|
||||||
|
isThisTabActive,
|
||||||
|
}: {
|
||||||
|
teamName: string;
|
||||||
|
isTeamProvisioning: boolean;
|
||||||
|
isTeamAlive?: boolean;
|
||||||
|
isThisTabActive: boolean;
|
||||||
|
}): null {
|
||||||
|
const { leadActivity, fetchTeamAgentRuntime } = useStore(
|
||||||
|
useShallow((s) => ({
|
||||||
|
leadActivity: s.leadActivityByTeam[teamName],
|
||||||
|
fetchTeamAgentRuntime: s.fetchTeamAgentRuntime,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isThisTabActive) return;
|
||||||
|
const shouldWatch =
|
||||||
|
isTeamProvisioning ||
|
||||||
|
isTeamAlive === true ||
|
||||||
|
leadActivity === 'active' ||
|
||||||
|
leadActivity === 'idle';
|
||||||
|
if (!shouldWatch) return;
|
||||||
|
|
||||||
|
void fetchTeamAgentRuntime(teamName);
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
void fetchTeamAgentRuntime(teamName);
|
||||||
|
}, TEAM_AGENT_RUNTIME_REFRESH_MS);
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
fetchTeamAgentRuntime,
|
||||||
|
isTeamAlive,
|
||||||
|
isTeamProvisioning,
|
||||||
|
isThisTabActive,
|
||||||
|
leadActivity,
|
||||||
|
teamName,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
const LeadContextWatcher = memo(function LeadContextWatcher({
|
const LeadContextWatcher = memo(function LeadContextWatcher({
|
||||||
teamName,
|
teamName,
|
||||||
tabId,
|
tabId,
|
||||||
|
|
@ -445,6 +520,7 @@ const LeadContextBridge = memo(function LeadContextBridge({
|
||||||
}: LeadContextBridgeProps): React.JSX.Element | null {
|
}: LeadContextBridgeProps): React.JSX.Element | null {
|
||||||
const {
|
const {
|
||||||
leadTabData,
|
leadTabData,
|
||||||
|
leadContextSnapshot,
|
||||||
isContextPanelVisible,
|
isContextPanelVisible,
|
||||||
selectedContextPhase,
|
selectedContextPhase,
|
||||||
setContextPanelVisibleForTab,
|
setContextPanelVisibleForTab,
|
||||||
|
|
@ -453,6 +529,7 @@ const LeadContextBridge = memo(function LeadContextBridge({
|
||||||
} = useStore(
|
} = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
leadTabData: tabId ? (s.tabSessionData[tabId] ?? null) : null,
|
leadTabData: tabId ? (s.tabSessionData[tabId] ?? null) : null,
|
||||||
|
leadContextSnapshot: s.leadContextByTeam[teamName] ?? null,
|
||||||
isContextPanelVisible: tabId ? (s.tabUIStates.get(tabId)?.showContextPanel ?? false) : false,
|
isContextPanelVisible: tabId ? (s.tabUIStates.get(tabId)?.showContextPanel ?? false) : false,
|
||||||
selectedContextPhase: tabId ? (s.tabUIStates.get(tabId)?.selectedContextPhase ?? null) : null,
|
selectedContextPhase: tabId ? (s.tabUIStates.get(tabId)?.selectedContextPhase ?? null) : null,
|
||||||
setContextPanelVisibleForTab: s.setContextPanelVisibleForTab,
|
setContextPanelVisibleForTab: s.setContextPanelVisibleForTab,
|
||||||
|
|
@ -491,9 +568,13 @@ const LeadContextBridge = memo(function LeadContextBridge({
|
||||||
const total = processes.reduce((sum, p) => sum + (p.metrics.costUsd ?? 0), 0);
|
const total = processes.reduce((sum, p) => sum + (p.metrics.costUsd ?? 0), 0);
|
||||||
return total > 0 ? total : undefined;
|
return total > 0 ? total : undefined;
|
||||||
}, [leadSessionDetail?.processes]);
|
}, [leadSessionDetail?.processes]);
|
||||||
const { allContextInjections, lastAiGroupTotalTokens } = useMemo(() => {
|
const { allContextInjections, lastAssistantUsage, lastAssistantModelName } = useMemo(() => {
|
||||||
if (!leadSessionLoaded || !leadSessionContextStats || !leadConversation?.items.length) {
|
if (!leadSessionLoaded || !leadSessionContextStats || !leadConversation?.items.length) {
|
||||||
return { allContextInjections: [] as ContextInjection[], lastAiGroupTotalTokens: undefined };
|
return {
|
||||||
|
allContextInjections: [] as ContextInjection[],
|
||||||
|
lastAssistantUsage: null as ContextUsageLike | null,
|
||||||
|
lastAssistantModelName: undefined as string | undefined,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const effectivePhase = selectedContextPhase;
|
const effectivePhase = selectedContextPhase;
|
||||||
|
|
@ -511,7 +592,8 @@ const LeadContextBridge = memo(function LeadContextBridge({
|
||||||
if (lastAiItem?.type !== 'ai') {
|
if (lastAiItem?.type !== 'ai') {
|
||||||
return {
|
return {
|
||||||
allContextInjections: [] as ContextInjection[],
|
allContextInjections: [] as ContextInjection[],
|
||||||
lastAiGroupTotalTokens: undefined,
|
lastAssistantUsage: null,
|
||||||
|
lastAssistantModelName: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
targetAiGroupId = lastAiItem.group.id;
|
targetAiGroupId = lastAiItem.group.id;
|
||||||
|
|
@ -520,7 +602,8 @@ const LeadContextBridge = memo(function LeadContextBridge({
|
||||||
const stats = leadSessionContextStats.get(targetAiGroupId);
|
const stats = leadSessionContextStats.get(targetAiGroupId);
|
||||||
const injections = stats?.accumulatedInjections ?? [];
|
const injections = stats?.accumulatedInjections ?? [];
|
||||||
|
|
||||||
let totalTokens: number | undefined;
|
let lastUsage: ContextUsageLike | null = null;
|
||||||
|
let lastModelName: string | undefined;
|
||||||
const targetItem = leadConversation.items.find(
|
const targetItem = leadConversation.items.find(
|
||||||
(item) => item.type === 'ai' && item.group.id === targetAiGroupId
|
(item) => item.type === 'ai' && item.group.id === targetAiGroupId
|
||||||
);
|
);
|
||||||
|
|
@ -529,18 +612,18 @@ const LeadContextBridge = memo(function LeadContextBridge({
|
||||||
for (let i = responses.length - 1; i >= 0; i--) {
|
for (let i = responses.length - 1; i >= 0; i--) {
|
||||||
const msg = responses[i];
|
const msg = responses[i];
|
||||||
if (msg.type === 'assistant' && msg.usage) {
|
if (msg.type === 'assistant' && msg.usage) {
|
||||||
const usage = msg.usage;
|
lastUsage = msg.usage;
|
||||||
totalTokens =
|
lastModelName = msg.model;
|
||||||
(usage.input_tokens ?? 0) +
|
|
||||||
(usage.output_tokens ?? 0) +
|
|
||||||
(usage.cache_read_input_tokens ?? 0) +
|
|
||||||
(usage.cache_creation_input_tokens ?? 0);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { allContextInjections: injections, lastAiGroupTotalTokens: totalTokens };
|
return {
|
||||||
|
allContextInjections: injections,
|
||||||
|
lastAssistantUsage: lastUsage,
|
||||||
|
lastAssistantModelName: lastModelName,
|
||||||
|
};
|
||||||
}, [
|
}, [
|
||||||
leadConversation,
|
leadConversation,
|
||||||
leadSessionContextStats,
|
leadSessionContextStats,
|
||||||
|
|
@ -552,10 +635,26 @@ const LeadContextBridge = memo(function LeadContextBridge({
|
||||||
() => sumContextInjectionTokens(allContextInjections),
|
() => sumContextInjectionTokens(allContextInjections),
|
||||||
[allContextInjections]
|
[allContextInjections]
|
||||||
);
|
);
|
||||||
const visibleContextPercentLabel = useMemo(
|
const contextMetrics = useMemo(
|
||||||
() => formatPercentOfTotal(visibleContextTokens, lastAiGroupTotalTokens),
|
() =>
|
||||||
[visibleContextTokens, lastAiGroupTotalTokens]
|
deriveContextMetrics({
|
||||||
|
usage: lastAssistantUsage,
|
||||||
|
modelName: lastAssistantModelName,
|
||||||
|
contextWindowTokens: leadContextSnapshot?.contextWindowTokens ?? null,
|
||||||
|
visibleContextTokens,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
lastAssistantModelName,
|
||||||
|
lastAssistantUsage,
|
||||||
|
leadContextSnapshot?.contextWindowTokens,
|
||||||
|
visibleContextTokens,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
const contextUsedPercentLabel = useMemo(() => {
|
||||||
|
const percent =
|
||||||
|
contextMetrics.contextUsedPercentOfContextWindow ?? leadContextSnapshot?.contextUsedPercent;
|
||||||
|
return percent === null || percent === undefined ? null : `${percent.toFixed(1)}%`;
|
||||||
|
}, [contextMetrics.contextUsedPercentOfContextWindow, leadContextSnapshot?.contextUsedPercent]);
|
||||||
|
|
||||||
if (!leadSessionId) {
|
if (!leadSessionId) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -570,7 +669,7 @@ const LeadContextBridge = memo(function LeadContextBridge({
|
||||||
injections={allContextInjections}
|
injections={allContextInjections}
|
||||||
onClose={() => setContextPanelVisible(false)}
|
onClose={() => setContextPanelVisible(false)}
|
||||||
projectRoot={leadSessionDetail?.session?.projectPath ?? fallbackProjectRoot}
|
projectRoot={leadSessionDetail?.session?.projectPath ?? fallbackProjectRoot}
|
||||||
totalSessionTokens={lastAiGroupTotalTokens}
|
contextMetrics={contextMetrics}
|
||||||
sessionMetrics={leadSessionDetail?.metrics}
|
sessionMetrics={leadSessionDetail?.metrics}
|
||||||
subagentCostUsd={leadSubagentCostUsd}
|
subagentCostUsd={leadSubagentCostUsd}
|
||||||
phaseInfo={leadSessionPhaseInfo ?? undefined}
|
phaseInfo={leadSessionPhaseInfo ?? undefined}
|
||||||
|
|
@ -585,7 +684,7 @@ const LeadContextBridge = memo(function LeadContextBridge({
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-3 py-2">
|
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-3 py-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-medium text-[var(--color-text)]">Visible Context</p>
|
<p className="text-sm font-medium text-[var(--color-text)]">Context</p>
|
||||||
<p className="text-[10px] text-[var(--color-text-muted)]">
|
<p className="text-[10px] text-[var(--color-text-muted)]">
|
||||||
{leadSessionLoading ? 'Loading…' : 'No session loaded'}
|
{leadSessionLoading ? 'Loading…' : 'No session loaded'}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -644,7 +743,7 @@ const LeadContextBridge = memo(function LeadContextBridge({
|
||||||
: leadSessionId
|
: leadSessionId
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{visibleContextPercentLabel ?? 'Context'}
|
{contextUsedPercentLabel ?? 'Context'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
@ -655,18 +754,24 @@ const TeamMemberListBridge = memo(function TeamMemberListBridge({
|
||||||
teamName,
|
teamName,
|
||||||
...props
|
...props
|
||||||
}: TeamMemberListBridgeProps): React.JSX.Element {
|
}: TeamMemberListBridgeProps): React.JSX.Element {
|
||||||
const { leadActivity, progress, memberSpawnStatuses, memberSpawnSnapshot } = useStore(
|
const { leadActivity, progress, memberSpawnStatuses, memberSpawnSnapshot, runtimeSnapshot } =
|
||||||
useShallow((s) => ({
|
useStore(
|
||||||
leadActivity: s.leadActivityByTeam[teamName],
|
useShallow((s) => ({
|
||||||
progress: getCurrentProvisioningProgressForTeam(s, teamName),
|
leadActivity: s.leadActivityByTeam[teamName],
|
||||||
memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName],
|
progress: getCurrentProvisioningProgressForTeam(s, teamName),
|
||||||
memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName],
|
memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName],
|
||||||
}))
|
memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName],
|
||||||
);
|
runtimeSnapshot: s.teamAgentRuntimeByTeam[teamName],
|
||||||
|
}))
|
||||||
|
);
|
||||||
const memberSpawnStatusMap = useMemo(
|
const memberSpawnStatusMap = useMemo(
|
||||||
() => buildMemberSpawnStatusMap(memberSpawnStatuses),
|
() => buildMemberSpawnStatusMap(memberSpawnStatuses),
|
||||||
[memberSpawnStatuses]
|
[memberSpawnStatuses]
|
||||||
);
|
);
|
||||||
|
const memberRuntimeMap = useMemo(
|
||||||
|
() => buildTeamAgentRuntimeMap(runtimeSnapshot?.members),
|
||||||
|
[runtimeSnapshot?.members]
|
||||||
|
);
|
||||||
const isLaunchSettling = useMemo(() => {
|
const isLaunchSettling = useMemo(() => {
|
||||||
if (progress?.state !== 'ready') {
|
if (progress?.state !== 'ready') {
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -685,6 +790,7 @@ const TeamMemberListBridge = memo(function TeamMemberListBridge({
|
||||||
{...props}
|
{...props}
|
||||||
leadActivity={leadActivity}
|
leadActivity={leadActivity}
|
||||||
memberSpawnStatuses={memberSpawnStatusMap}
|
memberSpawnStatuses={memberSpawnStatusMap}
|
||||||
|
memberRuntimeEntries={memberRuntimeMap}
|
||||||
isLaunchSettling={isLaunchSettling}
|
isLaunchSettling={isLaunchSettling}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
@ -740,19 +846,23 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge(
|
||||||
}: TeamMemberDetailDialogBridgeProps): React.JSX.Element | null {
|
}: TeamMemberDetailDialogBridgeProps): React.JSX.Element | null {
|
||||||
const {
|
const {
|
||||||
leadActivity,
|
leadActivity,
|
||||||
|
liveMember,
|
||||||
progress,
|
progress,
|
||||||
members: launchMembers,
|
launchMembers,
|
||||||
memberSpawnStatuses,
|
memberSpawnStatuses,
|
||||||
memberSpawnSnapshot,
|
memberSpawnSnapshot,
|
||||||
spawnEntry,
|
spawnEntry,
|
||||||
|
runtimeEntry,
|
||||||
} = useStore(
|
} = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
leadActivity: s.leadActivityByTeam[teamName],
|
leadActivity: s.leadActivityByTeam[teamName],
|
||||||
|
liveMember: member ? selectResolvedMemberForTeamName(s, teamName, member.name) : null,
|
||||||
progress: getCurrentProvisioningProgressForTeam(s, teamName),
|
progress: getCurrentProvisioningProgressForTeam(s, teamName),
|
||||||
members: s.selectedTeamName === teamName ? (s.selectedTeamData?.members ?? []) : [],
|
launchMembers: selectTeamMemberSnapshotsForName(s, teamName),
|
||||||
memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName],
|
memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName],
|
||||||
memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName],
|
memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName],
|
||||||
spawnEntry: member ? s.memberSpawnStatusesByTeam[teamName]?.[member.name] : undefined,
|
spawnEntry: member ? s.memberSpawnStatusesByTeam[teamName]?.[member.name] : undefined,
|
||||||
|
runtimeEntry: member ? s.teamAgentRuntimeByTeam[teamName]?.members[member.name] : undefined,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
const isLaunchSettling = useMemo(() => {
|
const isLaunchSettling = useMemo(() => {
|
||||||
|
|
@ -772,10 +882,11 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge(
|
||||||
<MemberDetailDialog
|
<MemberDetailDialog
|
||||||
{...props}
|
{...props}
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
member={member}
|
member={liveMember ?? member}
|
||||||
isLaunchSettling={isLaunchSettling}
|
isLaunchSettling={isLaunchSettling}
|
||||||
leadActivity={leadActivity}
|
leadActivity={leadActivity}
|
||||||
spawnEntry={spawnEntry}
|
spawnEntry={spawnEntry}
|
||||||
|
runtimeEntry={runtimeEntry}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -821,7 +932,6 @@ export const TeamDetailView = ({
|
||||||
);
|
);
|
||||||
const provisioningBannerRef = useRef<HTMLDivElement>(null);
|
const provisioningBannerRef = useRef<HTMLDivElement>(null);
|
||||||
const wasProvisioningRef = useRef(false);
|
const wasProvisioningRef = useRef(false);
|
||||||
const pendingReplyRefreshTimerRef = useRef<number | null>(null);
|
|
||||||
const handleOpenGraphTab = useCallback(() => {
|
const handleOpenGraphTab = useCallback(() => {
|
||||||
const state = useStore.getState();
|
const state = useStore.getState();
|
||||||
const displayName = state.teamByName[teamName]?.displayName ?? teamName;
|
const displayName = state.teamByName[teamName]?.displayName ?? teamName;
|
||||||
|
|
@ -898,7 +1008,7 @@ export const TeamDetailView = ({
|
||||||
initialActivityFilter,
|
initialActivityFilter,
|
||||||
} = (e as CustomEvent).detail ?? {};
|
} = (e as CustomEvent).detail ?? {};
|
||||||
if (tn !== teamName || !data) return;
|
if (tn !== teamName || !data) return;
|
||||||
const member = data.members.find((m: { name: string }) => m.name === memberName);
|
const member = members.find((m: { name: string }) => m.name === memberName);
|
||||||
if (member) {
|
if (member) {
|
||||||
setSelectedMember(member);
|
setSelectedMember(member);
|
||||||
setSelectedMemberView({
|
setSelectedMemberView({
|
||||||
|
|
@ -1059,6 +1169,7 @@ export const TeamDetailView = ({
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
|
members,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
projects,
|
projects,
|
||||||
|
|
@ -1081,6 +1192,7 @@ export const TeamDetailView = ({
|
||||||
lastSendMessageResult,
|
lastSendMessageResult,
|
||||||
reviewActionError,
|
reviewActionError,
|
||||||
addMember,
|
addMember,
|
||||||
|
restartMember,
|
||||||
removeMember,
|
removeMember,
|
||||||
updateMemberRole,
|
updateMemberRole,
|
||||||
launchTeam,
|
launchTeam,
|
||||||
|
|
@ -1088,6 +1200,7 @@ export const TeamDetailView = ({
|
||||||
clearProvisioningError,
|
clearProvisioningError,
|
||||||
isTeamProvisioning,
|
isTeamProvisioning,
|
||||||
refreshTeamData,
|
refreshTeamData,
|
||||||
|
syncTeamPendingReplyRefresh,
|
||||||
kanbanFilterQuery,
|
kanbanFilterQuery,
|
||||||
clearKanbanFilter,
|
clearKanbanFilter,
|
||||||
softDeleteTask,
|
softDeleteTask,
|
||||||
|
|
@ -1126,6 +1239,7 @@ export const TeamDetailView = ({
|
||||||
lastSendMessageResult: s.lastSendMessageResult,
|
lastSendMessageResult: s.lastSendMessageResult,
|
||||||
reviewActionError: s.reviewActionError,
|
reviewActionError: s.reviewActionError,
|
||||||
addMember: s.addMember,
|
addMember: s.addMember,
|
||||||
|
restartMember: s.restartMember,
|
||||||
removeMember: s.removeMember,
|
removeMember: s.removeMember,
|
||||||
updateMemberRole: s.updateMemberRole,
|
updateMemberRole: s.updateMemberRole,
|
||||||
launchTeam: s.launchTeam,
|
launchTeam: s.launchTeam,
|
||||||
|
|
@ -1133,9 +1247,11 @@ export const TeamDetailView = ({
|
||||||
clearProvisioningError: s.clearProvisioningError,
|
clearProvisioningError: s.clearProvisioningError,
|
||||||
isTeamProvisioning: teamName ? isTeamProvisioningActive(s, teamName) : false,
|
isTeamProvisioning: teamName ? isTeamProvisioningActive(s, teamName) : false,
|
||||||
data: s.selectedTeamName === teamName ? s.selectedTeamData : null,
|
data: s.selectedTeamName === teamName ? s.selectedTeamData : null,
|
||||||
|
members: selectResolvedMembersForTeamName(s, teamName),
|
||||||
loading: s.selectedTeamName === teamName ? s.selectedTeamLoading : false,
|
loading: s.selectedTeamName === teamName ? s.selectedTeamLoading : false,
|
||||||
error: s.selectedTeamName === teamName ? s.selectedTeamError : null,
|
error: s.selectedTeamName === teamName ? s.selectedTeamError : null,
|
||||||
refreshTeamData: s.refreshTeamData,
|
refreshTeamData: s.refreshTeamData,
|
||||||
|
syncTeamPendingReplyRefresh: s.syncTeamPendingReplyRefresh,
|
||||||
kanbanFilterQuery: s.kanbanFilterQuery,
|
kanbanFilterQuery: s.kanbanFilterQuery,
|
||||||
clearKanbanFilter: s.clearKanbanFilter,
|
clearKanbanFilter: s.clearKanbanFilter,
|
||||||
softDeleteTask: s.softDeleteTask,
|
softDeleteTask: s.softDeleteTask,
|
||||||
|
|
@ -1169,13 +1285,12 @@ export const TeamDetailView = ({
|
||||||
diagnostic.count += 1;
|
diagnostic.count += 1;
|
||||||
|
|
||||||
const commitMs = performance.now() - renderStartedAtRef.current;
|
const commitMs = performance.now() - renderStartedAtRef.current;
|
||||||
const messagesCount = data?.messages.length ?? 0;
|
|
||||||
const tasksCount = data?.tasks.length ?? 0;
|
const tasksCount = data?.tasks.length ?? 0;
|
||||||
const membersCount = data?.members.length ?? 0;
|
const membersCount = members.length;
|
||||||
const processesCount = data?.processes.length ?? 0;
|
const processesCount = data?.processes.length ?? 0;
|
||||||
const shouldWarnSlow = commitMs >= TEAM_DETAIL_COMMIT_WARN_MS;
|
const shouldWarnSlow = commitMs >= TEAM_DETAIL_COMMIT_WARN_MS;
|
||||||
const shouldWarnBurst = diagnostic.count >= TEAM_DETAIL_RENDER_BURST_WARN_COUNT;
|
const shouldWarnBurst = diagnostic.count >= TEAM_DETAIL_RENDER_BURST_WARN_COUNT;
|
||||||
const shouldWarnLarge = messagesCount >= 150 || tasksCount >= 80;
|
const shouldWarnLarge = tasksCount >= 80;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(shouldWarnSlow || shouldWarnBurst || shouldWarnLarge) &&
|
(shouldWarnSlow || shouldWarnBurst || shouldWarnLarge) &&
|
||||||
|
|
@ -1187,7 +1302,7 @@ export const TeamDetailView = ({
|
||||||
now - diagnostic.windowStartedAt
|
now - diagnostic.windowStartedAt
|
||||||
} activeTab=${isThisTabActive ? 'yes' : 'no'} paneFocused=${isPaneFocused ? 'yes' : 'no'} loading=${
|
} activeTab=${isThisTabActive ? 'yes' : 'no'} paneFocused=${isPaneFocused ? 'yes' : 'no'} loading=${
|
||||||
loading ? 'yes' : 'no'
|
loading ? 'yes' : 'no'
|
||||||
} messages=${messagesCount} tasks=${tasksCount} members=${membersCount} processes=${processesCount} panel=${messagesPanelMode}`
|
} tasks=${tasksCount} members=${membersCount} processes=${processesCount} panel=${messagesPanelMode}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -1301,36 +1416,34 @@ export const TeamDetailView = ({
|
||||||
);
|
);
|
||||||
|
|
||||||
const leadSessionId = data?.config.leadSessionId ?? null;
|
const leadSessionId = data?.config.leadSessionId ?? null;
|
||||||
|
const pendingReplyRefreshSourceId = useId();
|
||||||
const sessionHistoryKey = useMemo(
|
const sessionHistoryKey = useMemo(
|
||||||
() => (data?.config.sessionHistory ?? []).join('|'),
|
() => (data?.config.sessionHistory ?? []).join('|'),
|
||||||
[data?.config.sessionHistory]
|
[data?.config.sessionHistory]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Keep team message state fresh while we are explicitly waiting for a reply.
|
// Keep team message state fresh while we are explicitly waiting for a reply.
|
||||||
// Use a delayed single-shot refresh instead of a tight polling loop so we
|
// This stays enabled even for hidden mounted tabs, because the waiting state
|
||||||
// don't keep rewriting the whole team snapshot every 2 seconds.
|
// is renderer-local and should keep its lightweight polling until resolved.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pendingReplyRefreshTimerRef.current != null) {
|
const hasPendingReplies = Object.keys(pendingRepliesByMember).length > 0;
|
||||||
window.clearTimeout(pendingReplyRefreshTimerRef.current);
|
syncTeamPendingReplyRefresh(
|
||||||
pendingReplyRefreshTimerRef.current = null;
|
teamName,
|
||||||
}
|
pendingReplyRefreshSourceId,
|
||||||
|
Boolean(data?.isAlive) && hasPendingReplies,
|
||||||
if (!isThisTabActive) return;
|
TEAM_PENDING_REPLY_REFRESH_DELAY_MS
|
||||||
if (!data?.isAlive) return;
|
);
|
||||||
if (Object.keys(pendingRepliesByMember).length === 0) return;
|
|
||||||
|
|
||||||
pendingReplyRefreshTimerRef.current = window.setTimeout(() => {
|
|
||||||
pendingReplyRefreshTimerRef.current = null;
|
|
||||||
void refreshTeamData(teamName, { withDedup: true });
|
|
||||||
}, TEAM_PENDING_REPLY_REFRESH_DELAY_MS);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (pendingReplyRefreshTimerRef.current != null) {
|
syncTeamPendingReplyRefresh(teamName, pendingReplyRefreshSourceId, false);
|
||||||
window.clearTimeout(pendingReplyRefreshTimerRef.current);
|
|
||||||
pendingReplyRefreshTimerRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, [isThisTabActive, data, pendingRepliesByMember, refreshTeamData, teamName]);
|
}, [
|
||||||
|
data?.isAlive,
|
||||||
|
pendingRepliesByMember,
|
||||||
|
pendingReplyRefreshSourceId,
|
||||||
|
syncTeamPendingReplyRefresh,
|
||||||
|
teamName,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
|
|
@ -1364,9 +1477,9 @@ export const TeamDetailView = ({
|
||||||
// Live git branch tracking for the lead project and member worktrees
|
// Live git branch tracking for the lead project and member worktrees
|
||||||
const teamProjectPath = data?.config.projectPath?.trim() ?? null;
|
const teamProjectPath = data?.config.projectPath?.trim() ?? null;
|
||||||
const leadProjectPath = useMemo(() => {
|
const leadProjectPath = useMemo(() => {
|
||||||
const explicitLeadPath = data?.members.find((member) => isLeadMember(member))?.cwd?.trim();
|
const explicitLeadPath = members.find((member) => isLeadMember(member))?.cwd?.trim();
|
||||||
return explicitLeadPath && explicitLeadPath.length > 0 ? explicitLeadPath : teamProjectPath;
|
return explicitLeadPath && explicitLeadPath.length > 0 ? explicitLeadPath : teamProjectPath;
|
||||||
}, [data?.members, teamProjectPath]);
|
}, [members, teamProjectPath]);
|
||||||
const branchSyncPaths = useMemo(() => {
|
const branchSyncPaths = useMemo(() => {
|
||||||
const uniquePaths = new Map<string, string>();
|
const uniquePaths = new Map<string, string>();
|
||||||
const addPath = (candidate: string | null | undefined): void => {
|
const addPath = (candidate: string | null | undefined): void => {
|
||||||
|
|
@ -1378,12 +1491,12 @@ export const TeamDetailView = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
addPath(leadProjectPath);
|
addPath(leadProjectPath);
|
||||||
for (const member of data?.members ?? []) {
|
for (const member of members) {
|
||||||
addPath(member.cwd);
|
addPath(member.cwd);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(uniquePaths.values());
|
return Array.from(uniquePaths.values());
|
||||||
}, [data?.members, leadProjectPath]);
|
}, [members, leadProjectPath]);
|
||||||
useBranchSync(branchSyncPaths, { live: true });
|
useBranchSync(branchSyncPaths, { live: true });
|
||||||
const trackedBranches = useStore(
|
const trackedBranches = useStore(
|
||||||
useShallow((s) =>
|
useShallow((s) =>
|
||||||
|
|
@ -1401,7 +1514,7 @@ export const TeamDetailView = ({
|
||||||
const membersWithLiveBranches = useMemo(() => {
|
const membersWithLiveBranches = useMemo(() => {
|
||||||
if (!data) return [];
|
if (!data) return [];
|
||||||
|
|
||||||
return data.members.map((member) => {
|
return members.map((member) => {
|
||||||
const memberPath = member.cwd?.trim();
|
const memberPath = member.cwd?.trim();
|
||||||
const nextGitBranch =
|
const nextGitBranch =
|
||||||
memberPath && !isLeadMember(member) && leadBranch !== null
|
memberPath && !isLeadMember(member) && leadBranch !== null
|
||||||
|
|
@ -1423,7 +1536,7 @@ export const TeamDetailView = ({
|
||||||
}
|
}
|
||||||
return nextMember;
|
return nextMember;
|
||||||
});
|
});
|
||||||
}, [data, leadBranch, trackedBranches]);
|
}, [leadBranch, members, trackedBranches]);
|
||||||
|
|
||||||
// Filter sessions to team-only using sessionHistory + leadSessionId
|
// Filter sessions to team-only using sessionHistory + leadSessionId
|
||||||
const teamSessionIds = useMemo(() => {
|
const teamSessionIds = useMemo(() => {
|
||||||
|
|
@ -1787,7 +1900,6 @@ export const TeamDetailView = ({
|
||||||
mountPoint: messagesPanelMountPoint,
|
mountPoint: messagesPanelMountPoint,
|
||||||
members: activeMembers,
|
members: activeMembers,
|
||||||
tasks: data?.tasks ?? [],
|
tasks: data?.tasks ?? [],
|
||||||
messages: data?.messages ?? [],
|
|
||||||
isTeamAlive: data?.isAlive,
|
isTeamAlive: data?.isAlive,
|
||||||
timeWindow,
|
timeWindow,
|
||||||
teamSessionIds,
|
teamSessionIds,
|
||||||
|
|
@ -1805,7 +1917,6 @@ export const TeamDetailView = ({
|
||||||
activeMembers,
|
activeMembers,
|
||||||
data?.config.leadSessionId,
|
data?.config.leadSessionId,
|
||||||
data?.isAlive,
|
data?.isAlive,
|
||||||
data?.messages,
|
|
||||||
data?.tasks,
|
data?.tasks,
|
||||||
handleCreateTaskFromMessage,
|
handleCreateTaskFromMessage,
|
||||||
handleOpenTask,
|
handleOpenTask,
|
||||||
|
|
@ -1837,6 +1948,14 @@ export const TeamDetailView = ({
|
||||||
isTeamAlive={data?.isAlive}
|
isTeamAlive={data?.isAlive}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
const teamAgentRuntimeWatcher = (
|
||||||
|
<TeamAgentRuntimeWatcher
|
||||||
|
teamName={teamName}
|
||||||
|
isTeamProvisioning={isTeamProvisioning}
|
||||||
|
isTeamAlive={data?.isAlive}
|
||||||
|
isThisTabActive={isThisTabActive}
|
||||||
|
/>
|
||||||
|
);
|
||||||
const leadContextWatcher = (
|
const leadContextWatcher = (
|
||||||
<LeadContextWatcher
|
<LeadContextWatcher
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
|
|
@ -2482,7 +2601,7 @@ export const TeamDetailView = ({
|
||||||
open={requestChangesTaskId !== null}
|
open={requestChangesTaskId !== null}
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
taskId={requestChangesTaskId}
|
taskId={requestChangesTaskId}
|
||||||
members={data?.members ?? []}
|
members={members}
|
||||||
onCancel={() => setRequestChangesTaskId(null)}
|
onCancel={() => setRequestChangesTaskId(null)}
|
||||||
onSubmit={(comment, taskRefs) => {
|
onSubmit={(comment, taskRefs) => {
|
||||||
if (!requestChangesTaskId) {
|
if (!requestChangesTaskId) {
|
||||||
|
|
@ -2509,11 +2628,11 @@ export const TeamDetailView = ({
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
members={membersWithLiveBranches}
|
members={membersWithLiveBranches}
|
||||||
tasks={data.tasks}
|
tasks={data.tasks}
|
||||||
messages={data.messages}
|
|
||||||
initialTab={selectedMemberView?.initialTab}
|
initialTab={selectedMemberView?.initialTab}
|
||||||
initialActivityFilter={selectedMemberView?.initialActivityFilter}
|
initialActivityFilter={selectedMemberView?.initialActivityFilter}
|
||||||
isTeamAlive={data.isAlive}
|
isTeamAlive={data.isAlive}
|
||||||
isTeamProvisioning={isTeamProvisioning}
|
isTeamProvisioning={isTeamProvisioning}
|
||||||
|
launchParams={launchParams}
|
||||||
onClose={closeSelectedMemberDialog}
|
onClose={closeSelectedMemberDialog}
|
||||||
onSendMessage={() => {
|
onSendMessage={() => {
|
||||||
const name = selectedMember?.name ?? '';
|
const name = selectedMember?.name ?? '';
|
||||||
|
|
@ -2529,6 +2648,7 @@ export const TeamDetailView = ({
|
||||||
closeSelectedMemberDialog();
|
closeSelectedMemberDialog();
|
||||||
openCreateTaskDialog('', '', name);
|
openCreateTaskDialog('', '', name);
|
||||||
}}
|
}}
|
||||||
|
onRestartMember={(memberName) => restartMember(teamName, memberName)}
|
||||||
onTaskClick={(task) => {
|
onTaskClick={(task) => {
|
||||||
closeSelectedMemberDialog();
|
closeSelectedMemberDialog();
|
||||||
setSelectedTask(task);
|
setSelectedTask(task);
|
||||||
|
|
@ -2858,7 +2978,7 @@ export const TeamDetailView = ({
|
||||||
if (task) setSelectedTask(task);
|
if (task) setSelectedTask(task);
|
||||||
}}
|
}}
|
||||||
onOpenMemberProfile={(memberName, options) => {
|
onOpenMemberProfile={(memberName, options) => {
|
||||||
const member = data.members.find((m) => m.name === memberName);
|
const member = members.find((m) => m.name === memberName);
|
||||||
if (member) {
|
if (member) {
|
||||||
setSelectedMember(member);
|
setSelectedMember(member);
|
||||||
setSelectedMemberView({
|
setSelectedMemberView({
|
||||||
|
|
@ -2877,6 +2997,7 @@ export const TeamDetailView = ({
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{spawnStatusWatcher}
|
{spawnStatusWatcher}
|
||||||
|
{teamAgentRuntimeWatcher}
|
||||||
{leadContextWatcher}
|
{leadContextWatcher}
|
||||||
{renderBody()}
|
{renderBody()}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ import type {
|
||||||
ResolvedTeamMember,
|
ResolvedTeamMember,
|
||||||
TeamCreateRequest,
|
TeamCreateRequest,
|
||||||
TeamLaunchRequest,
|
TeamLaunchRequest,
|
||||||
|
TeamMemberSnapshot,
|
||||||
TeamSummary,
|
TeamSummary,
|
||||||
TeamSummaryMember,
|
TeamSummaryMember,
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
|
|
@ -94,6 +95,17 @@ function folderName(fullPath: string): string {
|
||||||
return getBaseName(fullPath) || fullPath;
|
return getBaseName(fullPath) || fullPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveLaunchDialogMembers(members: readonly TeamMemberSnapshot[]): ResolvedTeamMember[] {
|
||||||
|
return members.map((member) => {
|
||||||
|
return {
|
||||||
|
...member,
|
||||||
|
status: member.currentTaskId ? 'active' : 'idle',
|
||||||
|
messageCount: 0,
|
||||||
|
lastActiveAt: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function renderMemberChips(members: TeamSummaryMember[], isLight: boolean): React.JSX.Element {
|
function renderMemberChips(members: TeamSummaryMember[], isLight: boolean): React.JSX.Element {
|
||||||
const teamColorMap = buildMemberColorMap(members);
|
const teamColorMap = buildMemberColorMap(members);
|
||||||
return (
|
return (
|
||||||
|
|
@ -625,7 +637,7 @@ export const TeamListView = (): React.JSX.Element => {
|
||||||
try {
|
try {
|
||||||
const data = await api.teams.getData(teamName);
|
const data = await api.teams.getData(teamName);
|
||||||
setLaunchDialogTeamName(teamName);
|
setLaunchDialogTeamName(teamName);
|
||||||
setLaunchDialogMembers(data.members ?? []);
|
setLaunchDialogMembers(resolveLaunchDialogMembers(data.members ?? []));
|
||||||
setLaunchDialogDefaultPath(data.config.projectPath ?? projectPath);
|
setLaunchDialogDefaultPath(data.config.projectPath ?? projectPath);
|
||||||
setLaunchDialogOpen(true);
|
setLaunchDialogOpen(true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
|
||||||
import { shortenDisplayPath } from '@renderer/utils/pathDisplay';
|
import { shortenDisplayPath } from '@renderer/utils/pathDisplay';
|
||||||
import { highlightLines } from '@renderer/utils/syntaxHighlighter';
|
import { highlightLines } from '@renderer/utils/syntaxHighlighter';
|
||||||
import { AlertTriangle, FileText, MessageCircleQuestion, Search, Terminal } from 'lucide-react';
|
import { AlertTriangle, FileText, MessageCircleQuestion, Search, Terminal } from 'lucide-react';
|
||||||
|
|
@ -149,6 +150,7 @@ export const ToolApprovalSheet: React.FC = () => {
|
||||||
teams,
|
teams,
|
||||||
selectedTeamName,
|
selectedTeamName,
|
||||||
selectedTeamData,
|
selectedTeamData,
|
||||||
|
selectedTeamMembers,
|
||||||
} = useStore(
|
} = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
pendingApprovals: s.pendingApprovals,
|
pendingApprovals: s.pendingApprovals,
|
||||||
|
|
@ -157,6 +159,7 @@ export const ToolApprovalSheet: React.FC = () => {
|
||||||
teams: s.teams,
|
teams: s.teams,
|
||||||
selectedTeamName: s.selectedTeamName,
|
selectedTeamName: s.selectedTeamName,
|
||||||
selectedTeamData: s.selectedTeamData,
|
selectedTeamData: s.selectedTeamData,
|
||||||
|
selectedTeamMembers: selectResolvedMembersForTeamName(s, s.selectedTeamName),
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
const { isLight } = useTheme();
|
const { isLight } = useTheme();
|
||||||
|
|
@ -273,9 +276,9 @@ export const ToolApprovalSheet: React.FC = () => {
|
||||||
// Resolve teammate color for MemberBadge (when source !== 'lead')
|
// Resolve teammate color for MemberBadge (when source !== 'lead')
|
||||||
const sourceColor = useMemo(() => {
|
const sourceColor = useMemo(() => {
|
||||||
if (!current || current.source === 'lead') return undefined;
|
if (!current || current.source === 'lead') return undefined;
|
||||||
const member = selectedTeamData?.members?.find((m) => m.name === current.source);
|
const member = selectedTeamMembers.find((m) => m.name === current.source);
|
||||||
return member?.color;
|
return member?.color;
|
||||||
}, [current, selectedTeamData?.members]);
|
}, [current, selectedTeamMembers]);
|
||||||
|
|
||||||
if (!current) return null;
|
if (!current) return null;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,20 @@
|
||||||
import { Fragment, memo, useCallback, useMemo } from 'react';
|
import { Fragment, memo, useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
import {
|
||||||
|
CompactMarkdownPreview,
|
||||||
|
MarkdownViewer,
|
||||||
|
} from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||||
import { CopyButton } from '@renderer/components/common/CopyButton';
|
import { CopyButton } from '@renderer/components/common/CopyButton';
|
||||||
import { AttachmentDisplay } from '@renderer/components/team/attachments/AttachmentDisplay';
|
import { AttachmentDisplay } from '@renderer/components/team/attachments/AttachmentDisplay';
|
||||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||||
import { TaskTooltip } from '@renderer/components/team/TaskTooltip';
|
import { TaskTooltip } from '@renderer/components/team/TaskTooltip';
|
||||||
import { ExpandableContent } from '@renderer/components/ui/ExpandableContent';
|
import { ExpandableContent } from '@renderer/components/ui/ExpandableContent';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@renderer/components/ui/tooltip';
|
||||||
import {
|
import {
|
||||||
CARD_BG,
|
CARD_BG,
|
||||||
CARD_BG_ZEBRA,
|
CARD_BG_ZEBRA,
|
||||||
|
|
@ -668,11 +676,8 @@ export const ActivityItem = memo(
|
||||||
}, [message.timestamp]);
|
}, [message.timestamp]);
|
||||||
|
|
||||||
const structured = parseStructuredAgentMessage(message.text);
|
const structured = parseStructuredAgentMessage(message.text);
|
||||||
const bootstrapDisplay = useMemo(() => getBootstrapPromptDisplay(message), [message]);
|
const bootstrapDisplay = getBootstrapPromptDisplay(message);
|
||||||
const bootstrapAcknowledgement = useMemo(
|
const bootstrapAcknowledgement = getBootstrapAcknowledgementDisplay(message);
|
||||||
() => getBootstrapAcknowledgementDisplay(message),
|
|
||||||
[message]
|
|
||||||
);
|
|
||||||
// Only flag agent messages as rate-limited, not user's own quotes
|
// Only flag agent messages as rate-limited, not user's own quotes
|
||||||
const rateLimited = message.from !== 'user' && isRateLimitMessage(message.text);
|
const rateLimited = message.from !== 'user' && isRateLimitMessage(message.text);
|
||||||
// Highlight messages containing API errors
|
// Highlight messages containing API errors
|
||||||
|
|
@ -681,22 +686,16 @@ export const ActivityItem = memo(
|
||||||
const isAuthError = isApiError && AUTH_ERROR_PATTERNS.some((p) => p.test(message.text));
|
const isAuthError = isApiError && AUTH_ERROR_PATTERNS.some((p) => p.test(message.text));
|
||||||
// Never collapse rate limit messages as noise — they must be visible
|
// Never collapse rate limit messages as noise — they must be visible
|
||||||
const noiseLabel = structured && !rateLimited ? getNoiseLabel(structured) : null;
|
const noiseLabel = structured && !rateLimited ? getNoiseLabel(structured) : null;
|
||||||
const idleSemantic = useMemo(() => classifyIdleNotification(message), [message]);
|
const idleSemantic = classifyIdleNotification(message);
|
||||||
|
|
||||||
const systemLabel = !structured && !rateLimited ? getSystemMessageLabel(message.text) : null;
|
const systemLabel = !structured && !rateLimited ? getSystemMessageLabel(message.text) : null;
|
||||||
const isManaged = collapseMode === 'managed';
|
const isManaged = collapseMode === 'managed';
|
||||||
const isExpanded = isManaged ? !isCollapsed : true;
|
const isExpanded = isManaged ? !isCollapsed : true;
|
||||||
|
|
||||||
const parsedCrossTeamPrefix = useMemo(() => parseCrossTeamPrefix(message.text), [message.text]);
|
const parsedCrossTeamPrefix = parseCrossTeamPrefix(message.text);
|
||||||
const qualifiedRecipient = useMemo(() => parseQualifiedRecipient(message.to), [message.to]);
|
const qualifiedRecipient = parseQualifiedRecipient(message.to);
|
||||||
const crossTeamSentTarget = useMemo(
|
const crossTeamSentTarget = getCrossTeamSentTarget(message.to, teamName, localMemberNames);
|
||||||
() => getCrossTeamSentTarget(message.to, teamName, localMemberNames),
|
const crossTeamSentMemberName = getCrossTeamSentMemberName(message.to);
|
||||||
[message.to, teamName, localMemberNames]
|
|
||||||
);
|
|
||||||
const crossTeamSentMemberName = useMemo(
|
|
||||||
() => getCrossTeamSentMemberName(message.to),
|
|
||||||
[message.to]
|
|
||||||
);
|
|
||||||
const isCrossTeam = message.source === CROSS_TEAM_SOURCE || parsedCrossTeamPrefix !== null;
|
const isCrossTeam = message.source === CROSS_TEAM_SOURCE || parsedCrossTeamPrefix !== null;
|
||||||
const isCrossTeamSent =
|
const isCrossTeamSent =
|
||||||
message.source === CROSS_TEAM_SENT_SOURCE || crossTeamSentTarget !== null;
|
message.source === CROSS_TEAM_SENT_SOURCE || crossTeamSentTarget !== null;
|
||||||
|
|
@ -789,7 +788,7 @@ export const ActivityItem = memo(
|
||||||
if (!isCrossTeamAny || !strippedText) return '';
|
if (!isCrossTeamAny || !strippedText) return '';
|
||||||
const oneLine = strippedText.replace(/\n+/g, ' ').trim();
|
const oneLine = strippedText.replace(/\n+/g, ' ').trim();
|
||||||
if (!oneLine) return '';
|
if (!oneLine) return '';
|
||||||
return oneLine.length > 80 ? oneLine.slice(0, 80) + '…' : oneLine;
|
return oneLine;
|
||||||
}, [isCrossTeamAny, strippedText]);
|
}, [isCrossTeamAny, strippedText]);
|
||||||
|
|
||||||
const rawSummary = useMemo(() => {
|
const rawSummary = useMemo(() => {
|
||||||
|
|
@ -815,8 +814,7 @@ export const ActivityItem = memo(
|
||||||
// Fallback: use the beginning of message text as preview for plain-text messages
|
// Fallback: use the beginning of message text as preview for plain-text messages
|
||||||
const plain = getSanitizedInboxMessageText(message).trim();
|
const plain = getSanitizedInboxMessageText(message).trim();
|
||||||
if (!plain) return '';
|
if (!plain) return '';
|
||||||
const oneLine = plain.replace(/\n+/g, ' ');
|
return plain.replace(/\n+/g, ' ');
|
||||||
return oneLine.length > 80 ? oneLine.slice(0, 80) + '…' : oneLine;
|
|
||||||
}, [
|
}, [
|
||||||
crossTeamPreview,
|
crossTeamPreview,
|
||||||
isSlashCommandMessage,
|
isSlashCommandMessage,
|
||||||
|
|
@ -827,7 +825,47 @@ export const ActivityItem = memo(
|
||||||
slashCommandMeta,
|
slashCommandMeta,
|
||||||
structured,
|
structured,
|
||||||
]);
|
]);
|
||||||
const summaryText = useMemo(() => extractMarkdownPlainText(rawSummary), [rawSummary]);
|
const summaryText = extractMarkdownPlainText(rawSummary);
|
||||||
|
const compactPreviewMarkdown = useMemo(() => {
|
||||||
|
if (idleSemantic?.hasPeerSummary && idleSemantic.peerSummary) {
|
||||||
|
return idleSemantic.peerSummary;
|
||||||
|
}
|
||||||
|
if (isSlashCommandResult && message.commandOutput) {
|
||||||
|
return message.summary || getCommandOutputSummary(message.text);
|
||||||
|
}
|
||||||
|
if (isSlashCommandMessage && slashCommandMeta) {
|
||||||
|
if (slashCommandMeta.args) {
|
||||||
|
const oneLine = slashCommandMeta.args.replace(/\n+/g, ' ').trim();
|
||||||
|
return `${slashCommandMeta.command} ${oneLine}`;
|
||||||
|
}
|
||||||
|
return slashCommandMeta.command;
|
||||||
|
}
|
||||||
|
if (crossTeamPreview) return crossTeamPreview;
|
||||||
|
|
||||||
|
const formattedDisplayText = displayText?.trim() ?? '';
|
||||||
|
if (formattedDisplayText) {
|
||||||
|
return formattedDisplayText;
|
||||||
|
}
|
||||||
|
|
||||||
|
return summaryText || rawSummary;
|
||||||
|
}, [
|
||||||
|
crossTeamPreview,
|
||||||
|
displayText,
|
||||||
|
idleSemantic,
|
||||||
|
isSlashCommandMessage,
|
||||||
|
isSlashCommandResult,
|
||||||
|
message,
|
||||||
|
message.commandOutput,
|
||||||
|
rawSummary,
|
||||||
|
slashCommandMeta,
|
||||||
|
summaryText,
|
||||||
|
]);
|
||||||
|
const compactPreviewTooltipText = useMemo(() => {
|
||||||
|
const normalized = extractMarkdownPlainText(compactPreviewMarkdown)
|
||||||
|
.replace(/\n+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
return normalized || compactPreviewMarkdown;
|
||||||
|
}, [compactPreviewMarkdown]);
|
||||||
const commentTaskRef =
|
const commentTaskRef =
|
||||||
message.messageKind === 'task_comment_notification' ? (message.taskRefs?.[0] ?? null) : null;
|
message.messageKind === 'task_comment_notification' ? (message.taskRefs?.[0] ?? null) : null;
|
||||||
const commentTaskDisplayId =
|
const commentTaskDisplayId =
|
||||||
|
|
@ -1187,13 +1225,109 @@ export const ActivityItem = memo(
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<TooltipProvider delayDuration={1000}>
|
||||||
className="mt-1 min-w-0 truncate text-[11px]"
|
<Tooltip>
|
||||||
style={{ color: CARD_TEXT_LIGHT }}
|
<TooltipTrigger asChild>
|
||||||
title={summaryText || rawSummary}
|
<div>
|
||||||
>
|
<CompactMarkdownPreview
|
||||||
{summaryContent}
|
content={compactPreviewMarkdown}
|
||||||
|
className="mt-1 line-clamp-2 w-full min-w-0 max-w-full break-words text-[11px] leading-4"
|
||||||
|
teamColorByName={teamColorByName}
|
||||||
|
onTeamClick={onTeamClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="bottom"
|
||||||
|
align="start"
|
||||||
|
className="max-w-sm whitespace-normal break-words"
|
||||||
|
>
|
||||||
|
{compactPreviewTooltipText}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
) : !isExpanded ? (
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
{isUnread ? (
|
||||||
|
<span
|
||||||
|
className="size-2 shrink-0 rounded-full bg-blue-500"
|
||||||
|
title="Unread"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{showChevron ? (
|
||||||
|
<ChevronRight
|
||||||
|
className="size-3 shrink-0 transition-transform duration-150"
|
||||||
|
style={{
|
||||||
|
color: CARD_ICON_MUTED,
|
||||||
|
transform: isExpanded ? 'rotate(90deg)' : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{crossTeamOrigin ? (
|
||||||
|
<CrossTeamTeamBadge teamName={crossTeamOrigin.teamName} onClick={onTeamClick} />
|
||||||
|
) : null}
|
||||||
|
{senderBadge}
|
||||||
|
{!compactHeader && formattedRole && !isSlashCommandResult ? (
|
||||||
|
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
||||||
|
{formattedRole}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{messageTypeBadge}
|
||||||
|
{leadSourceBadge}
|
||||||
|
{statusBadge}
|
||||||
|
{recipientBadge}
|
||||||
|
<div className="relative ml-auto flex shrink-0 items-center">
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
onExpand && expandItemKey
|
||||||
|
? 'text-[10px] transition-opacity group-hover:opacity-0'
|
||||||
|
: 'text-[10px]'
|
||||||
|
}
|
||||||
|
style={{ color: CARD_ICON_MUTED }}
|
||||||
|
>
|
||||||
|
{timestamp}
|
||||||
|
</span>
|
||||||
|
{onExpand && expandItemKey && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Expand message"
|
||||||
|
className="absolute right-0 top-1/2 -translate-y-1/2 rounded p-0.5 opacity-0 transition-opacity focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-blue-500/50 group-hover:opacity-100"
|
||||||
|
style={{ color: CARD_ICON_MUTED }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onExpand(expandItemKey);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Maximize2 size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<TooltipProvider delayDuration={1000}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div>
|
||||||
|
<CompactMarkdownPreview
|
||||||
|
content={compactPreviewMarkdown}
|
||||||
|
className="mt-1 line-clamp-2 w-full min-w-0 max-w-full break-words text-[11px] leading-4"
|
||||||
|
teamColorByName={teamColorByName}
|
||||||
|
onTeamClick={onTeamClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="bottom"
|
||||||
|
align="start"
|
||||||
|
className="max-w-sm whitespace-normal break-words"
|
||||||
|
>
|
||||||
|
{compactPreviewTooltipText}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -48,12 +48,6 @@ interface ActivityTimelineProps {
|
||||||
expandOverrides?: Set<string>;
|
expandOverrides?: Set<string>;
|
||||||
/** Called when user toggles expand/collapse override on a specific message. */
|
/** Called when user toggles expand/collapse override on a specific message. */
|
||||||
onToggleExpandOverride?: (key: string) => void;
|
onToggleExpandOverride?: (key: string) => void;
|
||||||
/**
|
|
||||||
* All session IDs belonging to this team (current + history).
|
|
||||||
* Used together with currentLeadSessionId to suppress only the reconnect boundary
|
|
||||||
* from the current live session back into the team's previous session history.
|
|
||||||
*/
|
|
||||||
teamSessionIds?: Set<string>;
|
|
||||||
/** Current lead session ID for the active team, if known. */
|
/** Current lead session ID for the active team, if known. */
|
||||||
currentLeadSessionId?: string;
|
currentLeadSessionId?: string;
|
||||||
/** Whether the current team is alive. */
|
/** Whether the current team is alive. */
|
||||||
|
|
@ -281,7 +275,6 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
|
||||||
allCollapsed,
|
allCollapsed,
|
||||||
expandOverrides,
|
expandOverrides,
|
||||||
onToggleExpandOverride,
|
onToggleExpandOverride,
|
||||||
teamSessionIds,
|
|
||||||
currentLeadSessionId,
|
currentLeadSessionId,
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
leadActivity,
|
leadActivity,
|
||||||
|
|
@ -425,10 +418,13 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
|
||||||
setVisibleCount(Infinity);
|
setVisibleCount(Infinity);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getItemSessionId = (item: TimelineItem): string | undefined =>
|
const getItemSessionAnchorId = (item: TimelineItem): string | undefined => {
|
||||||
item.type === 'lead-thoughts'
|
if (item.type === 'lead-thoughts') {
|
||||||
? item.group.thoughts[0].leadSessionId
|
return item.group.thoughts[0]?.leadSessionId;
|
||||||
: item.message.leadSessionId;
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
// Pin the newest thought group (if first) so it stays at the top and doesn't jump.
|
// Pin the newest thought group (if first) so it stays at the top and doesn't jump.
|
||||||
const pinnedThoughtGroup = timelineItems[0]?.type === 'lead-thoughts' ? timelineItems[0] : null;
|
const pinnedThoughtGroup = timelineItems[0]?.type === 'lead-thoughts' ? timelineItems[0] : null;
|
||||||
|
|
@ -535,32 +531,29 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
|
||||||
// Session boundary separator (messages sorted desc — new on top)
|
// Session boundary separator (messages sorted desc — new on top)
|
||||||
let sessionSeparator: React.JSX.Element | null = null;
|
let sessionSeparator: React.JSX.Element | null = null;
|
||||||
if (realIndex > 0) {
|
if (realIndex > 0) {
|
||||||
const prevSessionId = getItemSessionId(timelineItems[realIndex - 1]);
|
const currSessionId = getItemSessionAnchorId(item);
|
||||||
const currSessionId = getItemSessionId(item);
|
let prevSessionId: string | undefined;
|
||||||
if (prevSessionId && currSessionId && prevSessionId !== currSessionId) {
|
for (let searchIndex = realIndex - 1; searchIndex >= 0; searchIndex -= 1) {
|
||||||
// Suppress only the boundary between the current live session and the team's
|
const candidateSessionId = getItemSessionAnchorId(timelineItems[searchIndex]);
|
||||||
// older session history. Older historical session boundaries should still render.
|
if (candidateSessionId) {
|
||||||
const isReconnectBoundary =
|
prevSessionId = candidateSessionId;
|
||||||
!!currentLeadSessionId &&
|
break;
|
||||||
teamSessionIds &&
|
|
||||||
teamSessionIds.has(prevSessionId) &&
|
|
||||||
teamSessionIds.has(currSessionId) &&
|
|
||||||
(prevSessionId === currentLeadSessionId || currSessionId === currentLeadSessionId);
|
|
||||||
if (!isReconnectBoundary) {
|
|
||||||
sessionSeparator = (
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-3"
|
|
||||||
style={{ paddingTop: 45, paddingBottom: 45 }}
|
|
||||||
>
|
|
||||||
<div className="h-px flex-1 bg-blue-600/30 dark:bg-blue-400/30" />
|
|
||||||
<span className="whitespace-nowrap text-[11px] font-medium text-blue-600 dark:text-blue-400">
|
|
||||||
New session
|
|
||||||
</span>
|
|
||||||
<div className="h-px flex-1 bg-blue-600/30 dark:bg-blue-400/30" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (prevSessionId && currSessionId && prevSessionId !== currSessionId) {
|
||||||
|
sessionSeparator = (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-3"
|
||||||
|
style={{ paddingTop: 45, paddingBottom: 45 }}
|
||||||
|
>
|
||||||
|
<div className="h-px flex-1 bg-blue-600/30 dark:bg-blue-400/30" />
|
||||||
|
<span className="whitespace-nowrap text-[11px] font-medium text-blue-600 dark:text-blue-400">
|
||||||
|
New session
|
||||||
|
</span>
|
||||||
|
<div className="h-px flex-1 bg-blue-600/30 dark:bg-blue-400/30" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.type === 'lead-thoughts') {
|
if (item.type === 'lead-thoughts') {
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue