Merge pull request #64 from 777genius/dev

dev -> main
This commit is contained in:
Илия 2026-04-18 22:39:49 +03:00 committed by GitHub
commit d14e06473a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
223 changed files with 29177 additions and 2881 deletions

View file

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

File diff suppressed because it is too large Load diff

View 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

File diff suppressed because it is too large Load diff

View file

@ -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. */]
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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>
);
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -9,4 +9,5 @@ export {
isTmuxRuntimeReadyForCurrentPlatform,
killTmuxPaneForCurrentPlatform,
killTmuxPaneForCurrentPlatformSync,
listTmuxPanePidsForCurrentPlatform,
} from './composition/runtimeSupport';

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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

View file

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

View file

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

View file

@ -1,3 +1,9 @@
export {
AutoResumeService,
clearAutoResumeService,
getAutoResumeService,
initializeAutoResumeService,
} from './AutoResumeService';
export { BranchStatusService } from './BranchStatusService';
export { CascadeGuard } from './CascadeGuard';
export { ChangeExtractorService } from './ChangeExtractorService';

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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&apos;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 &amp; 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>,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -311,6 +311,7 @@ export function useSettingsHandlers({
notifyOnCrossTeamMessage: true,
notifyOnTeamLaunched: true,
notifyOnToolApproval: true,
autoResumeOnRateLimit: false,
statusChangeOnlySolo: true,
statusChangeStatuses: ['in_progress', 'completed'],
triggers: defaultTriggers,

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

@ -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()}
</>

View file

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

View file

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

View file

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

View file

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