commit
d14e06473a
223 changed files with 29177 additions and 2881 deletions
13
README.md
13
README.md
|
|
@ -23,7 +23,7 @@
|
|||
</p>
|
||||
|
||||
<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>
|
||||
|
||||
<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
|
||||
|
||||
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">
|
||||
<tr>
|
||||
|
|
@ -106,7 +107,7 @@ No prerequisites - the app can detect supported runtimes/providers and guide set
|
|||
|
||||
## 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
|
||||
- **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.
|
||||
|
||||
- **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
|
||||
|
||||
- **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>
|
||||
<summary><strong>Does it read or upload my code?</strong></summary>
|
||||
<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>
|
||||
|
|
@ -224,7 +227,7 @@ Yes. Agents send direct messages, create shared tasks, and leave comments - all
|
|||
<details>
|
||||
<summary><strong>Is it free?</strong></summary>
|
||||
<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>
|
||||
|
|
@ -251,7 +254,7 @@ Yes. Run multiple teams in one project or across different projects, even simult
|
|||
|
||||
## 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>
|
||||
<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';
|
||||
const client = new Anthropic();
|
||||
const response = await client.messages.create({
|
||||
model: 'claude-opus-4-6',
|
||||
model: 'claude-opus-4-7',
|
||||
messages: [{ role: 'user', content: 'Send message to teammate...' }],
|
||||
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)
|
||||
{
|
||||
name: 'electron-preload',
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "claude-agent-teams-ui",
|
||||
"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",
|
||||
"license": "AGPL-3.0",
|
||||
"author": {
|
||||
|
|
@ -143,6 +143,7 @@
|
|||
"motion": "12.38.0",
|
||||
"node-diff3": "^3.2.0",
|
||||
"node-pty": "^1.1.0",
|
||||
"pidusage": "4.0.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-grid-layout": "^2.2.2",
|
||||
|
|
@ -174,6 +175,7 @@
|
|||
"@types/hast": "^3.0.4",
|
||||
"@types/mdast": "^4.0.4",
|
||||
"@types/node": "^25.0.7",
|
||||
"@types/pidusage": "2.0.5",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@types/ssh2": "^1.15.5",
|
||||
|
|
@ -213,7 +215,7 @@
|
|||
},
|
||||
"build": {
|
||||
"appId": "com.agent-teams.app",
|
||||
"productName": "Claude Agent Teams UI",
|
||||
"productName": "Agent Teams UI",
|
||||
"directories": {
|
||||
"output": "release"
|
||||
},
|
||||
|
|
@ -303,7 +305,8 @@
|
|||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"electron",
|
||||
"node-pty"
|
||||
"node-pty",
|
||||
"cpu-features"
|
||||
]
|
||||
},
|
||||
"knip": {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,10 @@ import { HANDOFF_CARD, NODE, TASK_PILL, MIN_VISIBLE_OPACITY } from '../constants
|
|||
import type { CameraTransform } from '../hooks/useGraphCamera';
|
||||
import { getHandoffAnchorTarget } from '../layout/launchAnchor';
|
||||
import type { GraphNode } from '../ports/types';
|
||||
import type { TransientHandoffCard } from '../ui/transientHandoffs';
|
||||
import {
|
||||
getTransientHandoffCardAlpha,
|
||||
type TransientHandoffCard,
|
||||
} from '../ui/transientHandoffs';
|
||||
import { truncateText } from './draw-misc';
|
||||
import { hexWithAlpha, measureTextCached } from './render-cache';
|
||||
|
||||
|
|
@ -20,24 +23,24 @@ export function drawHandoffCards(
|
|||
const { cards, nodeMap, time, camera, viewport } = params;
|
||||
if (cards.length === 0) return;
|
||||
|
||||
const stackIndexByDestination = new Map<string, number>();
|
||||
const stackIndexByAnchor = new Map<string, number>();
|
||||
let drawnCount = 0;
|
||||
|
||||
for (const card of cards) {
|
||||
if (drawnCount >= HANDOFF_CARD.maxVisible) break;
|
||||
const destinationNode = nodeMap.get(card.destinationNodeId);
|
||||
if (!destinationNode || destinationNode.x == null || destinationNode.y == null) continue;
|
||||
const anchorNode = nodeMap.get(card.anchorNodeId);
|
||||
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;
|
||||
|
||||
const previewLines = buildPreviewLines(ctx, card.preview);
|
||||
const height = HANDOFF_CARD.baseHeight + previewLines.length * HANDOFF_CARD.previewLineHeight;
|
||||
const stackIndex = stackIndexByDestination.get(card.destinationNodeId) ?? 0;
|
||||
stackIndexByDestination.set(card.destinationNodeId, stackIndex + 1);
|
||||
const stackIndex = stackIndexByAnchor.get(card.anchorNodeId) ?? 0;
|
||||
stackIndexByAnchor.set(card.anchorNodeId, stackIndex + 1);
|
||||
|
||||
const position = getCardPosition({
|
||||
node: destinationNode,
|
||||
node: anchorNode,
|
||||
camera,
|
||||
viewport,
|
||||
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: {
|
||||
node: GraphNode;
|
||||
camera: CameraTransform;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* 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 { CAMERA, ANIM, NODE, TASK_PILL } from '../constants/canvas-constants';
|
||||
import type { WorldBounds } from '../layout/launchAnchor';
|
||||
|
|
@ -170,17 +170,31 @@ export function useGraphCamera(): UseGraphCameraResult {
|
|||
t.zoom = Math.max(CAMERA.minZoom, t.zoom / 1.2);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
transformRef,
|
||||
screenToWorld,
|
||||
worldToScreen,
|
||||
handleWheel,
|
||||
handlePanStart,
|
||||
handlePanMove,
|
||||
handlePanEnd,
|
||||
zoomToFit,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
updateInertia,
|
||||
};
|
||||
return useMemo(
|
||||
() => ({
|
||||
transformRef,
|
||||
screenToWorld,
|
||||
worldToScreen,
|
||||
handleWheel,
|
||||
handlePanStart,
|
||||
handlePanMove,
|
||||
handlePanEnd,
|
||||
zoomToFit,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
updateInertia,
|
||||
}),
|
||||
[
|
||||
screenToWorld,
|
||||
worldToScreen,
|
||||
handleWheel,
|
||||
handlePanStart,
|
||||
handlePanMove,
|
||||
handlePanEnd,
|
||||
zoomToFit,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
updateInertia,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
* Delegates hit testing to strategy pattern.
|
||||
*/
|
||||
|
||||
import { useRef, useCallback } from 'react';
|
||||
import { useRef, useCallback, useMemo } from 'react';
|
||||
import type { GraphNode } from '../ports/types';
|
||||
import { ANIM } from '../constants/canvas-constants';
|
||||
import { findNodeAt } from '../canvas/hit-detection';
|
||||
|
|
@ -81,13 +81,16 @@ export function useGraphInteraction(
|
|||
return findNodeAt(wx, wy, nodes);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
hoveredNodeId,
|
||||
dragNodeId,
|
||||
isDragging,
|
||||
handleMouseDown,
|
||||
handleMouseMove,
|
||||
handleMouseUp,
|
||||
handleDoubleClick,
|
||||
};
|
||||
return useMemo(
|
||||
() => ({
|
||||
hoveredNodeId,
|
||||
dragNodeId,
|
||||
isDragging,
|
||||
handleMouseDown,
|
||||
handleMouseMove,
|
||||
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 { getStateColor } from '../constants/colors';
|
||||
|
|
@ -239,19 +239,29 @@ export function useGraphSimulation(): UseGraphSimulationResult {
|
|||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
stateRef,
|
||||
updateData,
|
||||
tick,
|
||||
setNodePosition,
|
||||
clearNodePosition,
|
||||
clearTransientOwnerPositions,
|
||||
resolveNearestOwnerSlot,
|
||||
getLaunchAnchorWorldPosition: (leadNodeId: string) =>
|
||||
launchAnchorPositionsRef.current.get(leadNodeId) ?? null,
|
||||
getActivityWorldRect: (nodeId: string) => activityRectByNodeIdRef.current.get(nodeId) ?? null,
|
||||
getExtraWorldBounds: () => extraWorldBoundsRef.current,
|
||||
};
|
||||
return useMemo(
|
||||
() => ({
|
||||
stateRef,
|
||||
updateData,
|
||||
tick,
|
||||
setNodePosition,
|
||||
clearNodePosition,
|
||||
clearTransientOwnerPositions,
|
||||
resolveNearestOwnerSlot,
|
||||
getLaunchAnchorWorldPosition: (leadNodeId: string) =>
|
||||
launchAnchorPositionsRef.current.get(leadNodeId) ?? null,
|
||||
getActivityWorldRect: (nodeId: string) => activityRectByNodeIdRef.current.get(nodeId) ?? null,
|
||||
getExtraWorldBounds: () => extraWorldBoundsRef.current,
|
||||
}),
|
||||
[
|
||||
updateData,
|
||||
tick,
|
||||
setNodePosition,
|
||||
clearNodePosition,
|
||||
clearTransientOwnerPositions,
|
||||
resolveNearestOwnerSlot,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
function applySnapshotToNodes(
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@
|
|||
export { GraphView } from './ui/GraphView';
|
||||
export type { GraphViewProps } from './ui/GraphView';
|
||||
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) ─────────────────────────
|
||||
export type { GraphDataPort } from './ports/GraphDataPort';
|
||||
|
|
|
|||
|
|
@ -173,12 +173,16 @@ export function buildStableSlotLayoutSnapshot({
|
|||
);
|
||||
const leadActivityRect = leadSlotFrame.activityColumnRect;
|
||||
const launchHudRect = createRect(leadCoreRect.right, leadCoreRect.top, 0, 0);
|
||||
const leadCentralReservedBlock = leadSlotFrame.bounds;
|
||||
const leadCentralReservedBlock = buildLeadCentralReservedBlock({
|
||||
leadCoreRect,
|
||||
leadSlotFrame,
|
||||
});
|
||||
|
||||
const ownerFootprints = computeOwnerFootprints(nodes, layout);
|
||||
const unassignedTaskRect = buildUnassignedTaskRect(nodes, leadCentralReservedBlock);
|
||||
const centralCollisionRects = buildCentralCollisionRects({
|
||||
leadCentralReservedBlock,
|
||||
leadCoreRect,
|
||||
leadSlotFrame,
|
||||
unassignedTaskRect,
|
||||
});
|
||||
const runtimeCentralExclusion = padRect(
|
||||
|
|
@ -222,16 +226,34 @@ export function buildStableSlotLayoutSnapshot({
|
|||
}
|
||||
|
||||
function buildCentralCollisionRects(args: {
|
||||
leadCentralReservedBlock: StableRect;
|
||||
leadCoreRect: StableRect;
|
||||
leadSlotFrame: SlotFrame;
|
||||
unassignedTaskRect: StableRect | null;
|
||||
}): StableRect[] {
|
||||
const rects = [args.leadCentralReservedBlock];
|
||||
const rects = [
|
||||
args.leadCoreRect,
|
||||
args.leadSlotFrame.processBandRect,
|
||||
args.leadSlotFrame.activityColumnRect,
|
||||
args.leadSlotFrame.kanbanBandRect,
|
||||
];
|
||||
if (args.unassignedTaskRect) {
|
||||
rects.push(args.unassignedTaskRect);
|
||||
}
|
||||
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(
|
||||
rects: readonly StableRect[],
|
||||
padding: number
|
||||
|
|
@ -648,6 +670,12 @@ function validateLeadSnapshotRects(
|
|||
if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadActivityRect)) {
|
||||
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) {
|
||||
return {
|
||||
valid: false,
|
||||
|
|
@ -660,9 +688,6 @@ function validateLeadSnapshotRects(
|
|||
reason: 'leadActivityRect must mirror leadSlotFrame.activityColumnRect',
|
||||
};
|
||||
}
|
||||
if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadSlotFrame.bounds)) {
|
||||
return { valid: false, reason: 'leadSlotFrame must fit inside leadCentralReservedBlock' };
|
||||
}
|
||||
if (!rectContainsRect(snapshot.runtimeCentralExclusion, snapshot.leadCentralReservedBlock)) {
|
||||
return { valid: false, reason: 'runtimeCentralExclusion must contain leadCentralReservedBlock' };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import {
|
|||
import {
|
||||
createTransientHandoffState,
|
||||
selectRenderableTransientHandoffCards,
|
||||
type TransientHandoffCard,
|
||||
updateTransientHandoffState,
|
||||
} from './transientHandoffs';
|
||||
import type { CameraTransform } from '../hooks/useGraphCamera';
|
||||
|
|
@ -70,6 +71,14 @@ export interface GraphCanvasHandle {
|
|||
draw: (state: GraphDrawState) => void;
|
||||
/** Get the canvas element for coordinate transforms */
|
||||
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 {
|
||||
|
|
@ -163,6 +172,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(funct
|
|||
const activeParticleEdgesCache = useRef(new Set<string>());
|
||||
const handoffStateRef = useRef(createTransientHandoffState());
|
||||
const lastTeamNameRef = useRef<string | null>(null);
|
||||
const lastDrawTimeRef = useRef(0);
|
||||
|
||||
// Imperative draw function — called from RAF, NOT from React render
|
||||
useImperativeHandle(
|
||||
|
|
@ -181,6 +191,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(funct
|
|||
if (w === 0 || h === 0) return;
|
||||
|
||||
try {
|
||||
lastDrawTimeRef.current = state.time;
|
||||
if (lastTeamNameRef.current !== state.teamName) {
|
||||
handoffStateRef.current = createTransientHandoffState();
|
||||
lastTeamNameRef.current = state.teamName;
|
||||
|
|
@ -309,9 +320,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(funct
|
|||
focusNodeIds: state.focusNodeIds,
|
||||
focusEdgeIds: prioritizedEdgeIds ?? state.focusEdgeIds,
|
||||
}
|
||||
).filter(
|
||||
(card) => card.destinationKind !== 'lead' && card.destinationKind !== 'member'
|
||||
);
|
||||
).filter((card) => card.anchorKind !== 'lead' && card.anchorKind !== 'member');
|
||||
drawParticles(ctx, renderableParticles, edgeMap, nodeMap, state.time, prioritizedEdgeIds);
|
||||
|
||||
// 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,
|
||||
getTransientHandoffSnapshot: (options) => ({
|
||||
cards: selectRenderableTransientHandoffCards(handoffStateRef.current, options),
|
||||
time: lastDrawTimeRef.current,
|
||||
}),
|
||||
}),
|
||||
[showHexGrid, showStarField, bloomIntensity]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { GraphControls, type GraphFilterState } from './GraphControls';
|
|||
import { GraphOverlay } from './GraphOverlay';
|
||||
import { GraphEdgeOverlay } from './GraphEdgeOverlay';
|
||||
import { buildFocusState } from './buildFocusState';
|
||||
import type { TransientHandoffCard } from './transientHandoffs';
|
||||
import { useGraphSimulation } from '../hooks/useGraphSimulation';
|
||||
import { useGraphCamera } from '../hooks/useGraphCamera';
|
||||
import { useGraphInteraction } from '../hooks/useGraphInteraction';
|
||||
|
|
@ -31,7 +32,7 @@ import {
|
|||
findNodeAt,
|
||||
getEdgeMidpoint,
|
||||
} 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';
|
||||
|
||||
export interface GraphViewProps {
|
||||
|
|
@ -73,11 +74,16 @@ export interface GraphViewProps {
|
|||
leadNodeId: string,
|
||||
) => { x: number; y: number; scale: number; visible: boolean } | null;
|
||||
getActivityWorldRect: (ownerNodeId: string) => StableRect | null;
|
||||
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;
|
||||
getViewportSize: () => { width: number; height: number };
|
||||
focusNodeIds: ReadonlySet<string> | null;
|
||||
focusEdgeIds: ReadonlySet<string> | null;
|
||||
}) => React.ReactNode;
|
||||
}
|
||||
|
||||
|
|
@ -142,13 +148,6 @@ export function GraphView({
|
|||
// ─── Hooks ──────────────────────────────────────────────────────────────
|
||||
const simulation = useGraphSimulation();
|
||||
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(
|
||||
useCallback(
|
||||
(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(
|
||||
(nodes: GraphNode[]): GraphNode[] =>
|
||||
nodes.filter((node) => {
|
||||
|
|
@ -250,6 +263,17 @@ export function GraphView({
|
|||
(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 node = simulationRef.current.stateRef.current.nodes.find((candidate) => candidate.id === nodeId);
|
||||
if (node?.x == null || node?.y == null) {
|
||||
|
|
@ -416,16 +440,16 @@ export function GraphView({
|
|||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isSurfaceActive) {
|
||||
if (isSurfaceActive) {
|
||||
return;
|
||||
}
|
||||
interaction.handleMouseUp();
|
||||
simulation.clearTransientOwnerPositions();
|
||||
interactionRef.current.handleMouseUp();
|
||||
simulationRef.current.clearTransientOwnerPositions();
|
||||
dragPreviewRef.current = null;
|
||||
isPanningRef.current = false;
|
||||
edgeMouseDownRef.current = null;
|
||||
setInteractionGuards(false);
|
||||
}, [interaction, isSurfaceActive, simulation]);
|
||||
}, [isSurfaceActive, setInteractionGuards]);
|
||||
|
||||
const handleWheel = useCallback(
|
||||
(e: WheelEvent) => {
|
||||
|
|
@ -437,7 +461,13 @@ export function GraphView({
|
|||
|
||||
// ─── Mouse handlers (Figma-style: drag empty space = pan, drag node = move) ─
|
||||
const isPanningRef = useRef(false);
|
||||
const edgeMouseDownRef = useRef<{ id: string; x: number; y: number } | null>(null);
|
||||
const edgeMouseDownRef = useRef<{
|
||||
id: string;
|
||||
worldX: number;
|
||||
worldY: number;
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
} | null>(null);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
|
|
@ -474,7 +504,13 @@ export function GraphView({
|
|||
if (hitEdge) {
|
||||
markUserInteracted();
|
||||
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;
|
||||
} else {
|
||||
// Hit empty space → pan
|
||||
|
|
@ -501,11 +537,6 @@ export function GraphView({
|
|||
|
||||
const processActivePointerMove = useCallback(
|
||||
(clientX: number, clientY: number) => {
|
||||
if (!activePrimaryInteractionRef.current) {
|
||||
dragPreviewRef.current = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isPanningRef.current) {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.getSelection()?.removeAllRanges();
|
||||
|
|
@ -514,6 +545,36 @@ export function GraphView({
|
|||
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();
|
||||
if (!canvas) {
|
||||
dragPreviewRef.current = null;
|
||||
|
|
@ -610,8 +671,8 @@ export function GraphView({
|
|||
if (canvas && edgeMouseDownRef.current && !interaction.isDragging.current) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const world = camera.screenToWorld(clientX - rect.left, clientY - rect.top);
|
||||
const dx = world.x - edgeMouseDownRef.current.x;
|
||||
const dy = world.y - edgeMouseDownRef.current.y;
|
||||
const dx = world.x - edgeMouseDownRef.current.worldX;
|
||||
const dy = world.y - edgeMouseDownRef.current.worldY;
|
||||
if (dx * dx + dy * dy <= 25) {
|
||||
clickedEdgeId = edgeMouseDownRef.current.id;
|
||||
}
|
||||
|
|
@ -639,6 +700,8 @@ export function GraphView({
|
|||
},
|
||||
[camera, events, interaction, onOwnerSlotDrop, setInteractionGuards, simulation]
|
||||
);
|
||||
processActivePointerMoveRef.current = processActivePointerMove;
|
||||
completePointerInteractionRef.current = completePointerInteraction;
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
|
|
@ -694,36 +757,40 @@ export function GraphView({
|
|||
if (
|
||||
!activePrimaryInteractionRef.current &&
|
||||
!isPanningRef.current &&
|
||||
!interaction.dragNodeId.current &&
|
||||
!interaction.isDragging.current &&
|
||||
!interactionRef.current.dragNodeId.current &&
|
||||
!interactionRef.current.isDragging.current &&
|
||||
!edgeMouseDownRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
processActivePointerMove(event.clientX, event.clientY);
|
||||
processActivePointerMoveRef.current?.(event.clientX, event.clientY);
|
||||
};
|
||||
|
||||
const handleWindowMouseUp = (event: MouseEvent): void => {
|
||||
if (
|
||||
!activePrimaryInteractionRef.current &&
|
||||
!isPanningRef.current &&
|
||||
!interaction.dragNodeId.current &&
|
||||
!interaction.isDragging.current &&
|
||||
!interactionRef.current.dragNodeId.current &&
|
||||
!interactionRef.current.isDragging.current &&
|
||||
!edgeMouseDownRef.current
|
||||
) {
|
||||
setInteractionGuards(false);
|
||||
return;
|
||||
}
|
||||
completePointerInteraction(event.clientX, event.clientY);
|
||||
completePointerInteractionRef.current?.(event.clientX, event.clientY);
|
||||
};
|
||||
|
||||
const clearInteraction = (): void => {
|
||||
if (!activePrimaryInteractionRef.current && !isPanningRef.current && !interaction.isDragging.current) {
|
||||
if (
|
||||
!activePrimaryInteractionRef.current &&
|
||||
!isPanningRef.current &&
|
||||
!interactionRef.current.isDragging.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
interaction.handleMouseUp();
|
||||
camera.handlePanEnd();
|
||||
interactionRef.current.handleMouseUp();
|
||||
cameraRef.current.handlePanEnd();
|
||||
isPanningRef.current = false;
|
||||
edgeMouseDownRef.current = null;
|
||||
dragPreviewRef.current = null;
|
||||
|
|
@ -739,9 +806,14 @@ export function GraphView({
|
|||
window.removeEventListener('mouseup', handleWindowMouseUp);
|
||||
window.removeEventListener('blur', clearInteraction);
|
||||
window.removeEventListener('dragstart', clearInteraction);
|
||||
};
|
||||
}, [setInteractionGuards]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setInteractionGuards(false);
|
||||
};
|
||||
}, [camera, completePointerInteraction, interaction, processActivePointerMove, setInteractionGuards]);
|
||||
}, [setInteractionGuards]);
|
||||
|
||||
const handleDoubleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
|
|
@ -946,11 +1018,13 @@ export function GraphView({
|
|||
{renderHud({
|
||||
getLaunchAnchorScreenPlacement,
|
||||
getActivityWorldRect,
|
||||
getTransientHandoffSnapshot,
|
||||
getCameraZoom,
|
||||
worldToScreen: camera.worldToScreen,
|
||||
getNodeWorldPosition,
|
||||
getViewportSize,
|
||||
focusNodeIds: focusState.focusNodeIds,
|
||||
focusEdgeIds: focusState.focusEdgeIds,
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -8,12 +8,16 @@ export interface TransientHandoffCard {
|
|||
edgeId: string;
|
||||
sourceNodeId: string;
|
||||
destinationNodeId: string;
|
||||
anchorNodeId: string;
|
||||
anchorKind: GraphNode['kind'];
|
||||
sourceLabel: string;
|
||||
destinationLabel: string;
|
||||
destinationKind: GraphNode['kind'];
|
||||
kind: HandoffParticleKind;
|
||||
color: string;
|
||||
preview?: string;
|
||||
relatedTaskId?: string;
|
||||
relatedTaskDisplayId?: string;
|
||||
count: number;
|
||||
activatedAt: number;
|
||||
updatedAt: number;
|
||||
|
|
@ -70,6 +74,12 @@ export function updateTransientHandoffState(
|
|||
const sourceNode = nodeMap.get(sourceNodeId);
|
||||
const destinationNode = nodeMap.get(destinationNodeId);
|
||||
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);
|
||||
if (particle.kind === 'inbox_message' && isLowSignalInboxPreview(previewText)) {
|
||||
|
|
@ -86,12 +96,16 @@ export function updateTransientHandoffState(
|
|||
edgeId: edge.id,
|
||||
sourceNodeId,
|
||||
destinationNodeId,
|
||||
anchorNodeId: anchorNode.id,
|
||||
anchorKind: anchorNode.kind,
|
||||
sourceLabel: sourceNode.label,
|
||||
destinationLabel: destinationNode.label,
|
||||
destinationKind: destinationNode.kind,
|
||||
kind: particle.kind,
|
||||
color: particle.color,
|
||||
preview: previewText ?? existing?.preview,
|
||||
relatedTaskId: edge.sourceTaskIds?.[0] ?? edge.targetTaskIds?.[0],
|
||||
relatedTaskDisplayId: buildTaskDisplayId(edge.sourceTaskIds?.[0] ?? edge.targetTaskIds?.[0]),
|
||||
count: nextCount,
|
||||
activatedAt: existing?.activatedAt ?? time,
|
||||
updatedAt: time,
|
||||
|
|
@ -112,19 +126,19 @@ export function selectRenderableTransientHandoffCards(
|
|||
const focusEdgeIds = options?.focusEdgeIds ?? null;
|
||||
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()) {
|
||||
if (hasFocus && !isCardInFocus(card, focusNodeIds, focusEdgeIds)) continue;
|
||||
const destinationCards = byDestination.get(card.destinationNodeId);
|
||||
if (destinationCards) {
|
||||
destinationCards.push(card);
|
||||
const anchorCards = byAnchor.get(card.anchorNodeId);
|
||||
if (anchorCards) {
|
||||
anchorCards.push(card);
|
||||
} else {
|
||||
byDestination.set(card.destinationNodeId, [card]);
|
||||
byAnchor.set(card.anchorNodeId, [card]);
|
||||
}
|
||||
}
|
||||
|
||||
const selected: TransientHandoffCard[] = [];
|
||||
for (const cards of byDestination.values()) {
|
||||
for (const cards of byAnchor.values()) {
|
||||
cards.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
selected.push(...cards.slice(0, HANDOFF_CARD.maxPerDestination));
|
||||
}
|
||||
|
|
@ -145,10 +159,21 @@ function isCardInFocus(
|
|||
return (
|
||||
!!focusEdgeIds?.has(card.edgeId) ||
|
||||
!!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 {
|
||||
if (!text) return undefined;
|
||||
const normalized = text
|
||||
|
|
@ -161,3 +186,10 @@ function normalizePreviewText(text: string | undefined): string | undefined {
|
|||
function isLowSignalInboxPreview(preview: string | undefined): boolean {
|
||||
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:
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.0
|
||||
pidusage:
|
||||
specifier: 4.0.1
|
||||
version: 4.0.1
|
||||
react:
|
||||
specifier: ^19.0.0
|
||||
version: 19.2.4
|
||||
|
|
@ -327,6 +330,9 @@ importers:
|
|||
'@types/node':
|
||||
specifier: ^25.0.7
|
||||
version: 25.0.7
|
||||
'@types/pidusage':
|
||||
specifier: 2.0.5
|
||||
version: 2.0.5
|
||||
'@types/react':
|
||||
specifier: ^19.0.0
|
||||
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)))
|
||||
'@vueuse/nuxt':
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
specifier: ^3.0.4
|
||||
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
|
||||
'@nuxt/eslint':
|
||||
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:
|
||||
specifier: ^9.39.2
|
||||
version: 9.39.2(jiti@2.6.1)
|
||||
|
|
@ -494,7 +500,7 @@ importers:
|
|||
version: 1.98.0
|
||||
vite-plugin-vuetify:
|
||||
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:
|
||||
dependencies:
|
||||
|
|
@ -4353,6 +4359,9 @@ packages:
|
|||
'@types/pg@8.15.6':
|
||||
resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==}
|
||||
|
||||
'@types/pidusage@2.0.5':
|
||||
resolution: {integrity: sha512-MIiyZI4/MK9UGUXWt0jJcCZhVw7YdhBuTOuqP/BjuLDLZ2PmmViMIQgZiWxtaMicQfAz/kMrZ5T7PKxFSkTeUA==}
|
||||
|
||||
'@types/plist@3.0.5':
|
||||
resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==}
|
||||
|
||||
|
|
@ -8591,6 +8600,10 @@ packages:
|
|||
engines: {node: '>=0.10'}
|
||||
hasBin: true
|
||||
|
||||
pidusage@4.0.1:
|
||||
resolution: {integrity: sha512-yCH2dtLHfEBnzlHUJymR/Z1nN2ePG3m392Mv8TFlTP1B0xkpMQNHAnfkY0n2tAi6ceKO6YWhxYfZ96V4vVkh/g==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
pify@2.3.0:
|
||||
resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
|
@ -12589,20 +12602,20 @@ snapshots:
|
|||
|
||||
'@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:
|
||||
'@nuxt/kit': 3.21.2(magicast@0.5.2)
|
||||
'@nuxt/schema': 3.21.2
|
||||
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:
|
||||
- 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:
|
||||
'@nuxt/kit': 4.4.2(magicast@0.5.2)
|
||||
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:
|
||||
- magicast
|
||||
|
||||
|
|
@ -12617,9 +12630,9 @@ snapshots:
|
|||
pkg-types: 2.3.0
|
||||
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:
|
||||
'@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/kit': 4.4.2(magicast@0.5.2)
|
||||
'@vue/devtools-core': 8.1.0(vue@3.5.30(typescript@5.9.3))
|
||||
|
|
@ -12647,9 +12660,9 @@ snapshots:
|
|||
sirv: 3.0.2
|
||||
structured-clone-es: 2.0.0
|
||||
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-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-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: 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@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))
|
||||
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
|
||||
ws: 8.20.0
|
||||
transitivePeerDependencies:
|
||||
|
|
@ -12698,10 +12711,10 @@ snapshots:
|
|||
- supports-color
|
||||
- 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:
|
||||
'@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-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)
|
||||
|
|
@ -12777,7 +12790,7 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- 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:
|
||||
'@nuxt/devalue': 2.0.2
|
||||
'@nuxt/kit': 3.21.2(magicast@0.5.2)
|
||||
|
|
@ -12795,7 +12808,7 @@ snapshots:
|
|||
klona: 2.0.6
|
||||
mocked-exports: 0.1.1
|
||||
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
|
||||
pathe: 2.0.3
|
||||
pkg-types: 2.3.0
|
||||
|
|
@ -12860,7 +12873,7 @@ snapshots:
|
|||
rc9: 3.0.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:
|
||||
'@nuxt/kit': 3.21.2(magicast@0.5.2)
|
||||
'@rollup/plugin-replace': 6.0.3(rollup@4.60.0)
|
||||
|
|
@ -12879,7 +12892,7 @@ snapshots:
|
|||
magic-string: 0.30.21
|
||||
mlly: 1.8.2
|
||||
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
|
||||
ohash: 2.0.11
|
||||
pathe: 2.0.3
|
||||
|
|
@ -15002,6 +15015,8 @@ snapshots:
|
|||
pg-protocol: 1.13.0
|
||||
pg-types: 2.2.0
|
||||
|
||||
'@types/pidusage@2.0.5': {}
|
||||
|
||||
'@types/plist@3.0.5':
|
||||
dependencies:
|
||||
'@types/node': 25.0.7
|
||||
|
|
@ -15595,13 +15610,13 @@ snapshots:
|
|||
|
||||
'@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:
|
||||
'@nuxt/kit': 3.21.2(magicast@0.5.2)
|
||||
'@vueuse/core': 10.11.1(vue@3.5.30(typescript@5.9.3))
|
||||
'@vueuse/metadata': 10.11.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))
|
||||
transitivePeerDependencies:
|
||||
- '@vue/composition-api'
|
||||
|
|
@ -20143,27 +20158,27 @@ snapshots:
|
|||
dependencies:
|
||||
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:
|
||||
'@iconify/collections': 1.0.665
|
||||
'@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)
|
||||
transitivePeerDependencies:
|
||||
- magicast
|
||||
- vite
|
||||
- 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:
|
||||
'@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/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/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/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))
|
||||
'@vue/shared': 3.5.30
|
||||
c12: 3.3.3(magicast@0.5.2)
|
||||
|
|
@ -20647,6 +20662,10 @@ snapshots:
|
|||
|
||||
pidtree@0.6.0: {}
|
||||
|
||||
pidusage@4.0.1:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
pify@2.3.0: {}
|
||||
|
||||
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
|
||||
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:
|
||||
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-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: 5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0)
|
||||
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:
|
||||
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):
|
||||
dependencies:
|
||||
|
|
@ -22792,7 +22811,7 @@ snapshots:
|
|||
optionator: 0.9.4
|
||||
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:
|
||||
ansis: 4.2.0
|
||||
debug: 4.4.3
|
||||
|
|
@ -22802,29 +22821,29 @@ snapshots:
|
|||
perfect-debounce: 2.1.0
|
||||
sirv: 3.0.2
|
||||
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-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: 5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0)
|
||||
vite-dev-rpc: 1.1.0(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))
|
||||
optionalDependencies:
|
||||
'@nuxt/kit': 4.4.2(magicast@0.5.2)
|
||||
transitivePeerDependencies:
|
||||
- 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:
|
||||
estree-walker: 3.0.3
|
||||
exsolve: 1.0.8
|
||||
magic-string: 0.30.21
|
||||
pathe: 2.0.3
|
||||
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)
|
||||
|
||||
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:
|
||||
'@vuetify/loader-shared': 2.1.2(vue@3.5.30(typescript@5.9.3))(vuetify@3.12.3)
|
||||
debug: 4.4.3
|
||||
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)
|
||||
vuetify: 3.12.3(typescript@5.9.3)(vite-plugin-vuetify@2.1.3)(vue@3.5.30(typescript@5.9.3))
|
||||
transitivePeerDependencies:
|
||||
|
|
@ -23023,7 +23042,7 @@ snapshots:
|
|||
vue: 3.5.30(typescript@5.9.3)
|
||||
optionalDependencies:
|
||||
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: {}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
* reusable package port model. Renderer hooks may still read store state, but
|
||||
|
|
@ -57,12 +57,18 @@ import type {
|
|||
LeadActivityState,
|
||||
MemberSpawnStatusEntry,
|
||||
MemberSpawnStatusesSnapshot,
|
||||
TeamData,
|
||||
ResolvedTeamMember,
|
||||
TeamProcess,
|
||||
TeamProvisioningProgress,
|
||||
TeamViewSnapshot,
|
||||
} from '@shared/types/team';
|
||||
import type { LeadContextUsage } from '@shared/types/team';
|
||||
|
||||
export interface TeamGraphData extends TeamViewSnapshot {
|
||||
members: ResolvedTeamMember[];
|
||||
messageFeed: InboxMessage[];
|
||||
}
|
||||
|
||||
export class TeamGraphAdapter {
|
||||
// ─── ES #private fields ──────────────────────────────────────────────────
|
||||
#lastTeamName = '';
|
||||
|
|
@ -89,7 +95,7 @@ export class TeamGraphAdapter {
|
|||
* Adapt team data into a GraphDataPort snapshot.
|
||||
*/
|
||||
adapt(
|
||||
teamData: TeamData | null,
|
||||
teamData: TeamGraphData | null,
|
||||
teamName: string,
|
||||
spawnStatuses?: Record<string, MemberSpawnStatusEntry>,
|
||||
leadActivity?: LeadActivityState,
|
||||
|
|
@ -175,13 +181,22 @@ export class TeamGraphAdapter {
|
|||
isTeamProvisioning,
|
||||
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.#attachActivityFeeds(nodes, teamData, teamName, leadId, leadName);
|
||||
this.#buildMessageParticles(
|
||||
particles,
|
||||
nodes,
|
||||
teamData.messages,
|
||||
teamData.messageFeed,
|
||||
teamName,
|
||||
leadId,
|
||||
leadName,
|
||||
|
|
@ -224,11 +239,11 @@ export class TeamGraphAdapter {
|
|||
|
||||
// ─── Private: node builders ──────────────────────────────────────────────
|
||||
|
||||
static #getLeadMemberName(data: TeamData, teamName: string): string {
|
||||
static #getLeadMemberName(data: TeamGraphData, teamName: string): string {
|
||||
return getGraphLeadMemberName(data, teamName);
|
||||
}
|
||||
|
||||
static #buildMemberNodeIdByAlias(data: TeamData, teamName: string): Map<string, string> {
|
||||
static #buildMemberNodeIdByAlias(data: TeamGraphData, teamName: string): Map<string, string> {
|
||||
return buildGraphMemberNodeIdAliasMap(
|
||||
teamName,
|
||||
data.members.filter((member) => !isLeadMember(member))
|
||||
|
|
@ -236,7 +251,7 @@ export class TeamGraphAdapter {
|
|||
}
|
||||
|
||||
static #buildLayoutPort(
|
||||
data: TeamData,
|
||||
data: TeamGraphData,
|
||||
teamName: string,
|
||||
slotAssignments?: Record<string, GraphOwnerSlotAssignment>
|
||||
): GraphLayoutPort {
|
||||
|
|
@ -254,7 +269,7 @@ export class TeamGraphAdapter {
|
|||
);
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -306,7 +321,7 @@ export class TeamGraphAdapter {
|
|||
}
|
||||
|
||||
static #collectDuplicateStableOwnerIds(
|
||||
members: readonly TeamData['members'][number][]
|
||||
members: readonly TeamGraphData['members'][number][]
|
||||
): string[] {
|
||||
const counts = new Map<string, number>();
|
||||
for (const member of members) {
|
||||
|
|
@ -328,9 +343,9 @@ export class TeamGraphAdapter {
|
|||
}
|
||||
|
||||
static #getRuntimeLabel(
|
||||
providerId: TeamData['members'][number]['providerId'],
|
||||
model: TeamData['members'][number]['model'],
|
||||
effort: TeamData['members'][number]['effort']
|
||||
providerId: ResolvedTeamMember['providerId'],
|
||||
model: ResolvedTeamMember['model'],
|
||||
effort: ResolvedTeamMember['effort']
|
||||
): string | undefined {
|
||||
return formatTeamRuntimeSummary(providerId, model, effort);
|
||||
}
|
||||
|
|
@ -351,7 +366,7 @@ export class TeamGraphAdapter {
|
|||
#buildLeadNode(
|
||||
nodes: GraphNode[],
|
||||
leadId: string,
|
||||
data: TeamData,
|
||||
data: TeamGraphData,
|
||||
teamName: string,
|
||||
leadName: string,
|
||||
pendingApprovalAgents?: Set<string>,
|
||||
|
|
@ -362,7 +377,7 @@ export class TeamGraphAdapter {
|
|||
toolHistory?: Record<string, ActiveToolCall[]>,
|
||||
isTeamProvisioning = false
|
||||
): void {
|
||||
const percent = leadContext?.percent;
|
||||
const percent = leadContext?.contextUsedPercent;
|
||||
const leadMember = data.members.find((member) => member.name === leadName);
|
||||
const activeTool = TeamGraphAdapter.#selectVisibleTool(
|
||||
activeTools?.[leadName],
|
||||
|
|
@ -447,7 +462,7 @@ export class TeamGraphAdapter {
|
|||
nodes: GraphNode[],
|
||||
edges: GraphEdge[],
|
||||
leadId: string,
|
||||
data: TeamData,
|
||||
data: TeamGraphData,
|
||||
teamName: string,
|
||||
memberNodeIdByAlias: ReadonlyMap<string, string>,
|
||||
spawnStatuses?: Record<string, MemberSpawnStatusEntry>,
|
||||
|
|
@ -551,12 +566,14 @@ export class TeamGraphAdapter {
|
|||
#buildTaskNodes(
|
||||
nodes: GraphNode[],
|
||||
edges: GraphEdge[],
|
||||
data: TeamData,
|
||||
data: TeamGraphData,
|
||||
teamName: string,
|
||||
commentReadState?: Record<string, unknown>,
|
||||
memberNodeIdByAlias?: ReadonlyMap<string, string>
|
||||
memberNodeIdByAlias?: ReadonlyMap<string, string>,
|
||||
leadId?: string,
|
||||
leadName?: string
|
||||
): 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 memberColorByName = new Map<string, string>();
|
||||
|
||||
|
|
@ -575,7 +592,12 @@ export class TeamGraphAdapter {
|
|||
for (const task of data.tasks) {
|
||||
if (task.status === 'deleted') continue;
|
||||
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 reviewerName = resolveTaskReviewer(task, kanbanTaskState);
|
||||
const isReviewCycle = isTaskInReviewCycle(task);
|
||||
|
|
@ -736,7 +758,7 @@ export class TeamGraphAdapter {
|
|||
#buildProcessNodes(
|
||||
nodes: GraphNode[],
|
||||
edges: GraphEdge[],
|
||||
data: TeamData,
|
||||
data: TeamGraphData,
|
||||
teamName: string,
|
||||
memberNodeIdByAlias?: ReadonlyMap<string, string>
|
||||
): void {
|
||||
|
|
@ -814,7 +836,7 @@ export class TeamGraphAdapter {
|
|||
|
||||
#attachActivityFeeds(
|
||||
nodes: GraphNode[],
|
||||
data: TeamData,
|
||||
data: TeamGraphData,
|
||||
teamName: string,
|
||||
leadId: string,
|
||||
leadName: string
|
||||
|
|
@ -831,7 +853,10 @@ export class TeamGraphAdapter {
|
|||
}
|
||||
|
||||
const entriesByOwnerNodeId = buildInlineActivityEntries({
|
||||
data,
|
||||
data: {
|
||||
...data,
|
||||
messages: data.messageFeed,
|
||||
},
|
||||
teamName,
|
||||
leadId,
|
||||
leadName,
|
||||
|
|
@ -992,7 +1017,7 @@ export class TeamGraphAdapter {
|
|||
|
||||
#buildCommentParticles(
|
||||
particles: GraphParticle[],
|
||||
data: TeamData,
|
||||
data: TeamGraphData,
|
||||
teamName: string,
|
||||
leadId: string,
|
||||
leadName: string,
|
||||
|
|
@ -1085,8 +1110,8 @@ export class TeamGraphAdapter {
|
|||
}
|
||||
|
||||
static #buildMemberException(
|
||||
runtimeAdvisory: TeamData['members'][number]['runtimeAdvisory'],
|
||||
providerId: TeamData['members'][number]['providerId'],
|
||||
runtimeAdvisory: ResolvedTeamMember['runtimeAdvisory'],
|
||||
providerId: ResolvedTeamMember['providerId'],
|
||||
spawn: MemberSpawnStatusEntry | undefined,
|
||||
pendingApproval: boolean
|
||||
): Pick<GraphNode, 'exceptionTone' | 'exceptionLabel'> | undefined {
|
||||
|
|
@ -1228,6 +1253,25 @@ export class TeamGraphAdapter {
|
|||
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" */
|
||||
static #extractExternalTeamName(from: string): string | null {
|
||||
const dotIdx = from.indexOf('.');
|
||||
|
|
|
|||
|
|
@ -1,17 +1,34 @@
|
|||
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 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): {
|
||||
teamData: TeamData | null;
|
||||
teamData: TeamGraphData | null;
|
||||
teams: TeamSummary[];
|
||||
} {
|
||||
return useStore(
|
||||
useShallow((state) => ({
|
||||
teamData: selectTeamDataForName(state, teamName),
|
||||
teams: state.teams,
|
||||
}))
|
||||
useShallow((state) => {
|
||||
const snapshot = selectTeamDataForName(state, teamName);
|
||||
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 { CreateTaskDialog } from '@renderer/components/team/dialogs/CreateTaskDialog';
|
||||
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 type { TaskRef } from '@shared/types';
|
||||
|
|
@ -25,19 +29,17 @@ export function useGraphCreateTaskDialog(teamName: string): UseGraphCreateTaskDi
|
|||
});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const { teamData, createTeamTask, isTeamProvisioning } = useStore(
|
||||
const { teamData, activeMembers, createTeamTask, isTeamProvisioning } = useStore(
|
||||
useShallow((state) => ({
|
||||
teamData: selectTeamDataForName(state, teamName),
|
||||
activeMembers: selectResolvedMembersForTeamName(state, teamName).filter(
|
||||
(member) => !member.removedAt
|
||||
),
|
||||
createTeamTask: state.createTeamTask,
|
||||
isTeamProvisioning: isTeamProvisioningActive(state, teamName),
|
||||
}))
|
||||
);
|
||||
|
||||
const activeMembers = useMemo(
|
||||
() => (teamData?.members ?? []).filter((member) => !member.removedAt),
|
||||
[teamData?.members]
|
||||
);
|
||||
|
||||
const openCreateTaskDialog = useCallback((owner = ''): void => {
|
||||
setDialogState({
|
||||
open: true,
|
||||
|
|
|
|||
|
|
@ -1,19 +1,34 @@
|
|||
import { useStore } from '@renderer/store';
|
||||
import {
|
||||
getCurrentProvisioningProgressForTeam,
|
||||
selectResolvedMembersForTeamName,
|
||||
selectTeamDataForName,
|
||||
} from '@renderer/store/slices/teamSlice';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import type { TeamGraphData } from '../adapters/TeamGraphAdapter';
|
||||
|
||||
export function useGraphMemberPopoverContext(teamName: string, memberName: string) {
|
||||
return useStore(
|
||||
useShallow((state) => ({
|
||||
teamData: teamName ? selectTeamDataForName(state, teamName) : null,
|
||||
spawnEntry: teamName ? state.memberSpawnStatusesByTeam[teamName]?.[memberName] : undefined,
|
||||
leadActivity: teamName ? state.leadActivityByTeam[teamName] : undefined,
|
||||
progress: teamName ? getCurrentProvisioningProgressForTeam(state, teamName) : null,
|
||||
memberSpawnSnapshot: teamName ? state.memberSpawnSnapshotsByTeam[teamName] : undefined,
|
||||
memberSpawnStatuses: teamName ? state.memberSpawnStatusesByTeam[teamName] : undefined,
|
||||
}))
|
||||
useShallow((state) => {
|
||||
const snapshot = teamName ? selectTeamDataForName(state, teamName) : null;
|
||||
const teamMembers = teamName ? selectResolvedMembersForTeamName(state, teamName) : [];
|
||||
|
||||
return {
|
||||
teamData: snapshot
|
||||
? {
|
||||
...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 {
|
||||
getCurrentProvisioningProgressForTeam,
|
||||
isTeamGraphSlotPersistenceDisabled,
|
||||
selectResolvedMembersForTeamName,
|
||||
selectTeamDataForName,
|
||||
selectTeamMessages,
|
||||
} from '@renderer/store/slices/teamSlice';
|
||||
import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { TeamGraphAdapter } from '../adapters/TeamGraphAdapter';
|
||||
|
||||
import type { TeamGraphData } from '../adapters/TeamGraphAdapter';
|
||||
import type { GraphDataPort } from '@claude-teams/agent-graph';
|
||||
|
||||
export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
||||
const adapterRef = useRef<TeamGraphAdapter>(TeamGraphAdapter.create());
|
||||
|
||||
const {
|
||||
teamData,
|
||||
teamSnapshot,
|
||||
members,
|
||||
messages,
|
||||
spawnStatuses,
|
||||
leadActivity,
|
||||
leadContext,
|
||||
|
|
@ -38,7 +43,9 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
|||
ensureTeamGraphSlotAssignments,
|
||||
} = useStore(
|
||||
useShallow((s) => ({
|
||||
teamData: selectTeamDataForName(s, teamName),
|
||||
teamSnapshot: selectTeamDataForName(s, teamName),
|
||||
members: selectResolvedMembersForTeamName(s, teamName),
|
||||
messages: selectTeamMessages(s, teamName),
|
||||
spawnStatuses: teamName ? s.memberSpawnStatusesByTeam[teamName] : undefined,
|
||||
leadActivity: teamName ? s.leadActivityByTeam[teamName] : undefined,
|
||||
leadContext: teamName ? s.leadContextByTeam[teamName] : undefined,
|
||||
|
|
@ -64,6 +71,17 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
|||
return agents;
|
||||
}, [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 effectiveSlotAssignments = useMemo(() => {
|
||||
|
|
@ -97,9 +115,7 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
|||
const currentAssignment = slotAssignments[stableOwnerId];
|
||||
const defaultAssignment = defaultSeed.assignments[stableOwnerId];
|
||||
return (
|
||||
currentAssignment &&
|
||||
defaultAssignment &&
|
||||
currentAssignment.ringIndex === defaultAssignment.ringIndex &&
|
||||
currentAssignment?.ringIndex === defaultAssignment?.ringIndex &&
|
||||
currentAssignment.sectorIndex === defaultAssignment.sectorIndex
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useCallback } from 'react';
|
||||
|
||||
import { useStore } from '@renderer/store';
|
||||
import { isTeamGraphSlotPersistenceDisabled } from '@renderer/store/slices/teamSlice';
|
||||
|
||||
import { parseGraphMemberNodeId } from '../../core/domain/graphOwnerIdentity';
|
||||
|
||||
|
|
@ -8,6 +9,7 @@ import type { GraphOwnerSlotAssignment } from '@claude-teams/agent-graph';
|
|||
|
||||
export function useTeamGraphSurfaceActions(teamName: string): {
|
||||
openTeamPage: () => void;
|
||||
resetOwnerSlotAssignmentsToDefaults: () => void;
|
||||
commitOwnerSlotDrop: (payload: {
|
||||
nodeId: string;
|
||||
assignment: GraphOwnerSlotAssignment;
|
||||
|
|
@ -19,6 +21,13 @@ export function useTeamGraphSurfaceActions(teamName: string): {
|
|||
useStore.getState().openTeamTab(teamName);
|
||||
}, [teamName]);
|
||||
|
||||
const resetOwnerSlotAssignmentsToDefaults = useCallback(() => {
|
||||
if (!isTeamGraphSlotPersistenceDisabled()) {
|
||||
return;
|
||||
}
|
||||
useStore.getState().resetTeamGraphSlotAssignmentsToDefaults(teamName);
|
||||
}, [teamName]);
|
||||
|
||||
const commitOwnerSlotDrop = useCallback(
|
||||
(payload: {
|
||||
nodeId: string;
|
||||
|
|
@ -51,6 +60,7 @@ export function useTeamGraphSurfaceActions(teamName: string): {
|
|||
|
||||
return {
|
||||
openTeamPage,
|
||||
resetOwnerSlotAssignmentsToDefaults,
|
||||
commitOwnerSlotDrop,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
* into ui/, hooks/, or core/ directly.
|
||||
*/
|
||||
|
||||
export type { InlineActivityEntry } from '../core/domain/buildInlineActivityEntries';
|
||||
export { buildInlineActivityEntries } from '../core/domain/buildInlineActivityEntries';
|
||||
export { buildGraphMemberNodeIdForMember } from '../core/domain/graphOwnerIdentity';
|
||||
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 { ACTIVITY_LANE } from '@claude-teams/agent-graph';
|
||||
import { ActivityItem } from '@renderer/components/team/activity/ActivityItem';
|
||||
import {
|
||||
buildMessageContext,
|
||||
resolveMessageRenderProps,
|
||||
} from '@renderer/components/team/activity/activityMessageContext';
|
||||
import { buildMessageContext } from '@renderer/components/team/activity/activityMessageContext';
|
||||
import { MessageExpandDialog } from '@renderer/components/team/activity/MessageExpandDialog';
|
||||
import { useStableTeamMentionMeta } from '@renderer/hooks/useStableTeamMentionMeta';
|
||||
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
|
||||
|
|
@ -18,6 +14,8 @@ import {
|
|||
} from '../../core/domain/buildInlineActivityEntries';
|
||||
import { useGraphActivityContext } from '../hooks/useGraphActivityContext';
|
||||
|
||||
import { GraphActivityCard } from './GraphActivityCard';
|
||||
|
||||
import type { GraphNode } from '@claude-teams/agent-graph';
|
||||
import type { TimelineItem } from '@renderer/components/team/activity/LeadThoughtsGroup';
|
||||
import type {
|
||||
|
|
@ -77,6 +75,9 @@ export const GraphActivityHud = ({
|
|||
const connectorPathRefs = useRef(new Map<string, SVGPathElement | null>());
|
||||
const [expandedItem, setExpandedItem] = useState<TimelineItem | null>(null);
|
||||
const { teamData, teams } = useGraphActivityContext(teamName);
|
||||
const teamSnapshot = teamData;
|
||||
const members = teamData?.members ?? [];
|
||||
const messages = teamData?.messageFeed ?? [];
|
||||
|
||||
const ownerNodes = useMemo(
|
||||
() =>
|
||||
|
|
@ -87,21 +88,27 @@ export const GraphActivityHud = ({
|
|||
[nodes]
|
||||
);
|
||||
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 entryMapByOwnerNodeId = useMemo(() => {
|
||||
if (!teamData) {
|
||||
if (!teamSnapshot) {
|
||||
return new Map<string, InlineActivityEntry[]>();
|
||||
}
|
||||
return buildInlineActivityEntries({
|
||||
data: teamData,
|
||||
data: {
|
||||
members,
|
||||
tasks: teamSnapshot.tasks,
|
||||
messages,
|
||||
},
|
||||
teamName,
|
||||
leadId: leadNodeId,
|
||||
leadName,
|
||||
ownerNodeIds,
|
||||
});
|
||||
}, [leadName, leadNodeId, ownerNodeIds, teamData, teamName]);
|
||||
const messageContext = useMemo(() => buildMessageContext(teamData?.members), [teamData?.members]);
|
||||
}, [leadName, leadNodeId, members, messages, ownerNodeIds, teamName, teamSnapshot]);
|
||||
const messageContext = useMemo(() => buildMessageContext(members), [members]);
|
||||
const { teamNames, teamColorByName } = useStableTeamMentionMeta(teams);
|
||||
const { readSet } = useTeamMessagesRead(teamName);
|
||||
|
||||
|
|
@ -279,38 +286,10 @@ export const GraphActivityHud = ({
|
|||
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) => {
|
||||
setExpandedItem(item);
|
||||
}, []);
|
||||
|
||||
const handleMemberNameClick = useCallback(
|
||||
(memberName: string) => {
|
||||
onOpenMemberProfile?.(memberName);
|
||||
},
|
||||
[onOpenMemberProfile]
|
||||
);
|
||||
|
||||
const handleMemberClick = useCallback(
|
||||
(member: ResolvedTeamMember) => {
|
||||
onOpenMemberProfile?.(member.name);
|
||||
|
|
@ -381,7 +360,7 @@ export const GraphActivityHud = ({
|
|||
};
|
||||
}, [enabled, forwardWheelToGraph, visibleLanes]);
|
||||
|
||||
if (!enabled || !teamData || visibleLanes.length === 0) {
|
||||
if (!enabled || !teamSnapshot || visibleLanes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -444,10 +423,6 @@ export const GraphActivityHud = ({
|
|||
) : null}
|
||||
{lane.entries.map((entry, index) => {
|
||||
const messageKey = toMessageKey(entry.message);
|
||||
const renderProps = resolveMessageRenderProps(
|
||||
entry.message,
|
||||
messageContext
|
||||
);
|
||||
const timelineItem: TimelineItem = {
|
||||
type: 'message',
|
||||
message: entry.message,
|
||||
|
|
@ -468,26 +443,17 @@ export const GraphActivityHud = ({
|
|||
}
|
||||
}}
|
||||
>
|
||||
<ActivityItem
|
||||
<GraphActivityCard
|
||||
message={entry.message}
|
||||
teamName={teamName}
|
||||
compactHeader
|
||||
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}
|
||||
messageContext={messageContext}
|
||||
teamNames={teamNames}
|
||||
teamColorByName={teamColorByName}
|
||||
isUnread={isUnread}
|
||||
zebraShade={index % 2 === 1}
|
||||
onClick={() => handleMessageClick(timelineItem)}
|
||||
onOpenTaskDetail={onOpenTaskDetail}
|
||||
onOpenMemberProfile={onOpenMemberProfile}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -521,7 +487,7 @@ export const GraphActivityHud = ({
|
|||
}
|
||||
}}
|
||||
teamName={teamName}
|
||||
members={teamData.members}
|
||||
members={members}
|
||||
onMemberClick={handleMemberClick}
|
||||
onTaskIdClick={onOpenTaskDetail}
|
||||
teamNames={teamNames}
|
||||
|
|
|
|||
|
|
@ -292,14 +292,21 @@ const MemberPopoverContent = ({
|
|||
? node.domainRef.teamName
|
||||
: '';
|
||||
const avatarSrc = node.avatarUrl ?? agentAvatarUrl(memberName, 64);
|
||||
const { teamData, spawnEntry, leadActivity, progress, memberSpawnSnapshot, memberSpawnStatuses } =
|
||||
useGraphMemberPopoverContext(teamName, memberName);
|
||||
const member = teamData?.members.find((candidate) => candidate.name === memberName) ?? null;
|
||||
const {
|
||||
teamData,
|
||||
teamMembers,
|
||||
spawnEntry,
|
||||
leadActivity,
|
||||
progress,
|
||||
memberSpawnSnapshot,
|
||||
memberSpawnStatuses,
|
||||
} = useGraphMemberPopoverContext(teamName, memberName);
|
||||
const member = teamMembers.find((candidate) => candidate.name === memberName) ?? null;
|
||||
const provisioningPresentation =
|
||||
teamData && teamName
|
||||
? buildTeamProvisioningPresentation({
|
||||
progress,
|
||||
members: teamData.members,
|
||||
members: teamMembers,
|
||||
memberSpawnStatuses,
|
||||
memberSpawnSnapshot,
|
||||
})
|
||||
|
|
@ -425,7 +432,7 @@ const MemberPopoverContent = ({
|
|||
)}
|
||||
</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 */}
|
||||
{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 { GraphNodePopover } from './GraphNodePopover';
|
||||
import { GraphProvisioningHud } from './GraphProvisioningHud';
|
||||
import { GraphTransientHandoffHud } from './GraphTransientHandoffHud';
|
||||
|
||||
import type { GraphDomainRef, GraphEventPort } from '@claude-teams/agent-graph';
|
||||
import type {
|
||||
|
|
@ -88,7 +89,6 @@ export const TeamGraphOverlay = ({
|
|||
const openCreateTask = useCallback(() => {
|
||||
openCreateTaskDialog('');
|
||||
}, [openCreateTaskDialog]);
|
||||
|
||||
const events: GraphEventPort = {
|
||||
onNodeDoubleClick: useCallback(
|
||||
(ref: GraphDomainRef) => {
|
||||
|
|
@ -141,13 +141,30 @@ export const TeamGraphOverlay = ({
|
|||
height: number;
|
||||
} | null;
|
||||
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 };
|
||||
getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null;
|
||||
focusEdgeIds?: ReadonlySet<string> | null;
|
||||
};
|
||||
const { getViewportSize, focusNodeIds } = extraHudProps;
|
||||
|
||||
return (
|
||||
<>
|
||||
<GraphTransientHandoffHud
|
||||
teamName={teamName}
|
||||
getTransientHandoffSnapshot={extraHudProps.getTransientHandoffSnapshot}
|
||||
getCameraZoom={extraHudProps.getCameraZoom}
|
||||
worldToScreen={extraHudProps.worldToScreen}
|
||||
getNodeWorldPosition={extraHudProps.getNodeWorldPosition}
|
||||
focusNodeIds={focusNodeIds}
|
||||
focusEdgeIds={extraHudProps.focusEdgeIds ?? null}
|
||||
/>
|
||||
<GraphActivityHud
|
||||
teamName={teamName}
|
||||
nodes={graphData.nodes}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { GraphActivityHud } from './GraphActivityHud';
|
|||
import { GraphBlockingEdgePopover } from './GraphBlockingEdgePopover';
|
||||
import { GraphNodePopover } from './GraphNodePopover';
|
||||
import { GraphProvisioningHud } from './GraphProvisioningHud';
|
||||
import { GraphTransientHandoffHud } from './GraphTransientHandoffHud';
|
||||
|
||||
import type { GraphDomainRef, GraphEventPort } from '@claude-teams/agent-graph';
|
||||
import type {
|
||||
|
|
@ -78,7 +79,6 @@ export const TeamGraphTab = ({
|
|||
const openCreateTask = useCallback(() => {
|
||||
openCreateTaskDialog('');
|
||||
}, [openCreateTaskDialog]);
|
||||
|
||||
// Task action dispatchers
|
||||
const dispatchTaskAction = useCallback(
|
||||
(action: string) => (taskId: string) =>
|
||||
|
|
@ -165,13 +165,31 @@ export const TeamGraphTab = ({
|
|||
height: number;
|
||||
} | null;
|
||||
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 };
|
||||
getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null;
|
||||
focusEdgeIds?: ReadonlySet<string> | null;
|
||||
};
|
||||
const { getViewportSize, focusNodeIds } = extraHudProps;
|
||||
|
||||
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
|
||||
teamName={teamName}
|
||||
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: {
|
||||
name: 'claude-agent-teams-ui',
|
||||
title: 'Claude Agent Teams UI',
|
||||
title: 'Agent Teams UI',
|
||||
version: '0.1.0',
|
||||
},
|
||||
capabilities: {
|
||||
|
|
|
|||
|
|
@ -3,10 +3,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|||
import { type DashboardRecentProject } from '@features/recent-projects/contracts';
|
||||
import { api, isElectronMode } from '@renderer/api';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { buildTaskCountsByProject, normalizePath } from '@renderer/utils/pathNormalize';
|
||||
import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice';
|
||||
import { buildTaskCountsByProject } from '@renderer/utils/pathNormalize';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { adaptRecentProjectsSection } from '../adapters/RecentProjectsSectionAdapter';
|
||||
import { buildActiveTeamsByProject } from '../utils/activeProjectTeams';
|
||||
import {
|
||||
sortRecentProjectsByDisplayPriority,
|
||||
subscribeRecentProjectOpenHistory,
|
||||
|
|
@ -62,16 +64,27 @@ export function useRecentProjectsSection(
|
|||
openProjectPath: (projectPath: string) => Promise<void>;
|
||||
selectProjectFolder: () => Promise<void>;
|
||||
} {
|
||||
const { globalTasks, globalTasksInitialized, globalTasksLoading, fetchAllTasks, teams } =
|
||||
useStore(
|
||||
useShallow((state) => ({
|
||||
globalTasks: state.globalTasks,
|
||||
globalTasksInitialized: state.globalTasksInitialized,
|
||||
globalTasksLoading: state.globalTasksLoading,
|
||||
fetchAllTasks: state.fetchAllTasks,
|
||||
teams: state.teams,
|
||||
}))
|
||||
);
|
||||
const {
|
||||
globalTasks,
|
||||
globalTasksInitialized,
|
||||
globalTasksLoading,
|
||||
fetchAllTasks,
|
||||
teams,
|
||||
provisioningRuns,
|
||||
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 { openRecentProject, openProjectPath, selectProjectFolder } = useOpenRecentProject();
|
||||
const [recentProjects, setRecentProjects] = useState<DashboardRecentProject[]>(
|
||||
|
|
@ -92,6 +105,21 @@ export function useRecentProjectsSection(
|
|||
const recentProjectsRef = useRef<DashboardRecentProject[]>(
|
||||
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(() => {
|
||||
recentProjectsRef.current = recentProjects;
|
||||
|
|
@ -173,7 +201,7 @@ export function useRecentProjectsSection(
|
|||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [teams]);
|
||||
}, [provisioningTeamNamesKey, teams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
|
|
@ -189,25 +217,13 @@ export function useRecentProjectsSection(
|
|||
const taskCountsByProject = useMemo(() => buildTaskCountsByProject(globalTasks), [globalTasks]);
|
||||
|
||||
const activeTeamsByProject = useMemo(() => {
|
||||
const aliveSet = new Set(aliveTeams);
|
||||
const teamsByProject = new Map<string, TeamSummary[]>();
|
||||
|
||||
for (const team of teams) {
|
||||
if (!team.projectPath || !aliveSet.has(team.teamName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = normalizePath(team.projectPath);
|
||||
const existing = teamsByProject.get(key);
|
||||
if (existing) {
|
||||
existing.push(team);
|
||||
} else {
|
||||
teamsByProject.set(key, [team]);
|
||||
}
|
||||
}
|
||||
|
||||
return teamsByProject;
|
||||
}, [aliveTeams, teams]);
|
||||
return buildActiveTeamsByProject({
|
||||
teams,
|
||||
aliveTeamNames: aliveTeams,
|
||||
provisioningTeamNames,
|
||||
provisioningSnapshotByTeam,
|
||||
});
|
||||
}, [aliveTeams, provisioningSnapshotByTeam, provisioningTeamNames, teams]);
|
||||
|
||||
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));
|
||||
if (!foldedMatch || foldedMatch.exactPaths.size !== 1) {
|
||||
if (foldedMatch?.exactPaths.size !== 1) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,12 @@ export async function killTmuxPaneForCurrentPlatform(paneId: string): Promise<vo
|
|||
invalidateTmuxRuntimeStatusCache();
|
||||
}
|
||||
|
||||
export async function listTmuxPanePidsForCurrentPlatform(
|
||||
paneIds: readonly string[]
|
||||
): Promise<Map<string, number>> {
|
||||
return runtimeCommandExecutor.listPanePids(paneIds);
|
||||
}
|
||||
|
||||
export function killTmuxPaneForCurrentPlatformSync(paneId: string): void {
|
||||
runtimeCommandExecutor.killPaneSync(paneId);
|
||||
invalidateTmuxRuntimeStatusCache();
|
||||
|
|
|
|||
|
|
@ -9,4 +9,5 @@ export {
|
|||
isTmuxRuntimeReadyForCurrentPlatform,
|
||||
killTmuxPaneForCurrentPlatform,
|
||||
killTmuxPaneForCurrentPlatformSync,
|
||||
listTmuxPanePidsForCurrentPlatform,
|
||||
} 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 {
|
||||
if (process.platform === 'win32') {
|
||||
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],
|
||||
6_000
|
||||
);
|
||||
if (!result || result.exitCode !== 0 || !result.stdout.trim()) {
|
||||
if (result?.exitCode !== 0 || !result.stdout.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/**
|
||||
* Main process entry point for Claude Agent Teams UI.
|
||||
* Main process entry point for Agent Teams UI.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Initialize Electron app and main window
|
||||
|
|
@ -88,6 +88,7 @@ import {
|
|||
} from './services/extensions';
|
||||
import { startEventLoopLagMonitor } from './services/infrastructure/EventLoopLagMonitor';
|
||||
import { HttpServer } from './services/infrastructure/HttpServer';
|
||||
import { clearAutoResumeService } from './services/team/AutoResumeService';
|
||||
import {
|
||||
buildTeamControlApiBaseUrl,
|
||||
clearTeamControlApiState,
|
||||
|
|
@ -563,6 +564,13 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
|||
const teamName = row.teamName.trim();
|
||||
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 ---
|
||||
if (row.type === 'inbox') {
|
||||
if (reconcileScheduler) {
|
||||
|
|
@ -905,6 +913,12 @@ async function initializeServices(): Promise<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);
|
||||
httpServer?.broadcast('team-change', event);
|
||||
};
|
||||
|
|
@ -964,6 +978,7 @@ async function initializeServices(): Promise<void> {
|
|||
boardTaskExactLogsService,
|
||||
boardTaskExactLogDetailService,
|
||||
teammateToolTracker ?? undefined,
|
||||
teamLogSourceTracker,
|
||||
branchStatusService ?? undefined,
|
||||
{
|
||||
rewire: rewireContextEvents,
|
||||
|
|
@ -1070,6 +1085,11 @@ async function startHttpServer(
|
|||
function shutdownServices(): void {
|
||||
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.
|
||||
// This must happen before the OS closes stdin pipes (on app exit),
|
||||
// because stdin EOF triggers CLI's graceful shutdown which deletes team files.
|
||||
|
|
@ -1212,7 +1232,7 @@ function createWindow(): void {
|
|||
backgroundColor: '#1a1a1a',
|
||||
...(useNativeTitleBar ? {} : { titleBarStyle: 'hidden' as const }),
|
||||
...(isMac && { trafficLightPosition: getTrafficLightPositionForZoom(1) }),
|
||||
title: 'Claude Agent Teams UI',
|
||||
title: 'Agent Teams UI',
|
||||
});
|
||||
markRendererUnavailable(mainWindow);
|
||||
|
||||
|
|
|
|||
|
|
@ -125,6 +125,7 @@ function validateNotificationsSection(
|
|||
'notifyOnCrossTeamMessage',
|
||||
'notifyOnTeamLaunched',
|
||||
'notifyOnToolApproval',
|
||||
'autoResumeOnRateLimit',
|
||||
'statusChangeOnlySolo',
|
||||
'statusChangeStatuses',
|
||||
'triggers',
|
||||
|
|
@ -219,6 +220,12 @@ function validateNotificationsSection(
|
|||
}
|
||||
result.notifyOnToolApproval = value;
|
||||
break;
|
||||
case 'autoResumeOnRateLimit':
|
||||
if (typeof value !== 'boolean') {
|
||||
return { valid: false, error: `notifications.${key} must be a boolean` };
|
||||
}
|
||||
result.autoResumeOnRateLimit = value;
|
||||
break;
|
||||
case 'statusChangeOnlySolo':
|
||||
if (typeof value !== 'boolean') {
|
||||
return { valid: false, error: `notifications.${key} must be a boolean` };
|
||||
|
|
|
|||
|
|
@ -106,6 +106,7 @@ import type {
|
|||
ServiceContextRegistry,
|
||||
SshConnectionManager,
|
||||
TeamDataService,
|
||||
TeamLogSourceTracker,
|
||||
TeammateToolTracker,
|
||||
TeamMemberLogsFinder,
|
||||
TeamProvisioningService,
|
||||
|
|
@ -141,6 +142,7 @@ export function initializeIpcHandlers(
|
|||
boardTaskExactLogsService: BoardTaskExactLogsService,
|
||||
boardTaskExactLogDetailService: BoardTaskExactLogDetailService,
|
||||
teammateToolTracker: TeammateToolTracker | undefined,
|
||||
teamLogSourceTracker: TeamLogSourceTracker | undefined,
|
||||
branchStatusService: BranchStatusService | undefined,
|
||||
contextCallbacks: {
|
||||
rewire: (context: ServiceContext) => void;
|
||||
|
|
@ -184,6 +186,7 @@ export function initializeIpcHandlers(
|
|||
memberStatsComputer,
|
||||
teamBackupService,
|
||||
teammateToolTracker,
|
||||
teamLogSourceTracker,
|
||||
branchStatusService,
|
||||
boardTaskActivityService,
|
||||
boardTaskActivityDetailService,
|
||||
|
|
|
|||
|
|
@ -16,12 +16,14 @@ import {
|
|||
TEAM_DELETE_DRAFT,
|
||||
TEAM_DELETE_TASK_ATTACHMENT,
|
||||
TEAM_DELETE_TEAM,
|
||||
TEAM_GET_AGENT_RUNTIME,
|
||||
TEAM_GET_ALL_TASKS,
|
||||
TEAM_GET_ATTACHMENTS,
|
||||
TEAM_GET_CLAUDE_LOGS,
|
||||
TEAM_GET_DATA,
|
||||
TEAM_GET_DELETED_TASKS,
|
||||
TEAM_GET_LOGS_FOR_TASK,
|
||||
TEAM_GET_MEMBER_ACTIVITY_META,
|
||||
TEAM_GET_MEMBER_LOGS,
|
||||
TEAM_GET_MEMBER_STATS,
|
||||
TEAM_GET_MESSAGES_PAGE,
|
||||
|
|
@ -34,6 +36,7 @@ import {
|
|||
TEAM_GET_TASK_EXACT_LOG_DETAIL,
|
||||
TEAM_GET_TASK_EXACT_LOG_SUMMARIES,
|
||||
TEAM_GET_TASK_LOG_STREAM,
|
||||
TEAM_GET_TASK_LOG_STREAM_SUMMARY,
|
||||
TEAM_KILL_PROCESS,
|
||||
TEAM_LAUNCH,
|
||||
TEAM_LEAD_ACTIVITY,
|
||||
|
|
@ -50,6 +53,7 @@ import {
|
|||
TEAM_REMOVE_TASK_RELATIONSHIP,
|
||||
TEAM_REPLACE_MEMBERS,
|
||||
TEAM_REQUEST_REVIEW,
|
||||
TEAM_RESTART_MEMBER,
|
||||
TEAM_RESTORE,
|
||||
TEAM_RESTORE_TASK,
|
||||
TEAM_SAVE_TASK_ATTACHMENT,
|
||||
|
|
@ -57,6 +61,7 @@ import {
|
|||
TEAM_SET_CHANGE_PRESENCE_TRACKING,
|
||||
TEAM_SET_PROJECT_BRANCH_TRACKING,
|
||||
TEAM_SET_TASK_CLARIFICATION,
|
||||
TEAM_SET_TASK_LOG_STREAM_TRACKING,
|
||||
TEAM_SET_TOOL_ACTIVITY_TRACKING,
|
||||
TEAM_SHOW_MESSAGE_NOTIFICATION,
|
||||
TEAM_SOFT_DELETE_TASK,
|
||||
|
|
@ -92,7 +97,7 @@ import {
|
|||
parseStandaloneSlashCommand,
|
||||
} from '@shared/utils/slashCommands';
|
||||
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 path from 'path';
|
||||
|
||||
|
|
@ -103,10 +108,15 @@ import {
|
|||
buildActionModeAgentBlock,
|
||||
isAgentActionMode,
|
||||
} from '../services/team/actionModeInstructions';
|
||||
import {
|
||||
getAutoResumeService,
|
||||
initializeAutoResumeService,
|
||||
} from '../services/team/AutoResumeService';
|
||||
import {
|
||||
buildReplaceMembersDiff,
|
||||
buildReplaceMembersSummaryMessage,
|
||||
} from '../services/team/memberUpdateNotifications';
|
||||
import { mergeLiveLeadProcessMessages } from '../services/team/mergeLiveLeadProcessMessages';
|
||||
import { TeamAttachmentStore } from '../services/team/TeamAttachmentStore';
|
||||
import { TeamMembersMetaStore } from '../services/team/TeamMembersMetaStore';
|
||||
import { TeamMetaStore } from '../services/team/TeamMetaStore';
|
||||
|
|
@ -130,6 +140,7 @@ import type {
|
|||
BranchStatusService,
|
||||
MemberStatsComputer,
|
||||
TeamDataService,
|
||||
TeamLogSourceTracker,
|
||||
TeammateToolTracker,
|
||||
TeamMemberLogsFinder,
|
||||
TeamProvisioningService,
|
||||
|
|
@ -146,17 +157,16 @@ import type {
|
|||
BoardTaskExactLogDetailResult,
|
||||
BoardTaskExactLogSummariesResponse,
|
||||
BoardTaskLogStreamResponse,
|
||||
BoardTaskLogStreamSummary,
|
||||
CreateTaskRequest,
|
||||
EffortLevel,
|
||||
GlobalTask,
|
||||
IpcResult,
|
||||
KanbanColumnId,
|
||||
LeadActivitySnapshot,
|
||||
LeadContextUsage,
|
||||
LeadContextUsageSnapshot,
|
||||
MemberFullStats,
|
||||
MemberLogSummary,
|
||||
MemberSpawnStatusEntry,
|
||||
MemberSpawnStatusesSnapshot,
|
||||
MessagesPage,
|
||||
SendMessageRequest,
|
||||
|
|
@ -164,15 +174,16 @@ import type {
|
|||
TaskAttachmentMeta,
|
||||
TaskComment,
|
||||
TaskRef,
|
||||
TeamAgentRuntimeSnapshot,
|
||||
TeamClaudeLogsQuery,
|
||||
TeamClaudeLogsResponse,
|
||||
TeamConfig,
|
||||
TeamCreateConfigRequest,
|
||||
TeamCreateRequest,
|
||||
TeamCreateResponse,
|
||||
TeamData,
|
||||
TeamLaunchRequest,
|
||||
TeamLaunchResponse,
|
||||
TeamMemberActivityMeta,
|
||||
TeamMessageNotificationData,
|
||||
TeamProvisioningPrepareResult,
|
||||
TeamProvisioningProgress,
|
||||
|
|
@ -180,6 +191,7 @@ import type {
|
|||
TeamTask,
|
||||
TeamTaskStatus,
|
||||
TeamUpdateConfigRequest,
|
||||
TeamViewSnapshot,
|
||||
ToolApprovalFileContent,
|
||||
ToolApprovalSettings,
|
||||
UpdateKanbanPatch,
|
||||
|
|
@ -196,6 +208,16 @@ const logger = createLogger('IPC:teams');
|
|||
const seenRateLimitKeys = new Set<string>();
|
||||
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(
|
||||
teamName: string,
|
||||
leadName: string
|
||||
|
|
@ -301,11 +323,25 @@ const SEEN_API_ERROR_KEYS_MAX = 500;
|
|||
* and NotificationManager dedupeKey (to prevent storage duplicates).
|
||||
*/
|
||||
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,
|
||||
teamDisplayName: string,
|
||||
projectPath?: string
|
||||
projectPath?: string,
|
||||
teamIsAlive = true,
|
||||
currentLeadSessionId: string | null = null
|
||||
): void {
|
||||
const observedAt = new Date();
|
||||
const autoResumeEnabled =
|
||||
ConfigManager.getInstance().getConfig().notifications.autoResumeOnRateLimit;
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.from === 'user') continue;
|
||||
if (!isRateLimitMessage(msg.text)) continue;
|
||||
|
|
@ -313,28 +349,55 @@ function checkRateLimitMessages(
|
|||
const rawKey = msg.messageId ?? `${msg.from}:${msg.timestamp}`;
|
||||
const dedupeKey = `rate-limit:${teamName}:${rawKey}`;
|
||||
|
||||
// In-memory guard: prevents resurrection after user deletes the notification
|
||||
if (seenRateLimitKeys.has(dedupeKey)) continue;
|
||||
seenRateLimitKeys.add(dedupeKey);
|
||||
// In-memory guard: prevents resurrection after user deletes the notification.
|
||||
if (!seenRateLimitKeys.has(dedupeKey)) {
|
||||
seenRateLimitKeys.add(dedupeKey);
|
||||
|
||||
// Evict oldest entries to prevent unbounded growth
|
||||
if (seenRateLimitKeys.size > SEEN_RATE_LIMIT_KEYS_MAX) {
|
||||
const first = seenRateLimitKeys.values().next().value;
|
||||
if (first) seenRateLimitKeys.delete(first);
|
||||
// Evict oldest entries to prevent unbounded growth
|
||||
if (seenRateLimitKeys.size > SEEN_RATE_LIMIT_KEYS_MAX) {
|
||||
const first = seenRateLimitKeys.values().next().value;
|
||||
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()
|
||||
.addTeamNotification({
|
||||
teamEventType: 'rate_limit',
|
||||
// Only schedule auto-resume while a live team run currently exists.
|
||||
// Persisted history for an offline/stopped team may still contain the old
|
||||
// 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,
|
||||
teamDisplayName,
|
||||
from: msg.from,
|
||||
summary: `Rate limit: ${msg.from}`,
|
||||
body: msg.text.slice(0, 200),
|
||||
dedupeKey,
|
||||
projectPath,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
msg.text,
|
||||
observedAt,
|
||||
new Date(msg.timestamp)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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 teamProvisioningService: TeamProvisioningService | null = null;
|
||||
let teamMemberLogsFinder: TeamMemberLogsFinder | null = null;
|
||||
let memberStatsComputer: MemberStatsComputer | null = null;
|
||||
let teamBackupService: TeamBackupService | null = null;
|
||||
let teammateToolTracker: TeammateToolTracker | null = null;
|
||||
let teamLogSourceTracker: TeamLogSourceTracker | null = null;
|
||||
let branchStatusService: BranchStatusService | null = null;
|
||||
let boardTaskActivityService: BoardTaskActivityService | null = null;
|
||||
let boardTaskActivityDetailService: BoardTaskActivityDetailService | null = null;
|
||||
|
|
@ -427,6 +504,7 @@ export function initializeTeamHandlers(
|
|||
statsComputer?: MemberStatsComputer,
|
||||
backupService?: TeamBackupService,
|
||||
toolTracker?: TeammateToolTracker,
|
||||
logSourceTracker?: TeamLogSourceTracker,
|
||||
branchTracker?: BranchStatusService,
|
||||
taskActivityService?: BoardTaskActivityService,
|
||||
taskActivityDetailService?: BoardTaskActivityDetailService,
|
||||
|
|
@ -436,10 +514,12 @@ export function initializeTeamHandlers(
|
|||
): void {
|
||||
teamDataService = service;
|
||||
teamProvisioningService = provisioningService;
|
||||
initializeAutoResumeService(provisioningService);
|
||||
teamMemberLogsFinder = logsFinder ?? null;
|
||||
memberStatsComputer = statsComputer ?? null;
|
||||
teamBackupService = backupService ?? null;
|
||||
teammateToolTracker = toolTracker ?? null;
|
||||
teamLogSourceTracker = logSourceTracker ?? null;
|
||||
branchStatusService = branchTracker ?? null;
|
||||
boardTaskActivityService = taskActivityService ?? 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_SET_CHANGE_PRESENCE_TRACKING, handleSetChangePresenceTracking);
|
||||
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_GET_CLAUDE_LOGS, handleGetClaudeLogs);
|
||||
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_SEND_MESSAGE, handleSendMessage);
|
||||
ipcMain.handle(TEAM_GET_MESSAGES_PAGE, handleGetMessagesPage);
|
||||
ipcMain.handle(TEAM_GET_MEMBER_ACTIVITY_META, handleGetMemberActivityMeta);
|
||||
ipcMain.handle(TEAM_CREATE_TASK, handleCreateTask);
|
||||
ipcMain.handle(TEAM_REQUEST_REVIEW, handleRequestReview);
|
||||
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_TASK_ACTIVITY, handleGetTaskActivity);
|
||||
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_EXACT_LOG_SUMMARIES, handleGetTaskExactLogSummaries);
|
||||
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_CONTEXT, handleLeadContext);
|
||||
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_RESTORE_TASK, handleRestoreTask);
|
||||
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_SET_CHANGE_PRESENCE_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_GET_CLAUDE_LOGS);
|
||||
ipcMain.removeHandler(TEAM_PREPARE_PROVISIONING);
|
||||
|
|
@ -535,6 +621,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.removeHandler(TEAM_CANCEL_PROVISIONING);
|
||||
ipcMain.removeHandler(TEAM_SEND_MESSAGE);
|
||||
ipcMain.removeHandler(TEAM_GET_MESSAGES_PAGE);
|
||||
ipcMain.removeHandler(TEAM_GET_MEMBER_ACTIVITY_META);
|
||||
ipcMain.removeHandler(TEAM_CREATE_TASK);
|
||||
ipcMain.removeHandler(TEAM_REQUEST_REVIEW);
|
||||
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_TASK_ACTIVITY);
|
||||
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_EXACT_LOG_SUMMARIES);
|
||||
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_CONTEXT);
|
||||
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_RESTORE_TASK);
|
||||
ipcMain.removeHandler(TEAM_GET_DELETED_TASKS);
|
||||
|
|
@ -612,6 +702,13 @@ function getTeammateToolTracker(): TeammateToolTracker {
|
|||
return teammateToolTracker;
|
||||
}
|
||||
|
||||
function getTeamLogSourceTracker(): TeamLogSourceTracker {
|
||||
if (!teamLogSourceTracker) {
|
||||
throw new Error('Team log source tracker is not initialized');
|
||||
}
|
||||
return teamLogSourceTracker;
|
||||
}
|
||||
|
||||
function getBranchStatusService(): BranchStatusService {
|
||||
if (!branchStatusService) {
|
||||
throw new Error('Branch status service is not initialized');
|
||||
|
|
@ -702,14 +799,14 @@ async function handleListTeams(_event: IpcMainInvokeEvent): Promise<IpcResult<Te
|
|||
async function handleGetData(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown
|
||||
): Promise<IpcResult<TeamData>> {
|
||||
): Promise<IpcResult<TeamViewSnapshot>> {
|
||||
const validated = validateTeamName(teamName);
|
||||
if (!validated.valid) {
|
||||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||||
}
|
||||
const tn = validated.value!;
|
||||
const startedAt = Date.now();
|
||||
let data: TeamData;
|
||||
let data: TeamViewSnapshot;
|
||||
setCurrentMainOp('team:getData');
|
||||
try {
|
||||
// Prefer worker thread to keep main event loop responsive
|
||||
|
|
@ -721,9 +818,11 @@ async function handleGetData(
|
|||
logger.warn(
|
||||
`[teams:getData] worker failed, falling back: ${workerErr instanceof Error ? workerErr.message : workerErr}`
|
||||
);
|
||||
noteHeavyTeamDataWorkerFallback('teams:getData');
|
||||
data = await getTeamDataService().getTeamData(tn);
|
||||
}
|
||||
} else {
|
||||
noteHeavyTeamDataWorkerFallback('teams:getData');
|
||||
data = await getTeamDataService().getTeamData(tn);
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -759,95 +858,52 @@ async function handleGetData(
|
|||
}
|
||||
const provisioning = getTeamProvisioningService();
|
||||
const isAlive = provisioning.isTeamAlive(tn);
|
||||
const currentLeadSessionId = provisioning.getCurrentLeadSessionId(tn);
|
||||
|
||||
const displayName = data.config.name || tn;
|
||||
const projectPath = data.config.projectPath;
|
||||
|
||||
const live = provisioning.getLiveLeadProcessMessages(tn);
|
||||
const durableMessages = Array.isArray((data as { messages?: unknown }).messages)
|
||||
? ((data as { messages?: typeof live }).messages ?? [])
|
||||
: [];
|
||||
|
||||
if (live.length === 0) {
|
||||
checkRateLimitMessages(data.messages, tn, displayName, projectPath);
|
||||
checkApiErrorMessages(data.messages, tn, displayName, projectPath);
|
||||
if (durableMessages.length > 0) {
|
||||
checkRateLimitMessages(
|
||||
durableMessages,
|
||||
tn,
|
||||
displayName,
|
||||
projectPath,
|
||||
isAlive,
|
||||
currentLeadSessionId
|
||||
);
|
||||
checkApiErrorMessages(durableMessages, tn, displayName, projectPath);
|
||||
} else {
|
||||
scanTeamMessageNotifications(live, tn, displayName, projectPath);
|
||||
}
|
||||
return { success: true, data: { ...data, isAlive } };
|
||||
}
|
||||
|
||||
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)}`;
|
||||
|
||||
// Collect fingerprints only for thought-like lead messages. Include leadSessionId so a
|
||||
// repeated thought in a new session does not get collapsed into an old session's history.
|
||||
const existingTextFingerprints = new Set<string>();
|
||||
for (const msg of data.messages) {
|
||||
if (typeof msg.from !== 'string' || typeof msg.text !== 'string') continue;
|
||||
if (!isLeadThoughtLike(msg)) continue;
|
||||
existingTextFingerprints.add(getLeadThoughtFingerprint(msg));
|
||||
let merged = mergeLiveLeadProcessMessages(durableMessages, live);
|
||||
if (durableMessages.length >= 50) {
|
||||
try {
|
||||
const newestPage = await teamDataService.getMessagesPage(tn, {
|
||||
limit: 50,
|
||||
liveMessages: live,
|
||||
});
|
||||
merged = newestPage.messages;
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[teams:getData] failed to rebuild newest merged messages for ${tn}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const keyFor = (m: {
|
||||
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);
|
||||
checkRateLimitMessages(merged, tn, displayName, projectPath, isAlive, currentLeadSessionId);
|
||||
checkApiErrorMessages(merged, tn, displayName, projectPath);
|
||||
return { success: true, data: { ...data, isAlive, messages: merged } };
|
||||
return { success: true, data: { ...data, isAlive } };
|
||||
}
|
||||
|
||||
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(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown
|
||||
|
|
@ -926,6 +1004,7 @@ async function handleDeleteTeam(
|
|||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||||
}
|
||||
return wrapTeamHandler('deleteTeam', async () => {
|
||||
getAutoResumeService().cancelPendingAutoResume(validated.value!);
|
||||
getTeamProvisioningService().stopTeam(validated.value!);
|
||||
await getTeamDataService().deleteTeam(validated.value!);
|
||||
});
|
||||
|
|
@ -951,6 +1030,7 @@ async function handlePermanentlyDeleteTeam(
|
|||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||||
}
|
||||
return wrapTeamHandler('permanentlyDeleteTeam', async () => {
|
||||
getAutoResumeService().cancelPendingAutoResume(validated.value!);
|
||||
await getTeamDataService().permanentlyDeleteTeam(validated.value!);
|
||||
// Clean up app-owned data (attachments, task-attachments) that lives outside ~/.claude/
|
||||
const appData = getAppDataPath();
|
||||
|
|
@ -1724,16 +1804,89 @@ async function handleGetMessagesPage(
|
|||
return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||||
}
|
||||
const opts = (options && typeof options === 'object' ? options : {}) as {
|
||||
beforeTimestamp?: string;
|
||||
cursor?: string | null;
|
||||
limit?: number;
|
||||
};
|
||||
const limit = Math.min(Math.max(1, opts.limit ?? 50), 200);
|
||||
const beforeTimestamp =
|
||||
typeof opts.beforeTimestamp === 'string' ? opts.beforeTimestamp : undefined;
|
||||
const cursor =
|
||||
typeof opts.cursor === 'string' ? opts.cursor : opts.cursor === null ? null : undefined;
|
||||
|
||||
return wrapTeamHandler('getMessagesPage', async () => {
|
||||
const service = getTeamDataService();
|
||||
return service.getMessagesPage(vTeam.value!, { beforeTimestamp, limit });
|
||||
let page: MessagesPage;
|
||||
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(
|
||||
_event: IpcMainInvokeEvent,
|
||||
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(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown
|
||||
|
|
@ -2733,6 +2935,7 @@ async function handleStopTeam(
|
|||
}
|
||||
return wrapTeamHandler('stop', async () => {
|
||||
addMainBreadcrumb('team', 'stop', { teamName: validated.value! });
|
||||
getAutoResumeService().cancelPendingAutoResume(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.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');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ async function buildManagementCliEnvForBinary(binaryPath: string): Promise<NodeJ
|
|||
});
|
||||
return env;
|
||||
}
|
||||
|
||||
export interface ExtensionsRuntimeAdapter {
|
||||
readonly flavor: CliFlavor;
|
||||
buildManagementCliEnv(binaryPath: string): Promise<NodeJS.ProcessEnv>;
|
||||
|
|
|
|||
|
|
@ -48,7 +48,6 @@ function isSensitiveCliFlag(flag: string): boolean {
|
|||
const normalizedFlag = flag.toLowerCase().replace(/^--/, '').replace(/[-_]/g, '');
|
||||
return SENSITIVE_FLAG_NAMES.has(normalizedFlag);
|
||||
}
|
||||
|
||||
function extractJsonObject<T>(raw: string): T {
|
||||
const trimmed = raw.trim();
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -62,6 +62,12 @@ export interface NotificationConfig {
|
|||
notifyOnTeamLaunched: boolean;
|
||||
/** Whether to show native OS notifications when a tool needs user approval */
|
||||
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) */
|
||||
statusChangeOnlySolo: boolean;
|
||||
/** Which target statuses to notify about (e.g. ['in_progress', 'completed']) */
|
||||
|
|
@ -306,6 +312,7 @@ const DEFAULT_CONFIG: AppConfig = {
|
|||
notifyOnCrossTeamMessage: true,
|
||||
notifyOnTeamLaunched: true,
|
||||
notifyOnToolApproval: true,
|
||||
autoResumeOnRateLimit: false,
|
||||
statusChangeOnlySolo: false,
|
||||
statusChangeStatuses: ['in_progress', 'completed'],
|
||||
triggers: DEFAULT_TRIGGERS,
|
||||
|
|
@ -502,8 +509,55 @@ export class ConfigManager {
|
|||
|
||||
return {
|
||||
notifications: {
|
||||
...DEFAULT_CONFIG.notifications,
|
||||
...loadedNotifications,
|
||||
enabled: loadedNotifications.enabled ?? DEFAULT_CONFIG.notifications.enabled,
|
||||
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,
|
||||
},
|
||||
general: mergedGeneral,
|
||||
|
|
|
|||
|
|
@ -543,10 +543,10 @@ export class NotificationManager extends EventEmitter {
|
|||
logger.debug(`[test-notification] creating Notification (platform=${process.platform})`);
|
||||
const notification = new NotificationClass({
|
||||
title: 'Test Notification',
|
||||
...(isMac ? { subtitle: 'Claude Agent Teams UI' } : {}),
|
||||
...(isMac ? { subtitle: 'Agent Teams UI' } : {}),
|
||||
body: isMac
|
||||
? 'Notifications are working correctly!'
|
||||
: 'Claude Agent Teams UI\nNotifications are working correctly!',
|
||||
: 'Agent Teams UI\nNotifications are working correctly!',
|
||||
...(iconPath ? { icon: iconPath } : {}),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -161,7 +161,7 @@ function classifyFailedProbe(
|
|||
|
||||
export class CliProviderModelAvailabilityService {
|
||||
private readonly cache = new Map<string, ProviderModelAvailabilityCacheEntry>();
|
||||
private readonly queue: Array<() => void> = [];
|
||||
private readonly queue: (() => void)[] = [];
|
||||
private activeProbeCount = 0;
|
||||
|
||||
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 {
|
||||
encodePath,
|
||||
extractBaseDir,
|
||||
getClaudeBasePath,
|
||||
getProjectsBasePath,
|
||||
getTasksBasePath,
|
||||
getTeamsBasePath,
|
||||
} from '@main/utils/pathDecoder';
|
||||
import { getClaudeBasePath, getTasksBasePath, getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||
import { killProcessByPid } from '@main/utils/processKill';
|
||||
import {
|
||||
AGENT_BLOCK_CLOSE,
|
||||
|
|
@ -16,7 +9,7 @@ import {
|
|||
} from '@shared/constants/agentBlocks';
|
||||
import { getMemberColorByName } from '@shared/constants/memberColors';
|
||||
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 { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/utils/reviewState';
|
||||
import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands';
|
||||
|
|
@ -39,6 +32,11 @@ import {
|
|||
} from './cache/LeadSessionParseCache';
|
||||
import { atomicWriteAsync } from './atomicWrite';
|
||||
import { extractLeadSessionMessagesFromJsonl } from './leadSessionMessageExtractor';
|
||||
import { MemberActivityMetaService } from './MemberActivityMetaService';
|
||||
import {
|
||||
getLiveLeadProcessMessageKey,
|
||||
mergeLiveLeadProcessMessages,
|
||||
} from './mergeLiveLeadProcessMessages';
|
||||
import { buildTaskChangePresenceDescriptor } from './taskChangePresenceUtils';
|
||||
import { TeamConfigReader } from './TeamConfigReader';
|
||||
import { TeamInboxReader } from './TeamInboxReader';
|
||||
|
|
@ -47,11 +45,13 @@ import { TeamKanbanManager } from './TeamKanbanManager';
|
|||
import { TeamMemberResolver } from './TeamMemberResolver';
|
||||
import { TeamMemberRuntimeAdvisoryService } from './TeamMemberRuntimeAdvisoryService';
|
||||
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
||||
import { TeamMessageFeedService } from './TeamMessageFeedService';
|
||||
import { TeamMetaStore } from './TeamMetaStore';
|
||||
import { TeamSentMessagesStore } from './TeamSentMessagesStore';
|
||||
import { TeamTaskCommentNotificationJournal } from './TeamTaskCommentNotificationJournal';
|
||||
import { TeamTaskReader } from './TeamTaskReader';
|
||||
import { TeamTaskWriter } from './TeamTaskWriter';
|
||||
import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver';
|
||||
|
||||
import type { PersistedTaskChangePresenceIndex } from './cache/taskChangePresenceCacheTypes';
|
||||
import type { TaskChangePresenceRepository } from './cache/TaskChangePresenceRepository';
|
||||
|
|
@ -65,7 +65,6 @@ import type {
|
|||
KanbanColumnId,
|
||||
KanbanState,
|
||||
MessagesPage,
|
||||
ResolvedTeamMember,
|
||||
SendMessageRequest,
|
||||
SendMessageResult,
|
||||
TaskAttachmentMeta,
|
||||
|
|
@ -74,13 +73,14 @@ import type {
|
|||
TaskRef,
|
||||
TeamConfig,
|
||||
TeamCreateConfigRequest,
|
||||
TeamData,
|
||||
TeamMember,
|
||||
TeamMemberActivityMeta,
|
||||
TeamProcess,
|
||||
TeamSummary,
|
||||
TeamTask,
|
||||
TeamTaskStatus,
|
||||
TeamTaskWithKanban,
|
||||
TeamViewSnapshot,
|
||||
ToolCallMeta,
|
||||
UpdateKanbanPatch,
|
||||
} from '@shared/types';
|
||||
|
|
@ -98,6 +98,14 @@ const TASK_MAP_YIELD_EVERY = 250;
|
|||
const TASK_COMMENT_NOTIFICATION_SOURCE = 'system_notification';
|
||||
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 {
|
||||
key: string;
|
||||
messageId: string;
|
||||
|
|
@ -162,6 +170,8 @@ export class TeamDataService {
|
|||
private taskChangePresenceRepository: TaskChangePresenceRepository | null = null;
|
||||
private teamLogSourceTracker: TeamLogSourceTracker | null = null;
|
||||
private fileWatchReconcileDiagnostics = new Map<string, FileWatchReconcileDiagnostics>();
|
||||
private readonly messageFeedService: TeamMessageFeedService;
|
||||
private readonly memberActivityMetaService: MemberActivityMetaService;
|
||||
|
||||
constructor(
|
||||
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
|
||||
|
|
@ -182,8 +192,19 @@ export class TeamDataService {
|
|||
private readonly taskCommentNotificationJournal: TeamTaskCommentNotificationJournal = new TeamTaskCommentNotificationJournal(),
|
||||
private readonly teamMetaStore: TeamMetaStore = new TeamMetaStore(),
|
||||
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 {
|
||||
return this.controllerFactory(teamName);
|
||||
|
|
@ -622,7 +643,7 @@ export class TeamDataService {
|
|||
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 marks: Record<string, number> = {};
|
||||
const mark = (label: string): void => {
|
||||
|
|
@ -726,12 +747,6 @@ export class TeamDataService {
|
|||
warningText: 'Inboxes failed to load',
|
||||
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({
|
||||
label: 'metaMembers',
|
||||
createFallback: () => [],
|
||||
|
|
@ -756,40 +771,8 @@ export class TeamDataService {
|
|||
load: () => this.taskReader.getTasks(teamName),
|
||||
})
|
||||
);
|
||||
const messagesStep = runWithConcurrencyLimit(() =>
|
||||
startReadStep({
|
||||
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,
|
||||
]);
|
||||
const [tasksStepResult, inboxNamesStepResult, metaMembersStepResult, kanbanStateStepResult] =
|
||||
await Promise.all([tasksStep, inboxNamesStep, metaMembersStep, kanbanStateStep]);
|
||||
|
||||
// After parallelizing the top read phase, these marks no longer represent
|
||||
// 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.
|
||||
marks.tasks = tasksStepResult.completedAt;
|
||||
marks.inboxNames = inboxNamesStepResult.completedAt;
|
||||
marks.messages = messagesStepResult.completedAt;
|
||||
marks.leadTexts = leadTextsStepResult.completedAt;
|
||||
marks.sentMessages = sentMessagesStepResult.completedAt;
|
||||
marks.metaMembers = metaMembersStepResult.completedAt;
|
||||
marks.kanbanState = kanbanStateStepResult.completedAt;
|
||||
|
||||
if (tasksStepResult.warning) warnings.push(tasksStepResult.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 (kanbanStateStepResult.warning) warnings.push(kanbanStateStepResult.warning);
|
||||
|
||||
const tasks: TeamTask[] = tasksStepResult.value;
|
||||
const inboxNames: string[] = inboxNamesStepResult.value;
|
||||
let messages: InboxMessage[] = messagesStepResult.value;
|
||||
const leadTexts: InboxMessage[] = leadTextsStepResult.value;
|
||||
const sentMessages: InboxMessage[] = sentMessagesStepResult.value;
|
||||
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 kanbanState: KanbanState = kanbanStateStepResult.value;
|
||||
|
||||
|
|
@ -1000,8 +823,7 @@ export class TeamDataService {
|
|||
config,
|
||||
metaMembers,
|
||||
inboxNames,
|
||||
tasksWithKanban,
|
||||
messages
|
||||
tasksWithKanban
|
||||
);
|
||||
mark('resolveMembers');
|
||||
|
||||
|
|
@ -1036,30 +858,13 @@ export class TeamDataService {
|
|||
|
||||
const totalMs = Date.now() - startedAt;
|
||||
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(
|
||||
`getTeamData team=${teamName} slow total=${totalMs}ms config=${msSince('config')} tasks=${msSince('tasks')} inboxNames=${msSince(
|
||||
'inboxNames'
|
||||
)} messages=${msSince('messages')} leadTexts=${msSince('leadTexts')} sent=${msSince(
|
||||
'sentMessages'
|
||||
)} membersMeta=${msSince('metaMembers')} kanban=${msSince('kanbanState')} kanbanGc=${msSince(
|
||||
'kanbanGc'
|
||||
)} post=${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(
|
||||
)} post=${msBetween('postStart', 'attachKanban')}/loadPresenceIndex=${msBetween(
|
||||
'attachKanban',
|
||||
'loadPresenceIndex'
|
||||
)}/changePresence=${msBetween(
|
||||
|
|
@ -1088,21 +893,14 @@ export class TeamDataService {
|
|||
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 {
|
||||
teamName,
|
||||
config,
|
||||
tasks: tasksWithKanban,
|
||||
members,
|
||||
messages: cappedMessages,
|
||||
kanbanState,
|
||||
processes,
|
||||
isAlive: hasAlive,
|
||||
warnings: warnings.length > 0 ? warnings : undefined,
|
||||
};
|
||||
}
|
||||
|
|
@ -1113,106 +911,103 @@ export class TeamDataService {
|
|||
*/
|
||||
async getMessagesPage(
|
||||
teamName: string,
|
||||
options: { beforeTimestamp?: string; limit: number }
|
||||
options: { cursor?: string | null; limit: number; liveMessages?: InboxMessage[] }
|
||||
): Promise<MessagesPage> {
|
||||
const config = await this.configReader.getConfig(teamName);
|
||||
if (!config) {
|
||||
return { messages: [], nextCursor: null, hasMore: false };
|
||||
}
|
||||
const feed = await this.messageFeedService.getFeed(teamName);
|
||||
const newestDurableMessages = feed.messages;
|
||||
const durableMessageIndexByKey = new Map(
|
||||
newestDurableMessages.map((message, index) => [getLiveLeadProcessMessageKey(message), index])
|
||||
);
|
||||
let messages = newestDurableMessages;
|
||||
|
||||
// Collect all messages from the same sources as getTeamData
|
||||
let messages: InboxMessage[] = [];
|
||||
|
||||
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('|');
|
||||
if (options.cursor) {
|
||||
const [cursorTs, cursorId] = options.cursor.split('|');
|
||||
const cursorMs = Date.parse(cursorTs);
|
||||
messages = messages.filter((m) => {
|
||||
const ms = Date.parse(m.timestamp);
|
||||
if (ms < cursorMs) return true;
|
||||
if (ms > cursorMs) return false;
|
||||
// Same timestamp — use messageId tie-breaker
|
||||
if (!cursorId) return false;
|
||||
return (m.messageId ?? '').localeCompare(cursorId) > 0;
|
||||
return requireCanonicalMessageId(m).localeCompare(cursorId) > 0;
|
||||
});
|
||||
}
|
||||
|
||||
// Paginate
|
||||
const hasMore = messages.length > options.limit;
|
||||
const page = messages.slice(0, options.limit);
|
||||
const lastMsg = page[page.length - 1];
|
||||
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).
|
||||
*/
|
||||
private async enrichMemberBranches(
|
||||
members: ResolvedTeamMember[],
|
||||
members: TeamViewSnapshot['members'],
|
||||
config: TeamConfig
|
||||
): Promise<void> {
|
||||
const leadEntry = config.members?.find((member) => isLeadMember(member));
|
||||
|
|
@ -1892,7 +1687,7 @@ export class TeamDataService {
|
|||
slashCommand: slashCommandMeta,
|
||||
};
|
||||
}
|
||||
return this.getController(teamName).messages.sendMessage({
|
||||
const result = this.getController(teamName).messages.sendMessage({
|
||||
member: enrichedRequest.member,
|
||||
from: enrichedRequest.from,
|
||||
text: enrichedRequest.text,
|
||||
|
|
@ -1913,6 +1708,8 @@ export class TeamDataService {
|
|||
leadSessionId: enrichedRequest.leadSessionId,
|
||||
attachments: enrichedRequest.attachments,
|
||||
}) as SendMessageResult;
|
||||
this.invalidateMessageFeed(teamName);
|
||||
return result;
|
||||
}
|
||||
|
||||
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> {
|
||||
const { leadSessionId } = await this.resolveLeadRuntimeContext(teamName);
|
||||
this.getController(teamName).review.requestReview(taskId, {
|
||||
|
|
@ -2614,37 +2428,20 @@ export class TeamDataService {
|
|||
}
|
||||
}
|
||||
|
||||
private getLeadProjectDirCandidates(projectPath: 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>> {
|
||||
private async getLeadSessionJsonlPaths(projectDir: string): Promise<Map<string, string>> {
|
||||
const jsonlPaths = new Map<string, string>();
|
||||
for (const dirPath of this.getLeadProjectDirCandidates(projectPath)) {
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = await fs.promises.readdir(projectDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return jsonlPaths;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue;
|
||||
const sessionId = entry.name.slice(0, -'.jsonl'.length).trim();
|
||||
if (!sessionId || jsonlPaths.has(sessionId)) continue;
|
||||
jsonlPaths.set(sessionId, path.join(dirPath, entry.name));
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue;
|
||||
const sessionId = entry.name.slice(0, -'.jsonl'.length).trim();
|
||||
if (!sessionId || jsonlPaths.has(sessionId)) continue;
|
||||
jsonlPaths.set(sessionId, path.join(projectDir, entry.name));
|
||||
}
|
||||
|
||||
return jsonlPaths;
|
||||
|
|
@ -2890,17 +2687,25 @@ export class TeamDataService {
|
|||
}
|
||||
}
|
||||
|
||||
private async extractLeadSessionTexts(config: TeamConfig): Promise<InboxMessage[]> {
|
||||
if (!config.projectPath) {
|
||||
private async extractLeadSessionTexts(
|
||||
teamName: string,
|
||||
config: TeamConfig
|
||||
): Promise<InboxMessage[]> {
|
||||
const transcriptContext = await this.projectResolver.getContext(teamName);
|
||||
if (!transcriptContext) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const leadName = config.members?.find((m) => isLeadMember(m))?.name ?? 'team-lead';
|
||||
const sessionIds = this.getRecentLeadSessionIds(config);
|
||||
const leadName =
|
||||
transcriptContext.config.members?.find((m) => isLeadMember(m))?.name ?? 'team-lead';
|
||||
const knownLeadSessionIds = this.getRecentLeadSessionIds(config);
|
||||
if (knownLeadSessionIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const sessionIds = knownLeadSessionIds;
|
||||
if (sessionIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const availableJsonlPaths = await this.getLeadSessionJsonlPaths(config.projectPath);
|
||||
const availableJsonlPaths = await this.getLeadSessionJsonlPaths(transcriptContext.projectDir);
|
||||
if (availableJsonlPaths.size === 0) {
|
||||
return [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,12 @@ import { Worker } from 'node:worker_threads';
|
|||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
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 WORKER_CALL_TIMEOUT_MS = 30_000;
|
||||
|
|
@ -25,16 +30,20 @@ function makeId(): string {
|
|||
return `${Date.now()}-${crypto.randomUUID().slice(0, 12)}`;
|
||||
}
|
||||
|
||||
function resolveWorkerPath(): string | null {
|
||||
function getWorkerPathCandidates(): string[] {
|
||||
const baseDir =
|
||||
typeof __dirname === 'string' && __dirname.length > 0
|
||||
? __dirname
|
||||
: path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const candidates = [
|
||||
return [
|
||||
path.join(baseDir, '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) {
|
||||
try {
|
||||
|
|
@ -75,7 +84,9 @@ export class TeamDataWorkerClient {
|
|||
isAvailable(): boolean {
|
||||
if (!this.workerPath && !this.warnedUnavailable) {
|
||||
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;
|
||||
}
|
||||
|
|
@ -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');
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -223,8 +223,7 @@ export function createPersistedLaunchSnapshot(params: {
|
|||
for (const name of expectedMembers) {
|
||||
const member = members[name];
|
||||
if (
|
||||
member &&
|
||||
member.launchState === 'starting' &&
|
||||
member?.launchState === 'starting' &&
|
||||
!member.agentToolAccepted &&
|
||||
!member.runtimeAlive &&
|
||||
!member.bootstrapConfirmed &&
|
||||
|
|
|
|||
|
|
@ -14,13 +14,15 @@ import type { TeamChangeEvent } from '@shared/types';
|
|||
import type { FSWatcher } from 'chokidar';
|
||||
|
||||
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 {
|
||||
projectFingerprint: string | null;
|
||||
logSourceGeneration: string | null;
|
||||
}
|
||||
|
||||
export type TeamLogSourceTrackingConsumer = 'change_presence' | 'tool_activity';
|
||||
export type TeamLogSourceTrackingConsumer = 'change_presence' | 'tool_activity' | 'task_log_stream';
|
||||
|
||||
interface TrackingState {
|
||||
watcher: FSWatcher | null;
|
||||
|
|
@ -31,7 +33,7 @@ interface TrackingState {
|
|||
recomputePromise: Promise<TeamLogSourceSnapshot> | null;
|
||||
recomputeVersion: number | null;
|
||||
snapshot: TeamLogSourceSnapshot;
|
||||
consumers: Set<TeamLogSourceTrackingConsumer>;
|
||||
consumerCounts: Map<TeamLogSourceTrackingConsumer, number>;
|
||||
lifecycleVersion: number;
|
||||
}
|
||||
|
||||
|
|
@ -67,19 +69,29 @@ export class TeamLogSourceTracker {
|
|||
consumer: TeamLogSourceTrackingConsumer
|
||||
): Promise<TeamLogSourceSnapshot> {
|
||||
const state = this.getOrCreateState(teamName);
|
||||
if (!state.consumers.has(consumer)) {
|
||||
state.consumers.add(consumer);
|
||||
const activeConsumerCountBefore = this.getActiveConsumerCount(state);
|
||||
state.consumerCounts.set(consumer, (state.consumerCounts.get(consumer) ?? 0) + 1);
|
||||
if (activeConsumerCountBefore === 0) {
|
||||
state.lifecycleVersion += 1;
|
||||
}
|
||||
|
||||
if (
|
||||
state.initializePromise &&
|
||||
state.initializeVersion === state.lifecycleVersion &&
|
||||
state.consumers.size > 0
|
||||
this.getActiveConsumerCount(state) > 0
|
||||
) {
|
||||
return state.initializePromise;
|
||||
}
|
||||
|
||||
if (
|
||||
activeConsumerCountBefore > 0 &&
|
||||
(state.watcher !== null ||
|
||||
state.projectDir !== null ||
|
||||
state.snapshot.logSourceGeneration !== null)
|
||||
) {
|
||||
return { ...state.snapshot };
|
||||
}
|
||||
|
||||
const initializeVersion = state.lifecycleVersion;
|
||||
const initializePromise = this.initializeTeam(teamName, initializeVersion)
|
||||
.catch((error) => {
|
||||
|
|
@ -118,13 +130,21 @@ export class TeamLogSourceTracker {
|
|||
recomputePromise: null,
|
||||
recomputeVersion: null,
|
||||
snapshot: { projectFingerprint: null, logSourceGeneration: null },
|
||||
consumers: new Set(),
|
||||
consumerCounts: new Map(),
|
||||
lifecycleVersion: 0,
|
||||
};
|
||||
this.stateByTeam.set(teamName, 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> {
|
||||
await this.disableTracking(teamName, 'change_presence');
|
||||
}
|
||||
|
|
@ -138,15 +158,24 @@ export class TeamLogSourceTracker {
|
|||
return { projectFingerprint: null, logSourceGeneration: null };
|
||||
}
|
||||
|
||||
if (state.consumers.has(consumer)) {
|
||||
state.consumers.delete(consumer);
|
||||
state.lifecycleVersion += 1;
|
||||
const currentConsumerCount = state.consumerCounts.get(consumer) ?? 0;
|
||||
if (currentConsumerCount > 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 };
|
||||
}
|
||||
|
||||
if (currentConsumerCount > 0) {
|
||||
state.lifecycleVersion += 1;
|
||||
}
|
||||
|
||||
if (state.refreshTimer) {
|
||||
clearTimeout(state.refreshTimer);
|
||||
state.refreshTimer = null;
|
||||
|
|
@ -164,7 +193,11 @@ export class TeamLogSourceTracker {
|
|||
|
||||
private isTrackingCurrent(teamName: string, expectedVersion: number): boolean {
|
||||
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(
|
||||
|
|
@ -207,7 +240,11 @@ export class TeamLogSourceTracker {
|
|||
expectedVersion: number
|
||||
): Promise<void> {
|
||||
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;
|
||||
}
|
||||
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);
|
||||
if (!current || current.consumers.size === 0) {
|
||||
if (!current || this.getActiveConsumerCount(current) === 0 || !current.projectDir) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
changedPath &&
|
||||
this.handleTaskLogFreshnessSignalChange(teamName, current.projectDir, changedPath)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
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> {
|
||||
const state = this.getOrCreateState(teamName);
|
||||
if (state.consumers.size === 0) {
|
||||
if (this.getActiveConsumerCount(state) === 0) {
|
||||
return state.snapshot;
|
||||
}
|
||||
if (
|
||||
state.recomputePromise &&
|
||||
state.recomputeVersion === state.lifecycleVersion &&
|
||||
state.consumers.size > 0
|
||||
this.getActiveConsumerCount(state) > 0
|
||||
) {
|
||||
return state.recomputePromise;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,11 @@
|
|||
import { getMemberColorByName } from '@shared/constants/memberColors';
|
||||
import {
|
||||
createCliAutoSuffixNameGuard,
|
||||
createCliProvisionerNameGuard,
|
||||
} from '@shared/utils/teamMemberName';
|
||||
import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
|
||||
|
||||
import type {
|
||||
InboxMessage,
|
||||
MemberStatus,
|
||||
ResolvedTeamMember,
|
||||
TeamConfig,
|
||||
TeamMember,
|
||||
TeamTaskWithKanban,
|
||||
} from '@shared/types';
|
||||
import type { TeamConfig, TeamMember, TeamMemberSnapshot, TeamTaskWithKanban } from '@shared/types';
|
||||
|
||||
const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
|
||||
const CROSS_TEAM_TOOL_RECIPIENT_NAMES = new Set([
|
||||
|
|
@ -63,9 +57,8 @@ export class TeamMemberResolver {
|
|||
config: TeamConfig,
|
||||
metaMembers: TeamConfig['members'],
|
||||
inboxNames: string[],
|
||||
tasks: TeamTaskWithKanban[],
|
||||
messages: InboxMessage[]
|
||||
): ResolvedTeamMember[] {
|
||||
tasks: TeamTaskWithKanban[]
|
||||
): TeamMemberSnapshot[] {
|
||||
const names = new Set<string>();
|
||||
const explicitNames = 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) {
|
||||
const ownedTasks = tasks.filter((task) => task.owner === name);
|
||||
const currentTask =
|
||||
|
|
@ -226,21 +219,15 @@ export class TeamMemberResolver {
|
|||
task.reviewState !== 'approved' &&
|
||||
task.kanbanColumn !== 'approved'
|
||||
) ?? 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 metaMember = metaMemberMap.get(name);
|
||||
const agentId = configMember?.agentId ?? metaMember?.agentId;
|
||||
members.push({
|
||||
name,
|
||||
agentId,
|
||||
status,
|
||||
currentTaskId: currentTask?.id ?? null,
|
||||
taskCount: ownedTasks.length,
|
||||
messageCount: memberMessages.length,
|
||||
lastActiveAt: latestMessage?.timestamp ?? null,
|
||||
color: latestMessage?.color ?? configMember?.color ?? metaMember?.color,
|
||||
color: configMember?.color ?? metaMember?.color ?? getMemberColorByName(name),
|
||||
agentType: configMember?.agentType ?? metaMember?.agentType,
|
||||
role: configMember?.role ?? metaMember?.role,
|
||||
workflow: configMember?.workflow ?? metaMember?.workflow,
|
||||
|
|
@ -277,45 +264,4 @@ export class TeamMemberResolver {
|
|||
});
|
||||
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 { createReadStream, type Dirent } from 'fs';
|
||||
import * as fs from 'fs/promises';
|
||||
|
|
@ -15,6 +23,33 @@ const SESSION_DISCOVERY_CACHE_TTL = 30_000;
|
|||
const TEAM_AFFINITY_SCAN_LINES = 40;
|
||||
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 {
|
||||
let end = value.length;
|
||||
while (end > 0) {
|
||||
|
|
@ -32,6 +67,17 @@ function isSessionDirectoryName(name: string): boolean {
|
|||
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 {
|
||||
if (typeof entry.content === 'string') {
|
||||
return entry.content;
|
||||
|
|
@ -71,6 +117,9 @@ function lineMentionsTeam(text: string, teamName: string): boolean {
|
|||
return false;
|
||||
}
|
||||
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(`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[] {
|
||||
const knownSessionIds = new Set<string>();
|
||||
const push = (value: unknown): void => {
|
||||
|
|
@ -93,7 +164,8 @@ function collectKnownSessionIds(config: TeamConfig): string[] {
|
|||
|
||||
push(config.leadSessionId);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -130,13 +202,40 @@ export class TeamTranscriptProjectResolver {
|
|||
}
|
||||
|
||||
const config = await this.configReader.getConfig(teamName);
|
||||
if (!config?.projectPath) {
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { projectDir, projectId } = await this.resolveProjectDirectory(config);
|
||||
const sessionIds = await this.discoverSessionIds(teamName, projectDir, config);
|
||||
const value = { projectDir, projectId, config, sessionIds };
|
||||
const resolution = await this.resolveProjectDirectory(teamName, config);
|
||||
if (!resolution) {
|
||||
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, {
|
||||
value,
|
||||
expiresAt: Date.now() + SESSION_DISCOVERY_CACHE_TTL,
|
||||
|
|
@ -145,47 +244,377 @@ export class TeamTranscriptProjectResolver {
|
|||
}
|
||||
|
||||
private async resolveProjectDirectory(
|
||||
teamName: string,
|
||||
config: TeamConfig
|
||||
): Promise<{ projectDir: string; projectId: string }> {
|
||||
const normalizedProjectPath = trimTrailingSlashes(config.projectPath ?? '');
|
||||
let projectId = encodePath(normalizedProjectPath);
|
||||
let projectDir = path.join(getProjectsBasePath(), extractBaseDir(projectId));
|
||||
): Promise<{ projectDir: string; projectId: string; effectiveProjectPath?: string } | null> {
|
||||
const sessionIds = collectKnownSessionIds(config);
|
||||
const pathCandidates = this.collectProjectPathCandidates(config);
|
||||
const currentCandidate = pathCandidates[0] ?? null;
|
||||
if (sessionIds.length === 0) {
|
||||
return this.buildFallbackResolution(teamName, pathCandidates);
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await fs.stat(projectDir);
|
||||
if (!stat.isDirectory()) {
|
||||
throw new Error('not a directory');
|
||||
}
|
||||
return { projectDir, projectId };
|
||||
} catch {
|
||||
const leadSessionId =
|
||||
typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0
|
||||
? config.leadSessionId.trim()
|
||||
: null;
|
||||
if (!leadSessionId) {
|
||||
return { projectDir, projectId };
|
||||
}
|
||||
const rankBySessionId = new Map(sessionIds.map((sessionId, index) => [sessionId, index]));
|
||||
const getMatchRank = (match: { matchedSessionId: string } | null): number =>
|
||||
match
|
||||
? (rankBySessionId.get(match.matchedSessionId) ?? Number.POSITIVE_INFINITY)
|
||||
: Number.POSITIVE_INFINITY;
|
||||
|
||||
try {
|
||||
const projectEntries = await fs.readdir(getProjectsBasePath(), { withFileTypes: true });
|
||||
for (const entry of projectEntries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const candidateDir = path.join(getProjectsBasePath(), entry.name);
|
||||
try {
|
||||
await fs.access(path.join(candidateDir, `${leadSessionId}.jsonl`));
|
||||
projectDir = candidateDir;
|
||||
projectId = entry.name;
|
||||
break;
|
||||
} catch {
|
||||
// not this project
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// best-effort fallback
|
||||
const toResolution = (
|
||||
match: Pick<ProjectDirCandidate, 'projectDir' | 'projectId'> & { projectPath?: string }
|
||||
): { projectDir: string; projectId: string; effectiveProjectPath?: string } => ({
|
||||
projectDir: match.projectDir,
|
||||
projectId: match.projectId,
|
||||
...(match.projectPath ? { effectiveProjectPath: match.projectPath } : {}),
|
||||
});
|
||||
|
||||
let currentMatch: SessionProjectMatch | null = null;
|
||||
if (currentCandidate) {
|
||||
const resolvedCurrentMatch = await this.findMatchInProjectPathCandidate(
|
||||
currentCandidate,
|
||||
sessionIds
|
||||
);
|
||||
if (resolvedCurrentMatch && getMatchRank(resolvedCurrentMatch) === 0) {
|
||||
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(
|
||||
|
|
@ -199,42 +628,95 @@ export class TeamTranscriptProjectResolver {
|
|||
this.listSessionDirIds(projectDir),
|
||||
]);
|
||||
|
||||
return Array.from(new Set([...knownSessionIds, ...teamRootSessionIds, ...sessionDirIds])).sort(
|
||||
(left, right) => left.localeCompare(right)
|
||||
);
|
||||
const orderedSessionIds: string[] = [];
|
||||
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[]> {
|
||||
try {
|
||||
const dirEntries = await fs.readdir(projectDir, { withFileTypes: true });
|
||||
return dirEntries
|
||||
.filter((entry) => entry.isDirectory() && isSessionDirectoryName(entry.name))
|
||||
.map((entry) => entry.name);
|
||||
} catch {
|
||||
logger.debug(`Cannot read transcript project dir: ${projectDir}`);
|
||||
const dirEntries = await this.readProjectDirEntries(projectDir);
|
||||
if (!dirEntries) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return dirEntries
|
||||
.filter((entry) => entry.isDirectory() && isSessionDirectoryName(entry.name))
|
||||
.map((entry) => entry.name);
|
||||
}
|
||||
|
||||
private async listTeamRootSessionIds(projectDir: string, teamName: string): Promise<string[]> {
|
||||
let dirEntries: Dirent[];
|
||||
try {
|
||||
dirEntries = await fs.readdir(projectDir, { withFileTypes: true });
|
||||
} catch {
|
||||
logger.debug(`Cannot read transcript project dir: ${projectDir}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const rootJsonlEntries = dirEntries.filter(
|
||||
(entry) => entry.isFile() && entry.name.endsWith('.jsonl')
|
||||
);
|
||||
private async collectRootJsonlSessionIds(
|
||||
rootJsonlEntries: Dirent[],
|
||||
projectDir: string,
|
||||
teamName: string
|
||||
): Promise<string[]> {
|
||||
const discovered = new Set<string>();
|
||||
let nextIndex = 0;
|
||||
|
||||
const worker = async (): Promise<void> => {
|
||||
const scanNextRootEntry = async (): Promise<void> => {
|
||||
while (nextIndex < rootJsonlEntries.length) {
|
||||
const index = nextIndex++;
|
||||
const entry = rootJsonlEntries[index];
|
||||
const entry = rootJsonlEntries[nextIndex++];
|
||||
const filePath = path.join(projectDir, entry.name);
|
||||
if (!(await this.fileBelongsToTeam(filePath, teamName))) {
|
||||
continue;
|
||||
|
|
@ -245,13 +727,25 @@ export class TeamTranscriptProjectResolver {
|
|||
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(ROOT_DISCOVERY_CONCURRENCY, rootJsonlEntries.length) }, () =>
|
||||
worker()
|
||||
scanNextRootEntry()
|
||||
)
|
||||
);
|
||||
|
||||
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> {
|
||||
const stream = createReadStream(filePath, { encoding: 'utf8' });
|
||||
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
||||
|
|
@ -272,6 +766,9 @@ export class TeamTranscriptProjectResolver {
|
|||
if (directTeamName === normalizedTeam) {
|
||||
return true;
|
||||
}
|
||||
if (entryContainsNestedTeamName(entry, normalizedTeam)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const textContent = extractTextContent(entry);
|
||||
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 { CascadeGuard } from './CascadeGuard';
|
||||
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 { isLeadMember as isLeadMemberCheck } from '@shared/utils/leadDetection';
|
||||
|
||||
import { TeamTaskReader } from '../../TeamTaskReader';
|
||||
import { BoardTaskActivityRecordSource } from '../activity/BoardTaskActivityRecordSource';
|
||||
import { TeamTranscriptSourceLocator } from '../discovery/TeamTranscriptSourceLocator';
|
||||
import { BoardTaskExactLogChunkBuilder } from '../exact/BoardTaskExactLogChunkBuilder';
|
||||
import { BoardTaskExactLogDetailSelector } from '../exact/BoardTaskExactLogDetailSelector';
|
||||
import { BoardTaskExactLogStrictParser } from '../exact/BoardTaskExactLogStrictParser';
|
||||
|
|
@ -16,12 +19,15 @@ import type {
|
|||
BoardTaskLogParticipant,
|
||||
BoardTaskLogSegment,
|
||||
BoardTaskLogStreamResponse,
|
||||
BoardTaskLogStreamSummary,
|
||||
TeamTask,
|
||||
} from '@shared/types';
|
||||
|
||||
interface StreamSlice {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
filePath: string;
|
||||
sortOrder?: number;
|
||||
participantKey: string;
|
||||
actor: BoardTaskLogActor;
|
||||
actionCategory?: BoardTaskActivityCategory;
|
||||
|
|
@ -37,6 +43,22 @@ interface MergedMessageAccumulator {
|
|||
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 {
|
||||
return {
|
||||
participants: [],
|
||||
|
|
@ -45,10 +67,22 @@ function emptyResponse(): BoardTaskLogStreamResponse {
|
|||
};
|
||||
}
|
||||
|
||||
function emptySummary(): BoardTaskLogStreamSummary {
|
||||
return {
|
||||
segmentCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMemberName(value: string): string {
|
||||
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 {
|
||||
return {
|
||||
...(detail.memberName ? { memberName: detail.memberName } : {}),
|
||||
|
|
@ -139,14 +173,47 @@ function extractBoardToolOutputText(
|
|||
return null;
|
||||
}
|
||||
|
||||
const normalizedToolName = toolName.trim().toLowerCase();
|
||||
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;
|
||||
if (typeof comment?.text === 'string' && comment.text.trim().length > 0) {
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -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(
|
||||
messages: ParsedMessage[],
|
||||
canonicalToolName?: string
|
||||
): ParsedMessage[] {
|
||||
return messages.map((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;
|
||||
if (
|
||||
|
|
@ -366,12 +488,20 @@ function sanitizeJsonLikeToolResultPayloads(
|
|||
});
|
||||
|
||||
if (!changed) {
|
||||
return nextMessage;
|
||||
if (!toolResultsChanged) {
|
||||
return nextMessage;
|
||||
}
|
||||
|
||||
return {
|
||||
...nextMessage,
|
||||
toolResults: nextToolResults,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...nextMessage,
|
||||
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'}`;
|
||||
}
|
||||
|
||||
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 {
|
||||
constructor(
|
||||
private readonly recordSource: BoardTaskActivityRecordSource = new BoardTaskActivityRecordSource(),
|
||||
private readonly summarySelector: BoardTaskExactLogSummarySelector = new BoardTaskExactLogSummarySelector(),
|
||||
private readonly strictParser: BoardTaskExactLogStrictParser = new BoardTaskExactLogStrictParser(),
|
||||
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()) {
|
||||
return emptyResponse();
|
||||
return {
|
||||
participants: [],
|
||||
visibleSlices: [],
|
||||
};
|
||||
}
|
||||
|
||||
const records = await this.recordSource.getTaskRecords(teamName, taskId);
|
||||
if (records.length === 0) {
|
||||
return emptyResponse();
|
||||
return {
|
||||
participants: [],
|
||||
visibleSlices: [],
|
||||
};
|
||||
}
|
||||
|
||||
const fileVersionsByPath = await getBoardTaskExactLogFileVersions(
|
||||
|
|
@ -723,7 +1212,10 @@ export class BoardTaskLogStreamService {
|
|||
.sort(compareCandidates);
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return emptyResponse();
|
||||
return {
|
||||
participants: [],
|
||||
visibleSlices: [],
|
||||
};
|
||||
}
|
||||
|
||||
const parsedMessagesByFile = await this.strictParser.parseFiles(
|
||||
|
|
@ -762,6 +1254,7 @@ export class BoardTaskLogStreamService {
|
|||
id: detail.id,
|
||||
timestamp: detail.timestamp,
|
||||
filePath: detail.source.filePath,
|
||||
sortOrder: detail.source.sourceOrder,
|
||||
participantKey: buildParticipantKey(actor),
|
||||
actor,
|
||||
actionCategory: candidate.actionCategory,
|
||||
|
|
@ -770,10 +1263,20 @@ export class BoardTaskLogStreamService {
|
|||
}
|
||||
|
||||
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) =>
|
||||
hasNamedParticipant(slice.actor)
|
||||
|
|
@ -781,27 +1284,31 @@ export class BoardTaskLogStreamService {
|
|||
const visibleSlices =
|
||||
namedParticipantSlices.length > 0 ? namedParticipantSlices : deNoisedSlices;
|
||||
|
||||
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 {
|
||||
participants: buildOrderedParticipants(visibleSlices),
|
||||
visibleSlices,
|
||||
};
|
||||
}
|
||||
|
||||
async getTaskLogStreamSummary(
|
||||
teamName: string,
|
||||
taskId: string
|
||||
): Promise<BoardTaskLogStreamSummary> {
|
||||
const layout = await this.buildStreamLayout(teamName, taskId);
|
||||
if (layout.visibleSlices.length === 0) {
|
||||
return emptySummary();
|
||||
}
|
||||
|
||||
const orderedParticipants = 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);
|
||||
});
|
||||
return {
|
||||
segmentCount: countSegmentsFromSlices(layout.visibleSlices),
|
||||
};
|
||||
}
|
||||
|
||||
async getTaskLogStream(teamName: string, taskId: string): Promise<BoardTaskLogStreamResponse> {
|
||||
const layout = await this.buildStreamLayout(teamName, taskId);
|
||||
if (layout.visibleSlices.length === 0) {
|
||||
return emptyResponse();
|
||||
}
|
||||
|
||||
const segments: BoardTaskLogSegment[] = [];
|
||||
let currentSegmentSlices: StreamSlice[] = [];
|
||||
|
|
@ -835,7 +1342,7 @@ export class BoardTaskLogStreamService {
|
|||
currentSegmentSlices = [];
|
||||
};
|
||||
|
||||
for (const slice of visibleSlices) {
|
||||
for (const slice of layout.visibleSlices) {
|
||||
if (
|
||||
currentSegmentSlices.length > 0 &&
|
||||
currentSegmentSlices[0].participantKey !== slice.participantKey
|
||||
|
|
@ -846,11 +1353,11 @@ export class BoardTaskLogStreamService {
|
|||
}
|
||||
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';
|
||||
|
||||
return {
|
||||
participants: orderedParticipants,
|
||||
participants: layout.participants,
|
||||
defaultFilter,
|
||||
segments,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,7 +2,12 @@
|
|||
* 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 ──
|
||||
|
||||
|
|
@ -10,6 +15,18 @@ export interface GetTeamDataPayload {
|
|||
teamName: string;
|
||||
}
|
||||
|
||||
export interface GetMessagesPagePayload {
|
||||
teamName: string;
|
||||
options: {
|
||||
cursor?: string | null;
|
||||
limit: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GetMemberActivityMetaPayload {
|
||||
teamName: string;
|
||||
}
|
||||
|
||||
export interface FindLogsForTaskPayload {
|
||||
teamName: string;
|
||||
taskId: string;
|
||||
|
|
@ -25,8 +42,14 @@ export interface FindLogsForTaskPayload {
|
|||
|
||||
export type TeamDataWorkerRequest =
|
||||
| { id: string; op: 'getTeamData'; payload: GetTeamDataPayload }
|
||||
| { id: string; op: 'getMessagesPage'; payload: GetMessagesPagePayload }
|
||||
| { id: string; op: 'getMemberActivityMeta'; payload: GetMemberActivityMetaPayload }
|
||||
| { id: string; op: 'findLogsForTask'; payload: FindLogsForTaskPayload };
|
||||
|
||||
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 };
|
||||
|
|
|
|||
|
|
@ -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
|
||||
* 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:
|
||||
* - 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:
|
||||
* - Projects and sessions
|
||||
|
|
|
|||
|
|
@ -82,6 +82,21 @@ export interface UsageMetadata {
|
|||
output_tokens: number;
|
||||
cache_read_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;
|
||||
version: string;
|
||||
gitBranch: string;
|
||||
agentName?: 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
|
||||
* raw JSONL entries. This module also contains type guards for classifying
|
||||
|
|
@ -80,10 +80,14 @@ export interface ParsedMessage {
|
|||
// Metadata
|
||||
/** Current working directory when message was created */
|
||||
cwd?: string;
|
||||
/** Root/session identifier from transcript */
|
||||
sessionId?: string;
|
||||
/** Git branch context */
|
||||
gitBranch?: string;
|
||||
/** Agent ID for subagent messages */
|
||||
agentId?: string;
|
||||
/** Human-readable agent/member name from transcript */
|
||||
agentName?: string;
|
||||
/** Whether this is a sidechain message */
|
||||
isSidechain: boolean;
|
||||
/** Whether this is a meta message */
|
||||
|
|
|
|||
|
|
@ -240,8 +240,10 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null {
|
|||
let model: string | undefined;
|
||||
let requestId: string | undefined;
|
||||
let cwd: string | undefined;
|
||||
let sessionId: string | undefined;
|
||||
let gitBranch: string | undefined;
|
||||
let agentId: string | undefined;
|
||||
let agentName: string | undefined;
|
||||
let isSidechain = false;
|
||||
let isMeta = false;
|
||||
let userType: string | undefined;
|
||||
|
|
@ -255,10 +257,12 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null {
|
|||
if (isConversationalEntry(entry)) {
|
||||
// Common properties from ConversationalEntry base
|
||||
cwd = entry.cwd;
|
||||
sessionId = entry.sessionId;
|
||||
gitBranch = entry.gitBranch;
|
||||
isSidechain = entry.isSidechain ?? false;
|
||||
userType = entry.userType;
|
||||
parentUuid = entry.parentUuid ?? null;
|
||||
agentName = entry.agentName;
|
||||
|
||||
// Type-specific properties
|
||||
if (entry.type === 'user') {
|
||||
|
|
@ -298,8 +302,10 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null {
|
|||
model,
|
||||
// Metadata
|
||||
cwd,
|
||||
sessionId,
|
||||
gitBranch,
|
||||
agentId,
|
||||
agentName,
|
||||
isSidechain,
|
||||
isMeta,
|
||||
userType,
|
||||
|
|
|
|||
|
|
@ -42,6 +42,19 @@ parentPort?.on('message', async (msg: TeamDataWorkerRequest) => {
|
|||
respond({ id: msg.id, ok: true, result });
|
||||
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': {
|
||||
const { teamName, taskId, options } = msg.payload;
|
||||
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 */
|
||||
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) */
|
||||
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 */
|
||||
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 */
|
||||
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 */
|
||||
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 */
|
||||
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 */
|
||||
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) */
|
||||
export const TEAM_SOFT_DELETE_TASK = 'team:softDeleteTask';
|
||||
|
||||
|
|
|
|||
|
|
@ -127,6 +127,7 @@ import {
|
|||
TEAM_GET_DATA,
|
||||
TEAM_GET_DELETED_TASKS,
|
||||
TEAM_GET_LOGS_FOR_TASK,
|
||||
TEAM_GET_MEMBER_ACTIVITY_META,
|
||||
TEAM_GET_MEMBER_LOGS,
|
||||
TEAM_GET_MEMBER_STATS,
|
||||
TEAM_GET_MESSAGES_PAGE,
|
||||
|
|
@ -139,12 +140,14 @@ import {
|
|||
TEAM_GET_TASK_EXACT_LOG_DETAIL,
|
||||
TEAM_GET_TASK_EXACT_LOG_SUMMARIES,
|
||||
TEAM_GET_TASK_LOG_STREAM,
|
||||
TEAM_GET_TASK_LOG_STREAM_SUMMARY,
|
||||
TEAM_KILL_PROCESS,
|
||||
TEAM_LAUNCH,
|
||||
TEAM_LEAD_ACTIVITY,
|
||||
TEAM_LEAD_CONTEXT,
|
||||
TEAM_LIST,
|
||||
TEAM_MEMBER_SPAWN_STATUSES,
|
||||
TEAM_GET_AGENT_RUNTIME,
|
||||
TEAM_PERMANENTLY_DELETE,
|
||||
TEAM_PREPARE_PROVISIONING,
|
||||
TEAM_PROCESS_ALIVE,
|
||||
|
|
@ -156,11 +159,13 @@ import {
|
|||
TEAM_REMOVE_TASK_RELATIONSHIP,
|
||||
TEAM_REPLACE_MEMBERS,
|
||||
TEAM_REQUEST_REVIEW,
|
||||
TEAM_RESTART_MEMBER,
|
||||
TEAM_RESTORE,
|
||||
TEAM_RESTORE_TASK,
|
||||
TEAM_SAVE_TASK_ATTACHMENT,
|
||||
TEAM_SEND_MESSAGE,
|
||||
TEAM_SET_CHANGE_PRESENCE_TRACKING,
|
||||
TEAM_SET_TASK_LOG_STREAM_TRACKING,
|
||||
TEAM_SET_PROJECT_BRANCH_TRACKING,
|
||||
TEAM_SET_TASK_CLARIFICATION,
|
||||
TEAM_SET_TOOL_ACTIVITY_TRACKING,
|
||||
|
|
@ -240,6 +245,7 @@ import type {
|
|||
BoardTaskExactLogDetailResult,
|
||||
BoardTaskExactLogSummariesResponse,
|
||||
BoardTaskLogStreamResponse,
|
||||
BoardTaskLogStreamSummary,
|
||||
ChangeStats,
|
||||
ClaudeRootFolderSelection,
|
||||
ClaudeRootInfo,
|
||||
|
|
@ -264,6 +270,7 @@ import type {
|
|||
LeadContextUsageSnapshot,
|
||||
MemberFullStats,
|
||||
MemberLogSummary,
|
||||
TeamAgentRuntimeSnapshot,
|
||||
MemberSpawnStatusesSnapshot,
|
||||
MessagesPage,
|
||||
NotificationTrigger,
|
||||
|
|
@ -293,9 +300,9 @@ import type {
|
|||
TeamCreateConfigRequest,
|
||||
TeamCreateRequest,
|
||||
TeamCreateResponse,
|
||||
TeamData,
|
||||
TeamLaunchRequest,
|
||||
TeamLaunchResponse,
|
||||
TeamMemberActivityMeta,
|
||||
TeamMessageNotificationData,
|
||||
TeamProvisioningPrepareResult,
|
||||
TeamProvisioningProgress,
|
||||
|
|
@ -303,6 +310,7 @@ import type {
|
|||
TeamTask,
|
||||
TeamTaskStatus,
|
||||
TeamUpdateConfigRequest,
|
||||
TeamViewSnapshot,
|
||||
ToolApprovalEvent,
|
||||
ToolApprovalFileContent,
|
||||
ToolApprovalSettings,
|
||||
|
|
@ -823,7 +831,7 @@ const electronAPI: ElectronAPI = {
|
|||
return invokeIpcWithResult<TeamSummary[]>(TEAM_LIST);
|
||||
},
|
||||
getData: async (teamName: string) => {
|
||||
return invokeIpcWithResult<TeamData>(TEAM_GET_DATA, teamName);
|
||||
return invokeIpcWithResult<TeamViewSnapshot>(TEAM_GET_DATA, teamName);
|
||||
},
|
||||
getTaskChangePresence: async (teamName: string) => {
|
||||
return invokeIpcWithResult<Record<string, TaskChangePresenceState>>(
|
||||
|
|
@ -834,6 +842,9 @@ const electronAPI: ElectronAPI = {
|
|||
setChangePresenceTracking: async (teamName: string, enabled: boolean) => {
|
||||
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) => {
|
||||
return invokeIpcWithResult<void>(TEAM_SET_TOOL_ACTIVITY_TRACKING, teamName, enabled);
|
||||
},
|
||||
|
|
@ -888,10 +899,13 @@ const electronAPI: ElectronAPI = {
|
|||
},
|
||||
getMessagesPage: async (
|
||||
teamName: string,
|
||||
options?: { beforeTimestamp?: string; limit?: number }
|
||||
options?: { cursor?: string | null; limit?: number }
|
||||
) => {
|
||||
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) => {
|
||||
return invokeIpcWithResult<TeamTask>(TEAM_CREATE_TASK, teamName, request);
|
||||
},
|
||||
|
|
@ -986,6 +1000,13 @@ const electronAPI: ElectronAPI = {
|
|||
activityId
|
||||
);
|
||||
},
|
||||
getTaskLogStreamSummary: async (teamName: string, taskId: string) => {
|
||||
return invokeIpcWithResult<BoardTaskLogStreamSummary>(
|
||||
TEAM_GET_TASK_LOG_STREAM_SUMMARY,
|
||||
teamName,
|
||||
taskId
|
||||
);
|
||||
},
|
||||
getTaskLogStream: async (teamName: string, taskId: string) => {
|
||||
return invokeIpcWithResult<BoardTaskLogStreamResponse>(
|
||||
TEAM_GET_TASK_LOG_STREAM,
|
||||
|
|
@ -1059,6 +1080,12 @@ const electronAPI: ElectronAPI = {
|
|||
getMemberSpawnStatuses: async (teamName: string) => {
|
||||
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) => {
|
||||
return invokeIpcWithResult<void>(TEAM_SOFT_DELETE_TASK, teamName, taskId);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import type {
|
|||
BoardTaskExactLogDetailResult,
|
||||
BoardTaskExactLogSummariesResponse,
|
||||
BoardTaskLogStreamResponse,
|
||||
BoardTaskLogStreamSummary,
|
||||
ClaudeMdFileInfo,
|
||||
ClaudeRootFolderSelection,
|
||||
ClaudeRootInfo,
|
||||
|
|
@ -58,15 +59,16 @@ import type {
|
|||
TeamClaudeLogsResponse,
|
||||
TeamCreateRequest,
|
||||
TeamCreateResponse,
|
||||
TeamData,
|
||||
TeamLaunchRequest,
|
||||
TeamLaunchResponse,
|
||||
TeamMemberActivityMeta,
|
||||
TeamProvisioningPrepareResult,
|
||||
TeamProvisioningProgress,
|
||||
TeamsAPI,
|
||||
TeamSummary,
|
||||
TeamTask,
|
||||
TeamTaskStatus,
|
||||
TeamViewSnapshot,
|
||||
TmuxAPI,
|
||||
TmuxStatus,
|
||||
TriggerTestResult,
|
||||
|
|
@ -677,7 +679,7 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
console.warn('[HttpAPIClient] teams API is not available in browser mode');
|
||||
return [];
|
||||
},
|
||||
getData: async (_teamName: string): Promise<TeamData> => {
|
||||
getData: async (_teamName: string): Promise<TeamViewSnapshot> => {
|
||||
throw new Error('Teams detail is not available in browser mode');
|
||||
},
|
||||
getTaskChangePresence: async (): Promise<
|
||||
|
|
@ -688,6 +690,9 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
setChangePresenceTracking: async (): Promise<void> => {
|
||||
// Not available in browser mode — no-op.
|
||||
},
|
||||
setTaskLogStreamTracking: async (): Promise<void> => {
|
||||
// Not available in browser mode — no-op.
|
||||
},
|
||||
setToolActivityTracking: async (): Promise<void> => {
|
||||
// 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');
|
||||
},
|
||||
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> => {
|
||||
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');
|
||||
return { status: 'missing' };
|
||||
},
|
||||
getTaskLogStreamSummary: async (): Promise<BoardTaskLogStreamSummary> => {
|
||||
console.warn('[HttpAPIClient] getTaskLogStreamSummary is not available in browser mode');
|
||||
return { segmentCount: 0 };
|
||||
},
|
||||
getTaskLogStream: async (): Promise<BoardTaskLogStreamResponse> => {
|
||||
console.warn('[HttpAPIClient] getTaskLogStream is not available in browser mode');
|
||||
return {
|
||||
|
|
@ -905,6 +922,17 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
getMemberSpawnStatuses: async () => {
|
||||
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> => {
|
||||
// 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. */
|
||||
const SCROLL_THRESHOLD = 300;
|
||||
|
||||
import {
|
||||
computeRemainingContext,
|
||||
formatPercentOfTotal,
|
||||
sumContextInjectionTokens,
|
||||
} from '@renderer/utils/contextMath';
|
||||
import { computeRemainingContext, sumContextInjectionTokens } from '@renderer/utils/contextMath';
|
||||
import { deriveContextMetrics } from '@shared/utils/contextMetrics';
|
||||
|
||||
import { ChatHistoryEmptyState } from './ChatHistoryEmptyState';
|
||||
import { ChatHistoryItem } from './ChatHistoryItem';
|
||||
import { ChatHistoryLoadingState } from './ChatHistoryLoadingState';
|
||||
|
||||
import type { ContextInjection } from '@renderer/types/contextInjection';
|
||||
import type { ContextUsageLike } from '@shared/utils/contextMetrics';
|
||||
|
||||
/**
|
||||
* 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 teamBySessionId = useStore(useShallow((s) => s.teamBySessionId));
|
||||
const leadContextByTeam = useStore(useShallow((s) => s.leadContextByTeam));
|
||||
|
||||
// Look up whether this session belongs to a team
|
||||
const sessionTeam = useMemo(() => {
|
||||
|
|
@ -138,9 +137,13 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
|
|||
}, [teamBySessionId, sessionDetail?.session?.id]);
|
||||
|
||||
// Compute all accumulated context injections (phase-aware)
|
||||
const { allContextInjections, lastAiGroupTotalTokens } = useMemo(() => {
|
||||
const { allContextInjections, lastAssistantUsage, lastAssistantModelName } = useMemo(() => {
|
||||
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
|
||||
|
|
@ -161,7 +164,8 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
|
|||
if (lastAiItem?.type !== 'ai') {
|
||||
return {
|
||||
allContextInjections: [] as ContextInjection[],
|
||||
lastAiGroupTotalTokens: undefined,
|
||||
lastAssistantUsage: null,
|
||||
lastAssistantModelName: undefined,
|
||||
};
|
||||
}
|
||||
targetAiGroupId = lastAiItem.group.id;
|
||||
|
|
@ -170,9 +174,8 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
|
|||
const stats = sessionContextStats.get(targetAiGroupId);
|
||||
const injections = stats?.accumulatedInjections ?? [];
|
||||
|
||||
// Get total INPUT tokens from the target AI group (excluding output tokens,
|
||||
// since visible context is part of input only)
|
||||
let totalTokens: number | undefined;
|
||||
let lastUsage: ContextUsageLike | null = null;
|
||||
let lastModelName: string | undefined;
|
||||
const targetItem = conversation.items.find(
|
||||
(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--) {
|
||||
const msg = responses[i];
|
||||
if (msg.type === 'assistant' && msg.usage) {
|
||||
const usage = msg.usage;
|
||||
totalTokens =
|
||||
(usage.input_tokens ?? 0) +
|
||||
(usage.cache_read_input_tokens ?? 0) +
|
||||
(usage.cache_creation_input_tokens ?? 0);
|
||||
lastUsage = msg.usage;
|
||||
lastModelName = msg.model;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { allContextInjections: injections, lastAiGroupTotalTokens: totalTokens };
|
||||
return {
|
||||
allContextInjections: injections,
|
||||
lastAssistantUsage: lastUsage,
|
||||
lastAssistantModelName: lastModelName,
|
||||
};
|
||||
}, [sessionContextStats, conversation, selectedContextPhase, sessionPhaseInfo]);
|
||||
|
||||
const visibleContextPercentLabel = useMemo(() => {
|
||||
const visibleTokens = sumContextInjectionTokens(allContextInjections);
|
||||
return formatPercentOfTotal(visibleTokens, lastAiGroupTotalTokens);
|
||||
}, [allContextInjections, lastAiGroupTotalTokens]);
|
||||
const visibleContextTokens = useMemo(
|
||||
() => sumContextInjectionTokens(allContextInjections),
|
||||
[allContextInjections]
|
||||
);
|
||||
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(
|
||||
() => 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)
|
||||
|
|
@ -839,7 +866,7 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
|
|||
onNavigateToTurn={handleNavigateToTurn}
|
||||
onNavigateToTool={handleNavigateToTool}
|
||||
onNavigateToUserGroup={handleNavigateToUserGroup}
|
||||
totalSessionTokens={lastAiGroupTotalTokens}
|
||||
contextMetrics={contextMetrics}
|
||||
sessionMetrics={sessionDetail?.metrics}
|
||||
subagentCostUsd={subagentCostUsd}
|
||||
onViewReport={effectiveTabId ? () => openSessionReport(effectiveTabId) : undefined}
|
||||
|
|
@ -877,9 +904,9 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
|
|||
: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
{visibleContextPercentLabel ? (
|
||||
{contextUsedPercentLabel ? (
|
||||
<>
|
||||
{visibleContextPercentLabel}
|
||||
{contextUsedPercentLabel}
|
||||
{remainingContext && remainingContext.urgency !== 'normal' && (
|
||||
<span
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import {
|
|||
COLOR_TEXT_MUTED,
|
||||
COLOR_TEXT_SECONDARY,
|
||||
} from '@renderer/constants/cssVariables';
|
||||
import { formatPercentOfTotal } from '@renderer/utils/contextMath';
|
||||
import { formatCostUsd } from '@shared/utils/costFormatting';
|
||||
import { ArrowDownWideNarrow, FileText, LayoutList, X } from 'lucide-react';
|
||||
|
||||
|
|
@ -23,11 +22,12 @@ import { SessionContextHelpTooltip } from './SessionContextHelpTooltip';
|
|||
import type { ContextViewMode } from '../types';
|
||||
import type { ContextPhaseInfo } from '@renderer/types/contextInjection';
|
||||
import type { SessionMetrics } from '@shared/types';
|
||||
import type { DerivedContextMetrics } from '@shared/utils/contextMetrics';
|
||||
|
||||
interface SessionContextHeaderProps {
|
||||
injectionCount: number;
|
||||
totalTokens: number;
|
||||
totalSessionTokens?: number;
|
||||
contextMetrics?: DerivedContextMetrics;
|
||||
sessionMetrics?: SessionMetrics;
|
||||
subagentCostUsd?: number;
|
||||
onClose?: () => void;
|
||||
|
|
@ -42,7 +42,7 @@ interface SessionContextHeaderProps {
|
|||
export const SessionContextHeader = ({
|
||||
injectionCount,
|
||||
totalTokens,
|
||||
totalSessionTokens,
|
||||
contextMetrics,
|
||||
sessionMetrics,
|
||||
subagentCostUsd,
|
||||
onClose,
|
||||
|
|
@ -53,6 +53,45 @@ export const SessionContextHeader = ({
|
|||
viewMode,
|
||||
onViewModeChange,
|
||||
}: 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 (
|
||||
<div className="shrink-0 px-4 py-3" style={{ borderBottom: `1px solid ${COLOR_BORDER}` }}>
|
||||
{/* Title row */}
|
||||
|
|
@ -60,7 +99,7 @@ export const SessionContextHeader = ({
|
|||
<div className="flex items-center gap-2">
|
||||
<FileText size={16} style={{ color: COLOR_TEXT_SECONDARY }} />
|
||||
<h2 className="text-sm font-semibold" style={{ color: COLOR_TEXT }}>
|
||||
Visible Context
|
||||
Context
|
||||
</h2>
|
||||
<span
|
||||
className="rounded px-1.5 py-0.5 text-xs"
|
||||
|
|
@ -87,43 +126,51 @@ export const SessionContextHeader = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Token comparison stats */}
|
||||
{/* Primary metrics */}
|
||||
<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}` }}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Visible Context tokens */}
|
||||
<div>
|
||||
<span style={{ color: COLOR_TEXT_MUTED }}>Visible: </span>
|
||||
<span className="font-medium tabular-nums" style={{ color: COLOR_TEXT_SECONDARY }}>
|
||||
~{formatTokens(totalTokens)}
|
||||
</span>
|
||||
</div>
|
||||
{/* Total Input tokens (if provided) */}
|
||||
{totalSessionTokens !== undefined && totalSessionTokens > 0 && (
|
||||
<div>
|
||||
<span style={{ color: COLOR_TEXT_MUTED }}>Input: </span>
|
||||
<span className="font-medium tabular-nums" style={{ color: COLOR_TEXT_SECONDARY }}>
|
||||
{formatTokens(totalSessionTokens)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Percentage of total */}
|
||||
{formatPercentOfTotal(totalTokens, totalSessionTokens) && (
|
||||
<span
|
||||
className="rounded px-1.5 py-0.5 tabular-nums"
|
||||
style={{
|
||||
backgroundColor: COLOR_SURFACE_OVERLAY,
|
||||
color: COLOR_TEXT_MUTED,
|
||||
}}
|
||||
>
|
||||
{formatPercentOfTotal(totalTokens, totalSessionTokens)}
|
||||
</span>
|
||||
{renderMetricValue(
|
||||
'Context Used',
|
||||
contextMetrics?.contextUsedTokens ?? null,
|
||||
formatPercentLabel(
|
||||
contextMetrics?.contextUsedPercentOfContextWindow ?? null,
|
||||
'of context'
|
||||
)
|
||||
)}
|
||||
{renderMetricValue(
|
||||
'Prompt Input',
|
||||
contextMetrics?.promptInputTokens ?? null,
|
||||
formatPercentLabel(
|
||||
contextMetrics?.promptInputPercentOfContextWindow ?? null,
|
||||
'of context'
|
||||
)
|
||||
)}
|
||||
{renderMetricValue(
|
||||
'Visible Context',
|
||||
totalTokens,
|
||||
formatPercentLabel(
|
||||
contextMetrics?.visibleContextPercentOfPromptInput ?? null,
|
||||
'of prompt'
|
||||
),
|
||||
{ approximate: true }
|
||||
)}
|
||||
</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 */}
|
||||
{sessionMetrics && (
|
||||
<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';
|
||||
|
|
@ -116,64 +116,45 @@ export const SessionContextHelpTooltip = (): React.ReactElement => {
|
|||
<div style={arrowStyle} />
|
||||
|
||||
<div className="space-y-3 text-xs">
|
||||
{/* What is Visible Context */}
|
||||
{/* Metric definitions */}
|
||||
<div>
|
||||
<div className="mb-1 font-semibold" style={{ color: 'var(--color-text)' }}>
|
||||
What is Visible Context?
|
||||
Context Used
|
||||
</div>
|
||||
<p style={{ color: 'var(--color-text-secondary)', lineHeight: 1.5 }}>
|
||||
Tokens consumed by file reads, tool outputs, and configuration files (CLAUDE.md)
|
||||
that are injected into the conversation.
|
||||
Prompt input plus output tokens currently occupying the model's context
|
||||
window.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Difference with Total */}
|
||||
<div className="pt-2" style={{ borderTop: '1px solid var(--color-border-subtle)' }}>
|
||||
<div className="mb-1 font-semibold" style={{ color: 'var(--color-text)' }}>
|
||||
Total Context vs Visible Context
|
||||
</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>
|
||||
Prompt Input
|
||||
</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>
|
||||
|
||||
{/* Tips */}
|
||||
<div className="pt-2" style={{ borderTop: '1px solid var(--color-border-subtle)' }}>
|
||||
<div className="mb-1 font-semibold" style={{ color: 'var(--color-text)' }}>
|
||||
Optimization Tips
|
||||
Visible Context
|
||||
</div>
|
||||
<ul
|
||||
className="space-y-1 pl-3"
|
||||
style={{ color: 'var(--color-text-secondary)', lineHeight: 1.5 }}
|
||||
>
|
||||
<li className="list-disc">Shorten large CLAUDE.md files</li>
|
||||
<li className="list-disc">Split large @-mentioned files</li>
|
||||
<li className="list-disc">Adjust MCP tool output verbosity</li>
|
||||
</ul>
|
||||
<p style={{ color: 'var(--color-text-secondary)', lineHeight: 1.5 }}>
|
||||
The inspectable subset of prompt input: files, CLAUDE.md, tool outputs, user
|
||||
messages, and similar injections that you can optimize directly.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-2" style={{ borderTop: '1px solid var(--color-border-subtle)' }}>
|
||||
<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>,
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ export const SessionContextPanel = ({
|
|||
onNavigateToTurn,
|
||||
onNavigateToTool,
|
||||
onNavigateToUserGroup,
|
||||
totalSessionTokens,
|
||||
contextMetrics,
|
||||
sessionMetrics,
|
||||
subagentCostUsd,
|
||||
onViewReport,
|
||||
|
|
@ -193,7 +193,7 @@ export const SessionContextPanel = ({
|
|||
<SessionContextHeader
|
||||
injectionCount={injections.length}
|
||||
totalTokens={totalTokens}
|
||||
totalSessionTokens={totalSessionTokens}
|
||||
contextMetrics={contextMetrics}
|
||||
sessionMetrics={sessionMetrics}
|
||||
subagentCostUsd={subagentCostUsd}
|
||||
onClose={onClose}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
import type { ClaudeMdSource } from '@renderer/types/claudeMd';
|
||||
import type { ContextInjection, ContextPhaseInfo } from '@renderer/types/contextInjection';
|
||||
import type { SessionMetrics } from '@shared/types';
|
||||
import type { DerivedContextMetrics } from '@shared/utils/contextMetrics';
|
||||
|
||||
// =============================================================================
|
||||
// Props Interface
|
||||
|
|
@ -23,8 +24,8 @@ export interface SessionContextPanelProps {
|
|||
onNavigateToTool?: (turnIndex: number, toolUseId: string) => void;
|
||||
/** Navigate to the user message group preceding the AI group at turnIndex */
|
||||
onNavigateToUserGroup?: (turnIndex: number) => void;
|
||||
/** Total session tokens (input + output + cache) for comparison */
|
||||
totalSessionTokens?: number;
|
||||
/** Unified context metrics for the selected AI group */
|
||||
contextMetrics?: DerivedContextMetrics;
|
||||
/** Full session metrics (input, output, cache tokens, cost) */
|
||||
sessionMetrics?: SessionMetrics;
|
||||
/** Combined cost of all subagent processes */
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'
|
|||
import { useTabUI } from '@renderer/hooks/useTabUI';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
|
||||
import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
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
|
||||
const { members, teams } = useStore(
|
||||
useShallow((s) => ({
|
||||
members: s.selectedTeamData?.members,
|
||||
members: selectResolvedMembersForTeamName(s, s.selectedTeamName),
|
||||
teams: s.teams,
|
||||
}))
|
||||
);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
|
||||
import { detectOperationalNoise } from '@renderer/utils/agentMessageFormatting';
|
||||
import { formatTokensCompact } from '@renderer/utils/formatters';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
|
|
@ -86,7 +87,9 @@ export const TeammateMessageItem: React.FC<TeammateMessageItemProps> = ({
|
|||
const { isLight } = useTheme();
|
||||
|
||||
// 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(
|
||||
() => (members ? buildMemberColorMap(members) : new Map<string, string>()),
|
||||
[members]
|
||||
|
|
|
|||
|
|
@ -71,10 +71,21 @@ interface MarkdownViewerProps {
|
|||
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_TEAM_COLOR_MAP = new Map<string, string>();
|
||||
const NOOP_TEAM_CLICK = (): void => undefined;
|
||||
|
||||
type ViewerMarkdownMode = 'default' | 'compact-preview';
|
||||
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
|
@ -322,53 +333,89 @@ function createViewerMarkdownComponents(
|
|||
isLight = false,
|
||||
teamColorByName: ReadonlyMap<string, string> = new Map(),
|
||||
onTeamClick?: (teamName: string) => void,
|
||||
copyCodeBlocks: boolean = false
|
||||
copyCodeBlocks: boolean = false,
|
||||
mode: ViewerMarkdownMode = 'default'
|
||||
): Components {
|
||||
const hl = (children: React.ReactNode): React.ReactNode =>
|
||||
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 {
|
||||
// Headings
|
||||
h1: ({ children }) => (
|
||||
<h1 className="mb-2 mt-4 text-xl font-semibold first:mt-0" style={{ color: PROSE_HEADING }}>
|
||||
{hl(children)}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="mb-2 mt-4 text-lg font-semibold first:mt-0" style={{ color: PROSE_HEADING }}>
|
||||
{hl(children)}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="mb-2 mt-3 text-base font-semibold first:mt-0" style={{ color: PROSE_HEADING }}>
|
||||
{hl(children)}
|
||||
</h3>
|
||||
),
|
||||
h4: ({ children }) => (
|
||||
<h4 className="mb-1 mt-3 text-sm font-semibold first:mt-0" style={{ color: PROSE_HEADING }}>
|
||||
{hl(children)}
|
||||
</h4>
|
||||
),
|
||||
h5: ({ children }) => (
|
||||
<h5 className="mb-1 mt-2 text-sm font-medium first:mt-0" style={{ color: PROSE_HEADING }}>
|
||||
{hl(children)}
|
||||
</h5>
|
||||
),
|
||||
h6: ({ children }) => (
|
||||
<h6 className="mb-1 mt-2 text-xs font-medium first:mt-0" style={{ color: PROSE_HEADING }}>
|
||||
{hl(children)}
|
||||
</h6>
|
||||
),
|
||||
h1: ({ children }) =>
|
||||
isCompactPreview ? (
|
||||
renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING })
|
||||
) : (
|
||||
<h1 className="mb-2 mt-4 text-xl font-semibold first:mt-0" style={{ color: PROSE_HEADING }}>
|
||||
{hl(children)}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }) =>
|
||||
isCompactPreview ? (
|
||||
renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING })
|
||||
) : (
|
||||
<h2 className="mb-2 mt-4 text-lg font-semibold first:mt-0" style={{ color: PROSE_HEADING }}>
|
||||
{hl(children)}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children }) =>
|
||||
isCompactPreview ? (
|
||||
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)}
|
||||
</h3>
|
||||
),
|
||||
h4: ({ children }) =>
|
||||
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
|
||||
p: ({ children }) => (
|
||||
<p
|
||||
className="my-2 text-sm leading-relaxed first:mt-0 last:mb-0"
|
||||
style={{ color: PROSE_BODY }}
|
||||
>
|
||||
{hl(children)}
|
||||
</p>
|
||||
),
|
||||
p: ({ children }) =>
|
||||
isCompactPreview ? (
|
||||
renderCompactInline(children, '', { color: PROSE_BODY })
|
||||
) : (
|
||||
<p
|
||||
className="my-2 text-sm leading-relaxed first:mt-0 last:mb-0"
|
||||
style={{ color: PROSE_BODY }}
|
||||
>
|
||||
{hl(children)}
|
||||
</p>
|
||||
),
|
||||
|
||||
// Links — inline element, no hl(); parent block element's hl() descends here
|
||||
// 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
|
||||
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
|
||||
const codeEl = node?.children?.[0];
|
||||
if (codeEl && 'tagName' in codeEl && codeEl.tagName === 'code' && 'properties' in codeEl) {
|
||||
|
|
@ -596,74 +657,107 @@ function createViewerMarkdownComponents(
|
|||
},
|
||||
|
||||
// Blockquotes
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote
|
||||
className="my-3 border-l-4 pl-4 italic"
|
||||
style={{
|
||||
borderColor: PROSE_BLOCKQUOTE_BORDER,
|
||||
color: PROSE_MUTED,
|
||||
}}
|
||||
>
|
||||
{hl(children)}
|
||||
</blockquote>
|
||||
),
|
||||
blockquote: ({ children }) =>
|
||||
isCompactPreview ? (
|
||||
renderCompactInline(children, 'italic', { color: PROSE_MUTED })
|
||||
) : (
|
||||
<blockquote
|
||||
className="my-3 border-l-4 pl-4 italic"
|
||||
style={{
|
||||
borderColor: PROSE_BLOCKQUOTE_BORDER,
|
||||
color: PROSE_MUTED,
|
||||
}}
|
||||
>
|
||||
{hl(children)}
|
||||
</blockquote>
|
||||
),
|
||||
|
||||
// Lists
|
||||
ul: ({ children }) => (
|
||||
<ul className="my-2 list-disc space-y-1 pl-5" style={{ color: PROSE_BODY }}>
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="my-2 list-decimal space-y-1 pl-5" style={{ color: PROSE_BODY }}>
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({ children }) => (
|
||||
<li className="text-sm" style={{ color: PROSE_BODY }}>
|
||||
{hl(children)}
|
||||
</li>
|
||||
),
|
||||
ul: ({ children }) =>
|
||||
isCompactPreview ? (
|
||||
<span>{children}</span>
|
||||
) : (
|
||||
<ul className="my-2 list-disc space-y-1 pl-5" style={{ color: PROSE_BODY }}>
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children }) =>
|
||||
isCompactPreview ? (
|
||||
<span>{children}</span>
|
||||
) : (
|
||||
<ol className="my-2 list-decimal space-y-1 pl-5" style={{ color: PROSE_BODY }}>
|
||||
{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
|
||||
table: ({ children }) => (
|
||||
<div className="my-3 overflow-x-auto">
|
||||
<table
|
||||
className="min-w-full border-collapse text-sm"
|
||||
style={{ borderColor: PROSE_TABLE_BORDER }}
|
||||
table: ({ children }) =>
|
||||
isCompactPreview ? (
|
||||
<span>{children}</span>
|
||||
) : (
|
||||
<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}
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }) => (
|
||||
<thead style={{ backgroundColor: PROSE_TABLE_HEADER_BG }}>{children}</thead>
|
||||
),
|
||||
th: ({ children }) => (
|
||||
<th
|
||||
className="px-3 py-2 text-left font-semibold"
|
||||
style={{
|
||||
border: `1px solid ${PROSE_TABLE_BORDER}`,
|
||||
color: PROSE_HEADING,
|
||||
}}
|
||||
>
|
||||
{hl(children)}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td
|
||||
className="px-3 py-2"
|
||||
style={{
|
||||
border: `1px solid ${PROSE_TABLE_BORDER}`,
|
||||
color: PROSE_BODY,
|
||||
}}
|
||||
>
|
||||
{hl(children)}
|
||||
</td>
|
||||
),
|
||||
{hl(children)}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }) =>
|
||||
isCompactPreview ? (
|
||||
renderCompactInline(children, '', { color: PROSE_BODY })
|
||||
) : (
|
||||
<td
|
||||
className="px-3 py-2"
|
||||
style={{
|
||||
border: `1px solid ${PROSE_TABLE_BORDER}`,
|
||||
color: PROSE_BODY,
|
||||
}}
|
||||
>
|
||||
{hl(children)}
|
||||
</td>
|
||||
),
|
||||
|
||||
// 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
|
||||
// =============================================================================
|
||||
|
||||
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> = ({
|
||||
content,
|
||||
maxHeight = 'max-h-96',
|
||||
|
|
@ -695,24 +861,10 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
|||
const [showRaw, setShowRaw] = React.useState(false);
|
||||
const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS);
|
||||
const { isLight } = useTheme();
|
||||
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]);
|
||||
const teamColorByName =
|
||||
providedTeamColorByName ?? fallbackTeamColorByName ?? EMPTY_TEAM_COLOR_MAP;
|
||||
const onTeamClick = providedOnTeamClick ?? openTeamTab;
|
||||
const { teamColorByName, onTeamClick } = useResolvedViewerTeamContext(
|
||||
providedTeamColorByName,
|
||||
providedOnTeamClick
|
||||
);
|
||||
|
||||
const isTooLarge = content.length > MAX_MARKDOWN_CHARS;
|
||||
const disableHighlight = content.length > DISABLE_HIGHLIGHT_CHARS;
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ interface OngoingIndicatorProps {
|
|||
showLabel?: boolean;
|
||||
/** Custom label text */
|
||||
label?: string;
|
||||
/** Accessible title/tooltip text */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -24,11 +26,12 @@ export const OngoingIndicator = ({
|
|||
size = 'sm',
|
||||
showLabel = false,
|
||||
label = 'Session in progress...',
|
||||
title = label,
|
||||
}: Readonly<OngoingIndicatorProps>): React.JSX.Element => {
|
||||
const dotSize = size === 'sm' ? 'h-2 w-2' : 'h-2.5 w-2.5';
|
||||
|
||||
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="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`} />
|
||||
|
|
|
|||
|
|
@ -48,8 +48,6 @@ interface TokenUsageDisplayProps {
|
|||
totalPhases?: number;
|
||||
/** Optional USD cost for this usage */
|
||||
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 = ({
|
||||
contextStats,
|
||||
totalInputTokens,
|
||||
contextWindowSize,
|
||||
}: Readonly<{
|
||||
contextStats: ContextStats;
|
||||
totalInputTokens: number;
|
||||
contextWindowSize?: number;
|
||||
}>): React.JSX.Element => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const { tokensByCategory } = contextStats;
|
||||
|
||||
// contextStats.totalEstimatedTokens already includes all categories (CLAUDE.md, @files,
|
||||
// tool outputs, thinking+text, task coordination, user messages) — no manual adjustment needed.
|
||||
// Show context window usage % when contextWindowSize is available (more useful),
|
||||
// otherwise fall back to visible context / total input ratio.
|
||||
// tool outputs, thinking+text, task coordination, user messages) - no manual adjustment needed.
|
||||
// Visible Context is always shown as a share of prompt-side input tokens so this section
|
||||
// stays aligned with the unified context contract instead of silently switching semantics.
|
||||
const contextPercent =
|
||||
contextWindowSize && contextWindowSize > 0
|
||||
? Math.min((totalInputTokens / contextWindowSize) * 100, 100).toFixed(1)
|
||||
: totalInputTokens > 0
|
||||
? Math.min((contextStats.totalEstimatedTokens / totalInputTokens) * 100, 100).toFixed(1)
|
||||
: '0.0';
|
||||
const contextLabel = contextWindowSize ? 'of context' : 'of input';
|
||||
totalInputTokens > 0
|
||||
? Math.min((contextStats.totalEstimatedTokens / totalInputTokens) * 100, 100).toFixed(1)
|
||||
: '0.0';
|
||||
|
||||
// Count accumulated injections by category
|
||||
const claudeMdCount = contextStats.accumulatedInjections.filter(
|
||||
|
|
@ -152,7 +145,7 @@ const SessionContextSection = ({
|
|||
className="whitespace-nowrap text-[10px] tabular-nums"
|
||||
style={{ color: COLOR_TEXT_MUTED }}
|
||||
>
|
||||
{formatTokens(contextStats.totalEstimatedTokens)} ({contextPercent}% {contextLabel})
|
||||
{formatTokens(contextStats.totalEstimatedTokens)} ({contextPercent}% of prompt input)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
|
@ -261,10 +254,9 @@ export const TokenUsageDisplay = ({
|
|||
phaseNumber,
|
||||
totalPhases,
|
||||
costUsd,
|
||||
contextWindowSize,
|
||||
}: Readonly<TokenUsageDisplayProps>): React.JSX.Element => {
|
||||
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 formattedTotal = formatTokens(totalTokens);
|
||||
|
||||
|
|
@ -540,7 +532,6 @@ export const TokenUsageDisplay = ({
|
|||
<SessionContextSection
|
||||
contextStats={contextStats}
|
||||
totalInputTokens={totalInputTokens}
|
||||
contextWindowSize={contextWindowSize}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ import { useShallow } from 'zustand/react/shallow';
|
|||
|
||||
import { resolveSkillProjectPath } from './skillProjectUtils';
|
||||
|
||||
import type { SkillValidationIssue } from '@shared/types';
|
||||
import type { SkillValidationIssue } from '@shared/types/extensions';
|
||||
|
||||
interface SkillDetailDialogProps {
|
||||
skillId: string | null;
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ export interface SafeConfig {
|
|||
notifyOnCrossTeamMessage: boolean;
|
||||
notifyOnTeamLaunched: boolean;
|
||||
notifyOnToolApproval: boolean;
|
||||
autoResumeOnRateLimit: boolean;
|
||||
statusChangeOnlySolo: boolean;
|
||||
statusChangeStatuses: string[];
|
||||
triggers: AppConfig['notifications']['triggers'];
|
||||
|
|
@ -195,6 +196,7 @@ export function useSettingsConfig(): UseSettingsConfigReturn {
|
|||
notifyOnCrossTeamMessage: displayConfig?.notifications?.notifyOnCrossTeamMessage ?? true,
|
||||
notifyOnTeamLaunched: displayConfig?.notifications?.notifyOnTeamLaunched ?? true,
|
||||
notifyOnToolApproval: displayConfig?.notifications?.notifyOnToolApproval ?? true,
|
||||
autoResumeOnRateLimit: displayConfig?.notifications?.autoResumeOnRateLimit ?? false,
|
||||
statusChangeOnlySolo: displayConfig?.notifications?.statusChangeOnlySolo ?? true,
|
||||
statusChangeStatuses: displayConfig?.notifications?.statusChangeStatuses ?? [
|
||||
'in_progress',
|
||||
|
|
|
|||
|
|
@ -311,6 +311,7 @@ export function useSettingsHandlers({
|
|||
notifyOnCrossTeamMessage: true,
|
||||
notifyOnTeamLaunched: true,
|
||||
notifyOnToolApproval: true,
|
||||
autoResumeOnRateLimit: false,
|
||||
statusChangeOnlySolo: true,
|
||||
statusChangeStatuses: ['in_progress', 'completed'],
|
||||
triggers: defaultTriggers,
|
||||
|
|
|
|||
|
|
@ -172,7 +172,7 @@ export const AdvancedSection = ({
|
|||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
|
||||
Claude Agent Teams UI
|
||||
Agent Teams UI
|
||||
</p>
|
||||
{isElectron && (
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ interface NotificationsSectionProps {
|
|||
| 'notifyOnCrossTeamMessage'
|
||||
| 'notifyOnTeamLaunched'
|
||||
| 'notifyOnToolApproval'
|
||||
| 'autoResumeOnRateLimit'
|
||||
| 'statusChangeOnlySolo',
|
||||
value: boolean
|
||||
) => void;
|
||||
|
|
@ -360,6 +361,17 @@ export const NotificationsSection = ({
|
|||
disabled={saving || !safeConfig.notifications.enabled}
|
||||
/>
|
||||
</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 */}
|
||||
<div className="last:*:border-b-0">
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
getNonEmptyTaskCategories,
|
||||
groupTasksByDate,
|
||||
groupTasksByProject,
|
||||
NO_PROJECT_KEY,
|
||||
sortTasksByFreshness,
|
||||
} from '@renderer/utils/taskGrouping';
|
||||
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
|
||||
|
|
@ -33,6 +34,14 @@ import { AnimatedHeightReveal } from '../team/activity/AnimatedHeightReveal';
|
|||
import { type ComboboxOption } from '../ui/combobox';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
||||
|
||||
import {
|
||||
canProjectGroupShowLess,
|
||||
canProjectGroupShowMore,
|
||||
getNextProjectGroupVisibleCount,
|
||||
getPreviousProjectGroupVisibleCount,
|
||||
getProjectGroupVisibleCount,
|
||||
syncProjectGroupVisibleCountByKey,
|
||||
} from './projectGroupPagination';
|
||||
import { SidebarTaskItem } from './SidebarTaskItem';
|
||||
import { TaskContextMenu } from './TaskContextMenu';
|
||||
import { TaskFiltersPopover } from './TaskFiltersPopover';
|
||||
|
|
@ -207,6 +216,9 @@ export const GlobalTaskList = ({
|
|||
const [sortPopoverOpen, setSortPopoverOpen] = useState(false);
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [renamingTaskKey, setRenamingTaskKey] = useState<string | null>(null);
|
||||
const [projectRequestedVisibleCountByKey, setProjectRequestedVisibleCountByKey] = useState<
|
||||
Record<string, number>
|
||||
>({});
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const hasFetchedRef = useRef(false);
|
||||
const readState = useReadStateSnapshot();
|
||||
|
|
@ -221,21 +233,23 @@ export const GlobalTaskList = ({
|
|||
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) {
|
||||
isInitialTaskLoadRef.current = false;
|
||||
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}`);
|
||||
}
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
// Subsequent updates: detect truly new tasks
|
||||
const newIds = new Set<string>();
|
||||
for (const t of globalTasks) {
|
||||
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)) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -326,6 +340,11 @@ export const GlobalTaskList = ({
|
|||
|
||||
// Resolve project filter from filters state
|
||||
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(() => {
|
||||
let result = globalTasks;
|
||||
|
|
@ -345,7 +364,7 @@ export const GlobalTaskList = ({
|
|||
}
|
||||
result = applySearch(result, searchQuery);
|
||||
// Archive filtering
|
||||
if (showArchived) {
|
||||
if (effectiveShowArchived) {
|
||||
result = result.filter((t) => taskLocalState.isArchived(t.teamName, t.id));
|
||||
} else {
|
||||
result = result.filter((t) => !taskLocalState.isArchived(t.teamName, t.id));
|
||||
|
|
@ -353,29 +372,16 @@ export const GlobalTaskList = ({
|
|||
return result;
|
||||
}, [
|
||||
globalTasks,
|
||||
filters.projectPath,
|
||||
selectedProjectPath,
|
||||
filters.statusIds,
|
||||
filters.teamName,
|
||||
filters.readFilter,
|
||||
searchQuery,
|
||||
readState,
|
||||
showArchived,
|
||||
effectiveShowArchived,
|
||||
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
|
||||
const pinnedTasks = useMemo(
|
||||
() => filtered.filter((t) => taskLocalState.isPinned(t.teamName, t.id)),
|
||||
|
|
@ -400,6 +406,19 @@ export const GlobalTaskList = ({
|
|||
[projectGroups]
|
||||
);
|
||||
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 timeCollapsed = useCollapsedGroups('time', timeGroupKeys);
|
||||
|
|
@ -412,8 +431,18 @@ export const GlobalTaskList = ({
|
|||
? categories.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 (
|
||||
<div className="flex size-full min-w-0 flex-col">
|
||||
<div className="flex size-full min-w-0 flex-col overflow-x-hidden">
|
||||
{!hideHeader && (
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-2 border-b px-3 py-1.5"
|
||||
|
|
@ -499,7 +528,7 @@ export const GlobalTaskList = ({
|
|||
</div>
|
||||
|
||||
{/* Pinned tasks section */}
|
||||
{pinnedTasks.length > 0 && !showArchived && (
|
||||
{pinnedTasks.length > 0 && !effectiveShowArchived && (
|
||||
<div className="shrink-0 border-b" style={{ borderColor: 'var(--color-border)' }}>
|
||||
<div className="flex items-center gap-1 px-2 py-1">
|
||||
<Pin className="size-3 text-text-muted" />
|
||||
|
|
@ -562,7 +591,7 @@ export const GlobalTaskList = ({
|
|||
onClick={() => setShowArchived(!showArchived)}
|
||||
className={cn(
|
||||
'rounded p-0.5 transition-colors',
|
||||
showArchived
|
||||
effectiveShowArchived
|
||||
? 'bg-surface-raised text-text-secondary'
|
||||
: 'text-text-muted hover:text-text-secondary'
|
||||
)}
|
||||
|
|
@ -571,7 +600,7 @@ export const GlobalTaskList = ({
|
|||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{showArchived ? 'Hide archived' : 'Show archived'}
|
||||
{effectiveShowArchived ? 'Hide archived' : 'Show archived'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
|
@ -579,7 +608,7 @@ export const GlobalTaskList = ({
|
|||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden">
|
||||
{globalTasksLoading && !globalTasksInitialized && (
|
||||
<div className="space-y-2 p-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
|
|
@ -626,15 +655,31 @@ export const GlobalTaskList = ({
|
|||
projectGroups.map((group) => {
|
||||
if (group.tasks.length === 0) return null;
|
||||
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;
|
||||
return (
|
||||
<div key={group.projectKey}>
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
style={{ backgroundColor: 'var(--color-surface-sidebar)' }}
|
||||
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)',
|
||||
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 ? (
|
||||
<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" />
|
||||
)}
|
||||
<Folder
|
||||
className="size-3 shrink-0"
|
||||
style={{ color: groupColor.border }}
|
||||
className="size-3.5 shrink-0"
|
||||
style={{ color: groupColor.icon }}
|
||||
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}
|
||||
</span>
|
||||
<span className="ml-auto shrink-0 text-[10px] font-normal text-text-muted">
|
||||
|
|
@ -654,7 +702,7 @@ export const GlobalTaskList = ({
|
|||
</span>
|
||||
</button>
|
||||
{!isGroupCollapsed &&
|
||||
group.tasks.map((task) => {
|
||||
visibleTasks.map((task) => {
|
||||
const showTeamHeader = task.teamName !== lastTeam;
|
||||
lastTeam = task.teamName;
|
||||
return (
|
||||
|
|
@ -691,6 +739,44 @@ export const GlobalTaskList = ({
|
|||
</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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -144,11 +144,13 @@ export const SidebarTaskItem = ({
|
|||
);
|
||||
|
||||
const showTeamRow = showTeamName && !hideTeamName;
|
||||
const unreadBackgroundClass =
|
||||
unreadCount > 0 ? (isLight ? 'bg-blue-500/[0.03]' : 'bg-blue-500/[0.05]') : '';
|
||||
|
||||
return (
|
||||
<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)' }}
|
||||
onClick={() => {
|
||||
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 { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
|
||||
import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers';
|
||||
import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils';
|
||||
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
||||
|
|
@ -70,14 +71,16 @@ export const TaskTooltip = ({
|
|||
children,
|
||||
side = 'top',
|
||||
}: TaskTooltipProps): React.JSX.Element => {
|
||||
const { selectedTeamName, selectedTeamData, globalTasks, teamByName } = useStore(
|
||||
useShallow((s) => ({
|
||||
selectedTeamName: s.selectedTeamName,
|
||||
selectedTeamData: s.selectedTeamData,
|
||||
globalTasks: s.globalTasks,
|
||||
teamByName: s.teamByName,
|
||||
}))
|
||||
);
|
||||
const { selectedTeamName, selectedTeamData, selectedTeamMembers, globalTasks, teamByName } =
|
||||
useStore(
|
||||
useShallow((s) => ({
|
||||
selectedTeamName: s.selectedTeamName,
|
||||
selectedTeamData: s.selectedTeamData,
|
||||
selectedTeamMembers: selectResolvedMembersForTeamName(s, s.selectedTeamName),
|
||||
globalTasks: s.globalTasks,
|
||||
teamByName: s.teamByName,
|
||||
}))
|
||||
);
|
||||
|
||||
const task = useMemo(() => {
|
||||
if (teamName && selectedTeamName === teamName) {
|
||||
|
|
@ -105,13 +108,13 @@ export const TaskTooltip = ({
|
|||
|
||||
const members = useMemo(() => {
|
||||
if (teamName && selectedTeamName === teamName) {
|
||||
return selectedTeamData?.members ?? [];
|
||||
return selectedTeamMembers;
|
||||
}
|
||||
if (!teamName && task && selectedTeamName === (task as { teamName?: string }).teamName) {
|
||||
return selectedTeamData?.members ?? [];
|
||||
return selectedTeamMembers;
|
||||
}
|
||||
return [];
|
||||
}, [selectedTeamData, selectedTeamName, teamName, task]);
|
||||
}, [selectedTeamMembers, selectedTeamName, teamName, task]);
|
||||
|
||||
const colorMap = useMemo(
|
||||
() => (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 { SessionContextPanel } from '@renderer/components/chat/SessionContextPanel/index';
|
||||
|
|
@ -23,9 +33,12 @@ import { useStore } from '@renderer/store';
|
|||
import {
|
||||
getCurrentProvisioningProgressForTeam,
|
||||
isTeamProvisioningActive,
|
||||
selectResolvedMemberForTeamName,
|
||||
selectResolvedMembersForTeamName,
|
||||
selectTeamMemberSnapshotsForName,
|
||||
} from '@renderer/store/slices/teamSlice';
|
||||
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 { buildTaskCountsByOwner, normalizePath } from '@renderer/utils/pathNormalize';
|
||||
import { nameColorSet } from '@renderer/utils/projectColor';
|
||||
|
|
@ -35,6 +48,7 @@ import {
|
|||
type TaskChangeRequestOptions,
|
||||
} from '@renderer/utils/taskChangeRequest';
|
||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||
import { deriveContextMetrics } from '@shared/utils/contextMetrics';
|
||||
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
|
|
@ -111,9 +125,11 @@ import type {
|
|||
MemberSpawnStatusEntry,
|
||||
ResolvedTeamMember,
|
||||
TaskRef,
|
||||
TeamAgentRuntimeEntry,
|
||||
TeamTaskWithKanban,
|
||||
} from '@shared/types';
|
||||
import type { EditorSelectionAction } from '@shared/types/editor';
|
||||
import type { ContextUsageLike } from '@shared/utils/contextMetrics';
|
||||
|
||||
interface TeamDetailViewProps {
|
||||
teamName: string;
|
||||
|
|
@ -286,7 +302,7 @@ type TeamMemberListBridgeProps = Omit<
|
|||
};
|
||||
type TeamMemberDetailDialogBridgeProps = Omit<
|
||||
ComponentProps<typeof MemberDetailDialog>,
|
||||
'leadActivity' | 'spawnEntry'
|
||||
'leadActivity' | 'spawnEntry' | 'runtimeEntry'
|
||||
>;
|
||||
type TeamSidebarRailBridgeProps = Omit<
|
||||
ComponentProps<typeof TeamSidebarRail>,
|
||||
|
|
@ -324,6 +340,17 @@ function buildMemberSpawnStatusMap(
|
|||
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({
|
||||
teamName,
|
||||
isTeamProvisioning,
|
||||
|
|
@ -361,6 +388,54 @@ const TeamSpawnStatusWatcher = memo(function TeamSpawnStatusWatcher({
|
|||
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({
|
||||
teamName,
|
||||
tabId,
|
||||
|
|
@ -445,6 +520,7 @@ const LeadContextBridge = memo(function LeadContextBridge({
|
|||
}: LeadContextBridgeProps): React.JSX.Element | null {
|
||||
const {
|
||||
leadTabData,
|
||||
leadContextSnapshot,
|
||||
isContextPanelVisible,
|
||||
selectedContextPhase,
|
||||
setContextPanelVisibleForTab,
|
||||
|
|
@ -453,6 +529,7 @@ const LeadContextBridge = memo(function LeadContextBridge({
|
|||
} = useStore(
|
||||
useShallow((s) => ({
|
||||
leadTabData: tabId ? (s.tabSessionData[tabId] ?? null) : null,
|
||||
leadContextSnapshot: s.leadContextByTeam[teamName] ?? null,
|
||||
isContextPanelVisible: tabId ? (s.tabUIStates.get(tabId)?.showContextPanel ?? false) : false,
|
||||
selectedContextPhase: tabId ? (s.tabUIStates.get(tabId)?.selectedContextPhase ?? null) : null,
|
||||
setContextPanelVisibleForTab: s.setContextPanelVisibleForTab,
|
||||
|
|
@ -491,9 +568,13 @@ const LeadContextBridge = memo(function LeadContextBridge({
|
|||
const total = processes.reduce((sum, p) => sum + (p.metrics.costUsd ?? 0), 0);
|
||||
return total > 0 ? total : undefined;
|
||||
}, [leadSessionDetail?.processes]);
|
||||
const { allContextInjections, lastAiGroupTotalTokens } = useMemo(() => {
|
||||
const { allContextInjections, lastAssistantUsage, lastAssistantModelName } = useMemo(() => {
|
||||
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;
|
||||
|
|
@ -511,7 +592,8 @@ const LeadContextBridge = memo(function LeadContextBridge({
|
|||
if (lastAiItem?.type !== 'ai') {
|
||||
return {
|
||||
allContextInjections: [] as ContextInjection[],
|
||||
lastAiGroupTotalTokens: undefined,
|
||||
lastAssistantUsage: null,
|
||||
lastAssistantModelName: undefined,
|
||||
};
|
||||
}
|
||||
targetAiGroupId = lastAiItem.group.id;
|
||||
|
|
@ -520,7 +602,8 @@ const LeadContextBridge = memo(function LeadContextBridge({
|
|||
const stats = leadSessionContextStats.get(targetAiGroupId);
|
||||
const injections = stats?.accumulatedInjections ?? [];
|
||||
|
||||
let totalTokens: number | undefined;
|
||||
let lastUsage: ContextUsageLike | null = null;
|
||||
let lastModelName: string | undefined;
|
||||
const targetItem = leadConversation.items.find(
|
||||
(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--) {
|
||||
const msg = responses[i];
|
||||
if (msg.type === 'assistant' && msg.usage) {
|
||||
const usage = msg.usage;
|
||||
totalTokens =
|
||||
(usage.input_tokens ?? 0) +
|
||||
(usage.output_tokens ?? 0) +
|
||||
(usage.cache_read_input_tokens ?? 0) +
|
||||
(usage.cache_creation_input_tokens ?? 0);
|
||||
lastUsage = msg.usage;
|
||||
lastModelName = msg.model;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { allContextInjections: injections, lastAiGroupTotalTokens: totalTokens };
|
||||
return {
|
||||
allContextInjections: injections,
|
||||
lastAssistantUsage: lastUsage,
|
||||
lastAssistantModelName: lastModelName,
|
||||
};
|
||||
}, [
|
||||
leadConversation,
|
||||
leadSessionContextStats,
|
||||
|
|
@ -552,10 +635,26 @@ const LeadContextBridge = memo(function LeadContextBridge({
|
|||
() => sumContextInjectionTokens(allContextInjections),
|
||||
[allContextInjections]
|
||||
);
|
||||
const visibleContextPercentLabel = useMemo(
|
||||
() => formatPercentOfTotal(visibleContextTokens, lastAiGroupTotalTokens),
|
||||
[visibleContextTokens, lastAiGroupTotalTokens]
|
||||
const contextMetrics = useMemo(
|
||||
() =>
|
||||
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) {
|
||||
return null;
|
||||
|
|
@ -570,7 +669,7 @@ const LeadContextBridge = memo(function LeadContextBridge({
|
|||
injections={allContextInjections}
|
||||
onClose={() => setContextPanelVisible(false)}
|
||||
projectRoot={leadSessionDetail?.session?.projectPath ?? fallbackProjectRoot}
|
||||
totalSessionTokens={lastAiGroupTotalTokens}
|
||||
contextMetrics={contextMetrics}
|
||||
sessionMetrics={leadSessionDetail?.metrics}
|
||||
subagentCostUsd={leadSubagentCostUsd}
|
||||
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="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)]">
|
||||
{leadSessionLoading ? 'Loading…' : 'No session loaded'}
|
||||
</p>
|
||||
|
|
@ -644,7 +743,7 @@ const LeadContextBridge = memo(function LeadContextBridge({
|
|||
: leadSessionId
|
||||
}
|
||||
>
|
||||
{visibleContextPercentLabel ?? 'Context'}
|
||||
{contextUsedPercentLabel ?? 'Context'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
|
|
@ -655,18 +754,24 @@ const TeamMemberListBridge = memo(function TeamMemberListBridge({
|
|||
teamName,
|
||||
...props
|
||||
}: TeamMemberListBridgeProps): React.JSX.Element {
|
||||
const { leadActivity, progress, memberSpawnStatuses, memberSpawnSnapshot } = useStore(
|
||||
useShallow((s) => ({
|
||||
leadActivity: s.leadActivityByTeam[teamName],
|
||||
progress: getCurrentProvisioningProgressForTeam(s, teamName),
|
||||
memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName],
|
||||
memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName],
|
||||
}))
|
||||
);
|
||||
const { leadActivity, progress, memberSpawnStatuses, memberSpawnSnapshot, runtimeSnapshot } =
|
||||
useStore(
|
||||
useShallow((s) => ({
|
||||
leadActivity: s.leadActivityByTeam[teamName],
|
||||
progress: getCurrentProvisioningProgressForTeam(s, teamName),
|
||||
memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName],
|
||||
memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName],
|
||||
runtimeSnapshot: s.teamAgentRuntimeByTeam[teamName],
|
||||
}))
|
||||
);
|
||||
const memberSpawnStatusMap = useMemo(
|
||||
() => buildMemberSpawnStatusMap(memberSpawnStatuses),
|
||||
[memberSpawnStatuses]
|
||||
);
|
||||
const memberRuntimeMap = useMemo(
|
||||
() => buildTeamAgentRuntimeMap(runtimeSnapshot?.members),
|
||||
[runtimeSnapshot?.members]
|
||||
);
|
||||
const isLaunchSettling = useMemo(() => {
|
||||
if (progress?.state !== 'ready') {
|
||||
return false;
|
||||
|
|
@ -685,6 +790,7 @@ const TeamMemberListBridge = memo(function TeamMemberListBridge({
|
|||
{...props}
|
||||
leadActivity={leadActivity}
|
||||
memberSpawnStatuses={memberSpawnStatusMap}
|
||||
memberRuntimeEntries={memberRuntimeMap}
|
||||
isLaunchSettling={isLaunchSettling}
|
||||
/>
|
||||
);
|
||||
|
|
@ -740,19 +846,23 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge(
|
|||
}: TeamMemberDetailDialogBridgeProps): React.JSX.Element | null {
|
||||
const {
|
||||
leadActivity,
|
||||
liveMember,
|
||||
progress,
|
||||
members: launchMembers,
|
||||
launchMembers,
|
||||
memberSpawnStatuses,
|
||||
memberSpawnSnapshot,
|
||||
spawnEntry,
|
||||
runtimeEntry,
|
||||
} = useStore(
|
||||
useShallow((s) => ({
|
||||
leadActivity: s.leadActivityByTeam[teamName],
|
||||
liveMember: member ? selectResolvedMemberForTeamName(s, teamName, member.name) : null,
|
||||
progress: getCurrentProvisioningProgressForTeam(s, teamName),
|
||||
members: s.selectedTeamName === teamName ? (s.selectedTeamData?.members ?? []) : [],
|
||||
launchMembers: selectTeamMemberSnapshotsForName(s, teamName),
|
||||
memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName],
|
||||
memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName],
|
||||
spawnEntry: member ? s.memberSpawnStatusesByTeam[teamName]?.[member.name] : undefined,
|
||||
runtimeEntry: member ? s.teamAgentRuntimeByTeam[teamName]?.members[member.name] : undefined,
|
||||
}))
|
||||
);
|
||||
const isLaunchSettling = useMemo(() => {
|
||||
|
|
@ -772,10 +882,11 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge(
|
|||
<MemberDetailDialog
|
||||
{...props}
|
||||
teamName={teamName}
|
||||
member={member}
|
||||
member={liveMember ?? member}
|
||||
isLaunchSettling={isLaunchSettling}
|
||||
leadActivity={leadActivity}
|
||||
spawnEntry={spawnEntry}
|
||||
runtimeEntry={runtimeEntry}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
|
@ -821,7 +932,6 @@ export const TeamDetailView = ({
|
|||
);
|
||||
const provisioningBannerRef = useRef<HTMLDivElement>(null);
|
||||
const wasProvisioningRef = useRef(false);
|
||||
const pendingReplyRefreshTimerRef = useRef<number | null>(null);
|
||||
const handleOpenGraphTab = useCallback(() => {
|
||||
const state = useStore.getState();
|
||||
const displayName = state.teamByName[teamName]?.displayName ?? teamName;
|
||||
|
|
@ -898,7 +1008,7 @@ export const TeamDetailView = ({
|
|||
initialActivityFilter,
|
||||
} = (e as CustomEvent).detail ?? {};
|
||||
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) {
|
||||
setSelectedMember(member);
|
||||
setSelectedMemberView({
|
||||
|
|
@ -1059,6 +1169,7 @@ export const TeamDetailView = ({
|
|||
|
||||
const {
|
||||
data,
|
||||
members,
|
||||
loading,
|
||||
error,
|
||||
projects,
|
||||
|
|
@ -1081,6 +1192,7 @@ export const TeamDetailView = ({
|
|||
lastSendMessageResult,
|
||||
reviewActionError,
|
||||
addMember,
|
||||
restartMember,
|
||||
removeMember,
|
||||
updateMemberRole,
|
||||
launchTeam,
|
||||
|
|
@ -1088,6 +1200,7 @@ export const TeamDetailView = ({
|
|||
clearProvisioningError,
|
||||
isTeamProvisioning,
|
||||
refreshTeamData,
|
||||
syncTeamPendingReplyRefresh,
|
||||
kanbanFilterQuery,
|
||||
clearKanbanFilter,
|
||||
softDeleteTask,
|
||||
|
|
@ -1126,6 +1239,7 @@ export const TeamDetailView = ({
|
|||
lastSendMessageResult: s.lastSendMessageResult,
|
||||
reviewActionError: s.reviewActionError,
|
||||
addMember: s.addMember,
|
||||
restartMember: s.restartMember,
|
||||
removeMember: s.removeMember,
|
||||
updateMemberRole: s.updateMemberRole,
|
||||
launchTeam: s.launchTeam,
|
||||
|
|
@ -1133,9 +1247,11 @@ export const TeamDetailView = ({
|
|||
clearProvisioningError: s.clearProvisioningError,
|
||||
isTeamProvisioning: teamName ? isTeamProvisioningActive(s, teamName) : false,
|
||||
data: s.selectedTeamName === teamName ? s.selectedTeamData : null,
|
||||
members: selectResolvedMembersForTeamName(s, teamName),
|
||||
loading: s.selectedTeamName === teamName ? s.selectedTeamLoading : false,
|
||||
error: s.selectedTeamName === teamName ? s.selectedTeamError : null,
|
||||
refreshTeamData: s.refreshTeamData,
|
||||
syncTeamPendingReplyRefresh: s.syncTeamPendingReplyRefresh,
|
||||
kanbanFilterQuery: s.kanbanFilterQuery,
|
||||
clearKanbanFilter: s.clearKanbanFilter,
|
||||
softDeleteTask: s.softDeleteTask,
|
||||
|
|
@ -1169,13 +1285,12 @@ export const TeamDetailView = ({
|
|||
diagnostic.count += 1;
|
||||
|
||||
const commitMs = performance.now() - renderStartedAtRef.current;
|
||||
const messagesCount = data?.messages.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 shouldWarnSlow = commitMs >= TEAM_DETAIL_COMMIT_WARN_MS;
|
||||
const shouldWarnBurst = diagnostic.count >= TEAM_DETAIL_RENDER_BURST_WARN_COUNT;
|
||||
const shouldWarnLarge = messagesCount >= 150 || tasksCount >= 80;
|
||||
const shouldWarnLarge = tasksCount >= 80;
|
||||
|
||||
if (
|
||||
(shouldWarnSlow || shouldWarnBurst || shouldWarnLarge) &&
|
||||
|
|
@ -1187,7 +1302,7 @@ export const TeamDetailView = ({
|
|||
now - diagnostic.windowStartedAt
|
||||
} activeTab=${isThisTabActive ? 'yes' : 'no'} paneFocused=${isPaneFocused ? 'yes' : 'no'} loading=${
|
||||
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 pendingReplyRefreshSourceId = useId();
|
||||
const sessionHistoryKey = useMemo(
|
||||
() => (data?.config.sessionHistory ?? []).join('|'),
|
||||
[data?.config.sessionHistory]
|
||||
);
|
||||
|
||||
// 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
|
||||
// don't keep rewriting the whole team snapshot every 2 seconds.
|
||||
// This stays enabled even for hidden mounted tabs, because the waiting state
|
||||
// is renderer-local and should keep its lightweight polling until resolved.
|
||||
useEffect(() => {
|
||||
if (pendingReplyRefreshTimerRef.current != null) {
|
||||
window.clearTimeout(pendingReplyRefreshTimerRef.current);
|
||||
pendingReplyRefreshTimerRef.current = null;
|
||||
}
|
||||
|
||||
if (!isThisTabActive) return;
|
||||
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);
|
||||
const hasPendingReplies = Object.keys(pendingRepliesByMember).length > 0;
|
||||
syncTeamPendingReplyRefresh(
|
||||
teamName,
|
||||
pendingReplyRefreshSourceId,
|
||||
Boolean(data?.isAlive) && hasPendingReplies,
|
||||
TEAM_PENDING_REPLY_REFRESH_DELAY_MS
|
||||
);
|
||||
|
||||
return () => {
|
||||
if (pendingReplyRefreshTimerRef.current != null) {
|
||||
window.clearTimeout(pendingReplyRefreshTimerRef.current);
|
||||
pendingReplyRefreshTimerRef.current = null;
|
||||
}
|
||||
syncTeamPendingReplyRefresh(teamName, pendingReplyRefreshSourceId, false);
|
||||
};
|
||||
}, [isThisTabActive, data, pendingRepliesByMember, refreshTeamData, teamName]);
|
||||
}, [
|
||||
data?.isAlive,
|
||||
pendingRepliesByMember,
|
||||
pendingReplyRefreshSourceId,
|
||||
syncTeamPendingReplyRefresh,
|
||||
teamName,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) return;
|
||||
|
|
@ -1364,9 +1477,9 @@ export const TeamDetailView = ({
|
|||
// Live git branch tracking for the lead project and member worktrees
|
||||
const teamProjectPath = data?.config.projectPath?.trim() ?? null;
|
||||
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;
|
||||
}, [data?.members, teamProjectPath]);
|
||||
}, [members, teamProjectPath]);
|
||||
const branchSyncPaths = useMemo(() => {
|
||||
const uniquePaths = new Map<string, string>();
|
||||
const addPath = (candidate: string | null | undefined): void => {
|
||||
|
|
@ -1378,12 +1491,12 @@ export const TeamDetailView = ({
|
|||
};
|
||||
|
||||
addPath(leadProjectPath);
|
||||
for (const member of data?.members ?? []) {
|
||||
for (const member of members) {
|
||||
addPath(member.cwd);
|
||||
}
|
||||
|
||||
return Array.from(uniquePaths.values());
|
||||
}, [data?.members, leadProjectPath]);
|
||||
}, [members, leadProjectPath]);
|
||||
useBranchSync(branchSyncPaths, { live: true });
|
||||
const trackedBranches = useStore(
|
||||
useShallow((s) =>
|
||||
|
|
@ -1401,7 +1514,7 @@ export const TeamDetailView = ({
|
|||
const membersWithLiveBranches = useMemo(() => {
|
||||
if (!data) return [];
|
||||
|
||||
return data.members.map((member) => {
|
||||
return members.map((member) => {
|
||||
const memberPath = member.cwd?.trim();
|
||||
const nextGitBranch =
|
||||
memberPath && !isLeadMember(member) && leadBranch !== null
|
||||
|
|
@ -1423,7 +1536,7 @@ export const TeamDetailView = ({
|
|||
}
|
||||
return nextMember;
|
||||
});
|
||||
}, [data, leadBranch, trackedBranches]);
|
||||
}, [leadBranch, members, trackedBranches]);
|
||||
|
||||
// Filter sessions to team-only using sessionHistory + leadSessionId
|
||||
const teamSessionIds = useMemo(() => {
|
||||
|
|
@ -1787,7 +1900,6 @@ export const TeamDetailView = ({
|
|||
mountPoint: messagesPanelMountPoint,
|
||||
members: activeMembers,
|
||||
tasks: data?.tasks ?? [],
|
||||
messages: data?.messages ?? [],
|
||||
isTeamAlive: data?.isAlive,
|
||||
timeWindow,
|
||||
teamSessionIds,
|
||||
|
|
@ -1805,7 +1917,6 @@ export const TeamDetailView = ({
|
|||
activeMembers,
|
||||
data?.config.leadSessionId,
|
||||
data?.isAlive,
|
||||
data?.messages,
|
||||
data?.tasks,
|
||||
handleCreateTaskFromMessage,
|
||||
handleOpenTask,
|
||||
|
|
@ -1837,6 +1948,14 @@ export const TeamDetailView = ({
|
|||
isTeamAlive={data?.isAlive}
|
||||
/>
|
||||
);
|
||||
const teamAgentRuntimeWatcher = (
|
||||
<TeamAgentRuntimeWatcher
|
||||
teamName={teamName}
|
||||
isTeamProvisioning={isTeamProvisioning}
|
||||
isTeamAlive={data?.isAlive}
|
||||
isThisTabActive={isThisTabActive}
|
||||
/>
|
||||
);
|
||||
const leadContextWatcher = (
|
||||
<LeadContextWatcher
|
||||
teamName={teamName}
|
||||
|
|
@ -2482,7 +2601,7 @@ export const TeamDetailView = ({
|
|||
open={requestChangesTaskId !== null}
|
||||
teamName={teamName}
|
||||
taskId={requestChangesTaskId}
|
||||
members={data?.members ?? []}
|
||||
members={members}
|
||||
onCancel={() => setRequestChangesTaskId(null)}
|
||||
onSubmit={(comment, taskRefs) => {
|
||||
if (!requestChangesTaskId) {
|
||||
|
|
@ -2509,11 +2628,11 @@ export const TeamDetailView = ({
|
|||
teamName={teamName}
|
||||
members={membersWithLiveBranches}
|
||||
tasks={data.tasks}
|
||||
messages={data.messages}
|
||||
initialTab={selectedMemberView?.initialTab}
|
||||
initialActivityFilter={selectedMemberView?.initialActivityFilter}
|
||||
isTeamAlive={data.isAlive}
|
||||
isTeamProvisioning={isTeamProvisioning}
|
||||
launchParams={launchParams}
|
||||
onClose={closeSelectedMemberDialog}
|
||||
onSendMessage={() => {
|
||||
const name = selectedMember?.name ?? '';
|
||||
|
|
@ -2529,6 +2648,7 @@ export const TeamDetailView = ({
|
|||
closeSelectedMemberDialog();
|
||||
openCreateTaskDialog('', '', name);
|
||||
}}
|
||||
onRestartMember={(memberName) => restartMember(teamName, memberName)}
|
||||
onTaskClick={(task) => {
|
||||
closeSelectedMemberDialog();
|
||||
setSelectedTask(task);
|
||||
|
|
@ -2858,7 +2978,7 @@ export const TeamDetailView = ({
|
|||
if (task) setSelectedTask(task);
|
||||
}}
|
||||
onOpenMemberProfile={(memberName, options) => {
|
||||
const member = data.members.find((m) => m.name === memberName);
|
||||
const member = members.find((m) => m.name === memberName);
|
||||
if (member) {
|
||||
setSelectedMember(member);
|
||||
setSelectedMemberView({
|
||||
|
|
@ -2877,6 +2997,7 @@ export const TeamDetailView = ({
|
|||
return (
|
||||
<>
|
||||
{spawnStatusWatcher}
|
||||
{teamAgentRuntimeWatcher}
|
||||
{leadContextWatcher}
|
||||
{renderBody()}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ import type {
|
|||
ResolvedTeamMember,
|
||||
TeamCreateRequest,
|
||||
TeamLaunchRequest,
|
||||
TeamMemberSnapshot,
|
||||
TeamSummary,
|
||||
TeamSummaryMember,
|
||||
} from '@shared/types';
|
||||
|
|
@ -94,6 +95,17 @@ function folderName(fullPath: string): string {
|
|||
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 {
|
||||
const teamColorMap = buildMemberColorMap(members);
|
||||
return (
|
||||
|
|
@ -625,7 +637,7 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
try {
|
||||
const data = await api.teams.getData(teamName);
|
||||
setLaunchDialogTeamName(teamName);
|
||||
setLaunchDialogMembers(data.members ?? []);
|
||||
setLaunchDialogMembers(resolveLaunchDialogMembers(data.members ?? []));
|
||||
setLaunchDialogDefaultPath(data.config.projectPath ?? projectPath);
|
||||
setLaunchDialogOpen(true);
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|||
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
|
||||
import { shortenDisplayPath } from '@renderer/utils/pathDisplay';
|
||||
import { highlightLines } from '@renderer/utils/syntaxHighlighter';
|
||||
import { AlertTriangle, FileText, MessageCircleQuestion, Search, Terminal } from 'lucide-react';
|
||||
|
|
@ -149,6 +150,7 @@ export const ToolApprovalSheet: React.FC = () => {
|
|||
teams,
|
||||
selectedTeamName,
|
||||
selectedTeamData,
|
||||
selectedTeamMembers,
|
||||
} = useStore(
|
||||
useShallow((s) => ({
|
||||
pendingApprovals: s.pendingApprovals,
|
||||
|
|
@ -157,6 +159,7 @@ export const ToolApprovalSheet: React.FC = () => {
|
|||
teams: s.teams,
|
||||
selectedTeamName: s.selectedTeamName,
|
||||
selectedTeamData: s.selectedTeamData,
|
||||
selectedTeamMembers: selectResolvedMembersForTeamName(s, s.selectedTeamName),
|
||||
}))
|
||||
);
|
||||
const { isLight } = useTheme();
|
||||
|
|
@ -273,9 +276,9 @@ export const ToolApprovalSheet: React.FC = () => {
|
|||
// Resolve teammate color for MemberBadge (when source !== 'lead')
|
||||
const sourceColor = useMemo(() => {
|
||||
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;
|
||||
}, [current, selectedTeamData?.members]);
|
||||
}, [current, selectedTeamMembers]);
|
||||
|
||||
if (!current) return null;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,20 @@
|
|||
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 { AttachmentDisplay } from '@renderer/components/team/attachments/AttachmentDisplay';
|
||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||
import { TaskTooltip } from '@renderer/components/team/TaskTooltip';
|
||||
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 {
|
||||
CARD_BG,
|
||||
CARD_BG_ZEBRA,
|
||||
|
|
@ -668,11 +676,8 @@ export const ActivityItem = memo(
|
|||
}, [message.timestamp]);
|
||||
|
||||
const structured = parseStructuredAgentMessage(message.text);
|
||||
const bootstrapDisplay = useMemo(() => getBootstrapPromptDisplay(message), [message]);
|
||||
const bootstrapAcknowledgement = useMemo(
|
||||
() => getBootstrapAcknowledgementDisplay(message),
|
||||
[message]
|
||||
);
|
||||
const bootstrapDisplay = getBootstrapPromptDisplay(message);
|
||||
const bootstrapAcknowledgement = getBootstrapAcknowledgementDisplay(message);
|
||||
// Only flag agent messages as rate-limited, not user's own quotes
|
||||
const rateLimited = message.from !== 'user' && isRateLimitMessage(message.text);
|
||||
// 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));
|
||||
// Never collapse rate limit messages as noise — they must be visible
|
||||
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 isManaged = collapseMode === 'managed';
|
||||
const isExpanded = isManaged ? !isCollapsed : true;
|
||||
|
||||
const parsedCrossTeamPrefix = useMemo(() => parseCrossTeamPrefix(message.text), [message.text]);
|
||||
const qualifiedRecipient = useMemo(() => parseQualifiedRecipient(message.to), [message.to]);
|
||||
const crossTeamSentTarget = useMemo(
|
||||
() => getCrossTeamSentTarget(message.to, teamName, localMemberNames),
|
||||
[message.to, teamName, localMemberNames]
|
||||
);
|
||||
const crossTeamSentMemberName = useMemo(
|
||||
() => getCrossTeamSentMemberName(message.to),
|
||||
[message.to]
|
||||
);
|
||||
const parsedCrossTeamPrefix = parseCrossTeamPrefix(message.text);
|
||||
const qualifiedRecipient = parseQualifiedRecipient(message.to);
|
||||
const crossTeamSentTarget = getCrossTeamSentTarget(message.to, teamName, localMemberNames);
|
||||
const crossTeamSentMemberName = getCrossTeamSentMemberName(message.to);
|
||||
const isCrossTeam = message.source === CROSS_TEAM_SOURCE || parsedCrossTeamPrefix !== null;
|
||||
const isCrossTeamSent =
|
||||
message.source === CROSS_TEAM_SENT_SOURCE || crossTeamSentTarget !== null;
|
||||
|
|
@ -789,7 +788,7 @@ export const ActivityItem = memo(
|
|||
if (!isCrossTeamAny || !strippedText) return '';
|
||||
const oneLine = strippedText.replace(/\n+/g, ' ').trim();
|
||||
if (!oneLine) return '';
|
||||
return oneLine.length > 80 ? oneLine.slice(0, 80) + '…' : oneLine;
|
||||
return oneLine;
|
||||
}, [isCrossTeamAny, strippedText]);
|
||||
|
||||
const rawSummary = useMemo(() => {
|
||||
|
|
@ -815,8 +814,7 @@ export const ActivityItem = memo(
|
|||
// Fallback: use the beginning of message text as preview for plain-text messages
|
||||
const plain = getSanitizedInboxMessageText(message).trim();
|
||||
if (!plain) return '';
|
||||
const oneLine = plain.replace(/\n+/g, ' ');
|
||||
return oneLine.length > 80 ? oneLine.slice(0, 80) + '…' : oneLine;
|
||||
return plain.replace(/\n+/g, ' ');
|
||||
}, [
|
||||
crossTeamPreview,
|
||||
isSlashCommandMessage,
|
||||
|
|
@ -827,7 +825,47 @@ export const ActivityItem = memo(
|
|||
slashCommandMeta,
|
||||
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 =
|
||||
message.messageKind === 'task_comment_notification' ? (message.taskRefs?.[0] ?? null) : null;
|
||||
const commentTaskDisplayId =
|
||||
|
|
@ -1187,13 +1225,109 @@ export const ActivityItem = memo(
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="mt-1 min-w-0 truncate text-[11px]"
|
||||
style={{ color: CARD_TEXT_LIGHT }}
|
||||
title={summaryText || rawSummary}
|
||||
>
|
||||
{summaryContent}
|
||||
<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>
|
||||
) : !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>
|
||||
<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>
|
||||
) : (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -48,12 +48,6 @@ interface ActivityTimelineProps {
|
|||
expandOverrides?: Set<string>;
|
||||
/** Called when user toggles expand/collapse override on a specific message. */
|
||||
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. */
|
||||
currentLeadSessionId?: string;
|
||||
/** Whether the current team is alive. */
|
||||
|
|
@ -281,7 +275,6 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
|
|||
allCollapsed,
|
||||
expandOverrides,
|
||||
onToggleExpandOverride,
|
||||
teamSessionIds,
|
||||
currentLeadSessionId,
|
||||
isTeamAlive,
|
||||
leadActivity,
|
||||
|
|
@ -425,10 +418,13 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
|
|||
setVisibleCount(Infinity);
|
||||
};
|
||||
|
||||
const getItemSessionId = (item: TimelineItem): string | undefined =>
|
||||
item.type === 'lead-thoughts'
|
||||
? item.group.thoughts[0].leadSessionId
|
||||
: item.message.leadSessionId;
|
||||
const getItemSessionAnchorId = (item: TimelineItem): string | undefined => {
|
||||
if (item.type === 'lead-thoughts') {
|
||||
return item.group.thoughts[0]?.leadSessionId;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// 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;
|
||||
|
|
@ -535,32 +531,29 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
|
|||
// Session boundary separator (messages sorted desc — new on top)
|
||||
let sessionSeparator: React.JSX.Element | null = null;
|
||||
if (realIndex > 0) {
|
||||
const prevSessionId = getItemSessionId(timelineItems[realIndex - 1]);
|
||||
const currSessionId = getItemSessionId(item);
|
||||
if (prevSessionId && currSessionId && prevSessionId !== currSessionId) {
|
||||
// Suppress only the boundary between the current live session and the team's
|
||||
// older session history. Older historical session boundaries should still render.
|
||||
const isReconnectBoundary =
|
||||
!!currentLeadSessionId &&
|
||||
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>
|
||||
);
|
||||
const currSessionId = getItemSessionAnchorId(item);
|
||||
let prevSessionId: string | undefined;
|
||||
for (let searchIndex = realIndex - 1; searchIndex >= 0; searchIndex -= 1) {
|
||||
const candidateSessionId = getItemSessionAnchorId(timelineItems[searchIndex]);
|
||||
if (candidateSessionId) {
|
||||
prevSessionId = candidateSessionId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
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') {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue