chore(workspace): checkpoint remaining claude team changes

This commit is contained in:
777genius 2026-04-12 22:15:57 +03:00
parent 32cea2a927
commit 9ca8055695
95 changed files with 1144 additions and 254 deletions

View file

@ -1,8 +1,6 @@
const fs = require('fs');
const path = require('path');
const kanban = require('./kanban.js'); const kanban = require('./kanban.js');
const messages = require('./messages.js'); const messages = require('./messages.js');
const runtimeHelpers = require('./runtimeHelpers.js');
const tasks = require('./tasks.js'); const tasks = require('./tasks.js');
const { wrapAgentBlock } = require('./agentBlocks.js'); const { wrapAgentBlock } = require('./agentBlocks.js');
@ -17,19 +15,7 @@ function getReviewer(context, flags) {
} }
function resolveLeadSessionId(context, flags) { function resolveLeadSessionId(context, flags) {
if (typeof flags.leadSessionId === 'string' && flags.leadSessionId.trim()) { return runtimeHelpers.resolveCanonicalLeadSessionId(context.paths, flags.leadSessionId);
return flags.leadSessionId.trim();
}
try {
const configPath = path.join(context.paths.teamDir, 'config.json');
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8'));
return typeof parsed.leadSessionId === 'string' && parsed.leadSessionId.trim()
? parsed.leadSessionId.trim()
: undefined;
} catch {
return undefined;
}
} }
function getCurrentReviewState(task) { function getCurrentReviewState(task) {

View file

@ -299,6 +299,24 @@ function resolveLeadSessionId(paths) {
: undefined; : undefined;
} }
function resolveCanonicalLeadSessionId(paths, candidate) {
const configured = resolveLeadSessionId(paths);
const explicit = typeof candidate === 'string' ? candidate.trim() : '';
if (!explicit) {
return configured;
}
// The team config is the canonical source of the current lead runtime session.
// If a caller passes a placeholder like "team-lead" or any other mismatched value,
// prefer the configured session id instead of persisting dirty metadata into inbox rows.
if (configured) {
return explicit === configured ? explicit : configured;
}
return explicit;
}
function isProcessAlive(pid) { function isProcessAlive(pid) {
try { try {
process.kill(pid, 0); process.kill(pid, 0);
@ -497,6 +515,7 @@ module.exports = {
readTeamConfig, readTeamConfig,
resolveTeamMembers, resolveTeamMembers,
getCurrentRuntimeMemberIdentity, getCurrentRuntimeMemberIdentity,
resolveCanonicalLeadSessionId,
resolveLeadSessionId, resolveLeadSessionId,
saveTaskAttachmentFile, saveTaskAttachmentFile,
}; };

View file

@ -530,6 +530,25 @@ describe('agent-teams-controller API', () => {
expect(inbox[0].leadSessionId).toBe('lead-session-1'); expect(inbox[0].leadSessionId).toBe('lead-session-1');
}); });
it('ignores mismatched leadSessionId placeholders on review_request and uses canonical config session', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Review me', owner: 'bob' });
controller.kanban.addReviewer('alice');
controller.tasks.completeTask(task.id, 'bob');
controller.review.requestReview(task.id, {
from: 'team-lead',
leadSessionId: 'team-lead',
});
const reviewerInboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'alice.json');
const inbox = JSON.parse(fs.readFileSync(reviewerInboxPath, 'utf8'));
expect(inbox).toHaveLength(1);
expect(inbox[0].leadSessionId).toBe('lead-session-1');
});
it('starts review idempotently without requiring completed status', () => { it('starts review idempotently without requiring completed status', () => {
const claudeDir = makeClaudeDir(); const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir }); const controller = createController({ teamName: 'my-team', claudeDir });
@ -697,6 +716,47 @@ describe('agent-teams-controller API', () => {
expect(rows.at(-1).leadSessionId).toBe('lead-session-1'); expect(rows.at(-1).leadSessionId).toBe('lead-session-1');
}); });
it('ignores mismatched leadSessionId placeholders on review_approve owner notifications', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Approve me', owner: 'bob' });
controller.kanban.addReviewer('alice');
controller.tasks.completeTask(task.id, 'bob');
controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' });
controller.review.approveReview(task.id, {
from: 'team-lead',
note: 'Looks good.',
'notify-owner': true,
leadSessionId: 'team-lead',
});
const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'bob.json');
const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
expect(rows.at(-1).summary).toContain('Approved');
expect(rows.at(-1).leadSessionId).toBe('lead-session-1');
});
it('ignores mismatched leadSessionId placeholders on review_request_changes owner notifications', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Needs revision', owner: 'bob' });
controller.kanban.addReviewer('alice');
controller.tasks.completeTask(task.id, 'bob');
controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' });
controller.review.requestChanges(task.id, {
from: 'alice',
comment: 'Please address review feedback.',
leadSessionId: 'team-lead',
});
const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'bob.json');
const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
expect(rows.at(-1).summary).toContain('Fix request');
expect(rows.at(-1).leadSessionId).toBe('lead-session-1');
});
it('limits approved briefing section to the latest 10 tasks by freshness', async () => { it('limits approved briefing section to the latest 10 tasks by freshness', async () => {
const claudeDir = makeClaudeDir(); const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir }); const controller = createController({ teamName: 'my-team', claudeDir });

View file

@ -1,5 +1,10 @@
# Итерация 03 — Kanban Board (click-to-move) + `kanban-state.json` # Итерация 03 — Kanban Board (click-to-move) + `kanban-state.json`
> Historical note
> This document captures the planned Kanban scope at iteration time.
> It is not the source of truth for the current product contract.
> For the current review flow, see [../team-management/README.md](../team-management/README.md) and [../team-management/kanban-design.md](../team-management/kanban-design.md).
Эта итерация добавляет **kanban-доску команды** во вкладке Team и вводит **персистентное состояние** для колонок `REVIEW`/`APPROVED` через файл `~/.claude/teams/{teamName}/kanban-state.json`. Эта итерация добавляет **kanban-доску команды** во вкладке Team и вводит **персистентное состояние** для колонок `REVIEW`/`APPROVED` через файл `~/.claude/teams/{teamName}/kanban-state.json`.
Основание: Основание:
@ -263,4 +268,3 @@
- **EXDEV/rename нюансы**: в atomic write добавляем fallback copy+unlink. - **EXDEV/rename нюансы**: в atomic write добавляем fallback copy+unlink.
- **Синхронизация UI**: после `updateKanban` делаем `refreshTeamData(teamName)` (и всё равно придёт watcher-событие; refresh должен быть идемпотентен). - **Синхронизация UI**: после `updateKanban` делаем `refreshTeamData(teamName)` (и всё равно придёт watcher-событие; refresh должен быть идемпотентен).
- **Шум от fs.watch**: kanban-write может вызвать два refresh (ручной + watcher). Это ок, но store должен coalesce, а `refreshTeamData` — быть безопасным при частых вызовах. - **Шум от fs.watch**: kanban-write может вызвать два refresh (ручной + watcher). Это ок, но store должен coalesce, а `refreshTeamData` — быть безопасным при частых вызовах.

View file

@ -1,5 +1,10 @@
# Итерация 04 — Messaging + Review (Inbox + ReviewDialog) # Итерация 04 — Messaging + Review (Inbox + ReviewDialog)
> Historical note
> This document captures the planned scope and assumptions at iteration time.
> It is not the source of truth for the current product contract.
> For the current review flow, see [../team-management/README.md](../team-management/README.md) and [../team-management/kanban-design.md](../team-management/kanban-design.md).
Эта итерация добавляет **панель активности (inbox messages)** и **отправку сообщений** тиммейтам, а также закрывает MVP review-flow: **Request Review → Approve / Request Changes**. Эта итерация добавляет **панель активности (inbox messages)** и **отправку сообщений** тиммейтам, а также закрывает MVP review-flow: **Request Review → Approve / Request Changes**.
Основание: Основание:
@ -293,4 +298,3 @@ Guards:
- **Race condition inbox**: атомарная запись не решает overwrite race, поэтому делаем `messageId verify` + retry/backoff, плюс in-process `withInboxLock`. - **Race condition inbox**: атомарная запись не решает overwrite race, поэтому делаем `messageId verify` + retry/backoff, плюс in-process `withInboxLock`.
- **Конфликт при записи task.status**: после write делаем verify; если agent перезаписал — показываем warning в UI, не делаем silent fail. - **Конфликт при записи task.status**: после write делаем verify; если agent перезаписал — показываем warning в UI, не делаем silent fail.
- **Большие inbox**: ограничиваем количество отображаемых сообщений (например 200) и добавляем “Show more” позже (итерация 05). - **Большие inbox**: ограничиваем количество отображаемых сообщений (например 200) и добавляем “Show more” позже (итерация 05).

View file

@ -1,5 +1,10 @@
# Итерация 05 — Testing + Polish (production-ready) # Итерация 05 — Testing + Polish (production-ready)
> Historical note
> This document captures iteration-era test and polish assumptions.
> It is not the source of truth for the current product contract.
> For the current review flow, see [../team-management/README.md](../team-management/README.md) and [../team-management/kanban-design.md](../team-management/kanban-design.md).
Эта итерация закрывает **качество**: тесты на критические пути (read/write), фиксация edge cases, UX-polish (empty/error/loading), и небольшие оптимизации под реальные объёмы inbox/tasks. Эта итерация закрывает **качество**: тесты на критические пути (read/write), фиксация edge cases, UX-polish (empty/error/loading), и небольшие оптимизации под реальные объёмы inbox/tasks.
Основание: Основание:
@ -204,4 +209,3 @@ test/
4) Request Review → карточка в REVIEW + (если reviewer задан) сообщение ушло 4) Request Review → карточка в REVIEW + (если reviewer задан) сообщение ушло
5) Request Changes → task.status стал `in_progress` + owner получил сообщение 5) Request Changes → task.status стал `in_progress` + owner получил сообщение
6) Любое изменение файлов `~/.claude/teams/**` / `~/.claude/tasks/**` → UI обновился в пределах ~1с 6) Любое изменение файлов `~/.claude/teams/**` / `~/.claude/tasks/**` → UI обновился в пределах ~1с

View file

@ -5,9 +5,9 @@
## Что делает ## Что делает
- Видеть состав команды и роли участников - Видеть состав команды и роли участников
- Kanban-доска с 5 колонками (TODO → IN PROGRESS → DONE → REVIEW → APPROVED) - Kanban-доска с 5 колонками: TODO, IN PROGRESS, REVIEW, DONE, APPROVED
- Отправка сообщений тиммейтам через inbox-файлы - Отправка сообщений тиммейтам через inbox-файлы
- Review flow: автоматическое назначение ревьюверов или ручное ревью - Review flow: запрос ревью, ручное ревью и прямое manual approval из DONE
- Live updates через file watcher - Live updates через file watcher
## Документация ## Документация
@ -23,6 +23,8 @@
## Ключевые решения ## Ключевые решения
⚠️ `docs/iterations/*` - это исторические planning notes. Они полезны для контекста, но не являются source-of-truth для текущего поведения продукта. Актуальный контракт review flow описан в этом файле и в [kanban-design.md](./kanban-design.md).
### 1. Messaging: Inbox-файлы ### 1. Messaging: Inbox-файлы
Единственный способ общаться с **запущенными** тиммейтами. SDK и CLI создают новые сессии, а не подключаются к существующим. Подробности: [research-messaging.md](./research-messaging.md) Единственный способ общаться с **запущенными** тиммейтами. SDK и CLI создают новые сессии, а не подключаются к существующим. Подробности: [research-messaging.md](./research-messaging.md)
@ -36,8 +38,9 @@ Kanban-позиция (REVIEW, APPROVED) хранится в `kanban-state.json`
### 3. Review Flow: Approve / Request Changes ### 3. Review Flow: Approve / Request Changes
- Есть ревьюверы в команде → автоматическое назначение через inbox - Есть ревьюверы в команде → автоматическое назначение через inbox
- Юзер также может вручную одобрить задачу напрямую из `DONE` без отдельного захода в `REVIEW`
- Нет ревьюверов → ручное ревью юзером (Approve / Request Changes в UI) - Нет ревьюверов → ручное ревью юзером (Approve / Request Changes в UI)
- При Request Changes → юзер описывает проблему (опционально) → задача к исходному owner - При Request Changes → юзер описывает проблему (опционально) → задача возвращается owner'у в `pending` с `needsFix`
### 4. Atomic Write ### 4. Atomic Write
Все записи через tmp + rename для предотвращения corrupted JSON. Все записи через tmp + rename для предотвращения corrupted JSON.
@ -62,6 +65,10 @@ Kanban-позиция (REVIEW, APPROVED) хранится в `kanban-state.json`
### Review Flow: Approve / Request Changes ### Review Flow: Approve / Request Changes
- Кнопки переименованы: **Approve** (вместо OK) и **Request Changes** (вместо Error) - Кнопки переименованы: **Approve** (вместо OK) и **Request Changes** (вместо Error)
- Комментарий при Request Changes — опционален - Комментарий при Request Changes — опционален
- Manual UI допускает два valid path:
- `DONE -> REVIEW -> APPROVED`
- `DONE -> APPROVED` как быстрый manual approval
- `Request Changes` снимает kanban-state запись и возвращает задачу в `pending` с `needsFix`
- `reviewHistory` и round-robin балансировка → Phase 2, не MVP - `reviewHistory` и round-robin балансировка → Phase 2, не MVP
### Members: полный список через union ### Members: полный список через union
@ -75,8 +82,9 @@ Kanban-позиция (REVIEW, APPROVED) хранится в `kanban-state.json`
- `IDLE`: idle > 5 минут - `IDLE`: idle > 5 минут
- `TERMINATED`: получен `shutdown_response` с `approve: true` - `TERMINATED`: получен `shutdown_response` с `approve: true`
### @dnd-kit: click-to-move для MVP ### @dnd-kit and review transitions
- MVP: выбор колонки через select/dropdown (click-to-move) — проще и надёжнее - Переходы между review-колонками делаются через card actions в UI
- `@dnd-kit` сейчас используется в первую очередь для перестановки задач внутри колонки
- Phase 2: полноценный D&D через `@dnd-kit` - Phase 2: полноценный D&D через `@dnd-kit`
--- ---

View file

@ -1,5 +1,9 @@
# Implementation Plan (v7 — Production-Ready Architecture) # Implementation Plan (v7 — Production-Ready Architecture)
> Historical note
> This is a planning and architecture document, not the source of truth for the current shipped product behavior.
> For the current review flow, see [README.md](./README.md) and [kanban-design.md](./kanban-design.md).
## Обзор ## Обзор
~34 новых файлов + 18 модификаций + 18 тестов. Vertical slices (не backend-first). ~34 новых файлов + 18 модификаций + 18 тестов. Vertical slices (не backend-first).

View file

@ -3,9 +3,11 @@
## Flow ## Flow
``` ```
TODO → IN PROGRESS → DONE → [юзер] → REVIEW → [юзер] → APPROVED TODO → IN PROGRESS → DONE ───────────────→ APPROVED
↑ | │ ↑
└── Fix (error) ←──┘ └→ REVIEW ───────────┘
└→ pending + needsFix
``` ```
## Колонки ## Колонки
@ -15,8 +17,8 @@ TODO → IN PROGRESS → DONE → [юзер] → REVIEW → [юзер] → APPRO
| **TODO** | task.status = pending | Автоматически | Задачи ожидающие исполнителя | | **TODO** | task.status = pending | Автоматически | Задачи ожидающие исполнителя |
| **IN PROGRESS** | task.status = in_progress | Автоматически | Агент работает | | **IN PROGRESS** | task.status = in_progress | Автоматически | Агент работает |
| **DONE** | task.status = completed | Автоматически | Агент завершил | | **DONE** | task.status = completed | Автоматически | Агент завершил |
| **REVIEW** | kanban-state.json | Юзер (drag-and-drop) | На проверке | | **REVIEW** | kanban-state.json | Юзер/UI actions | На проверке |
| **APPROVED** | kanban-state.json | Юзер (drag-and-drop) | Одобрено | | **APPROVED** | kanban-state.json | Юзер/UI actions | Одобрено |
--- ---
@ -90,9 +92,20 @@ const tasks = await getAllTasks(teamName);
## Review Flow ## Review Flow
⚠️ Этот файл описывает текущий продуктовый contract review flow. Исторические iteration-доки могут расходиться с ним.
### Manual actions from DONE
Из `DONE` сейчас есть два валидных пользовательских сценария:
1. **Request Review** - отправить задачу в `REVIEW`
2. **Approve** - сразу перевести задачу в `APPROVED` как manual shortcut
`REVIEW` нужен, когда пользователь хочет отдельный шаг проверки на доске, включая reviewer-driven flow или ручную проверку через UI. Но `REVIEW` не является обязательным промежуточным шагом для каждого manual approval.
### Перемещение DONE → REVIEW ### Перемещение DONE → REVIEW
1. Юзер перетаскивает карточку из DONE в REVIEW 1. Юзер переводит карточку из DONE в REVIEW через UI action
2. Проверяем `kanbanState.reviewers[]` 2. Проверяем `kanbanState.reviewers[]`
3. **Есть ревьюверы**: 3. **Есть ревьюверы**:
- Берём первого свободного (round-robin с балансировкой по количеству активных ревью) - Берём первого свободного (round-robin с балансировкой по количеству активных ревью)
@ -109,7 +122,14 @@ const tasks = await getAllTasks(teamName);
``` ```
4. **Нет ревьюверов**: 4. **Нет ревьюверов**:
- Записываем в kanban-state: `{ column: "review", reviewStatus: "pending" }` - Записываем в kanban-state: `{ column: "review", reviewStatus: "pending" }`
- Юзер сам ревьювит через UI (кнопки OK / Error) - Юзер сам ревьювит через UI (кнопки Approve / Request Changes)
### Прямое DONE → APPROVED
Юзер может сразу нажать **Approve** на карточке в `DONE`:
- kanban-state: `{ column: "approved" }`
- отдельный заход в `REVIEW` не требуется
- это manual shortcut и текущее допустимое поведение UI
### Review Result ### Review Result
@ -129,8 +149,10 @@ const tasks = await getAllTasks(teamName);
2. Появляется ReviewDialog — textarea для описания проблемы (опционально) 2. Появляется ReviewDialog — textarea для описания проблемы (опционально)
3. Юзер нажимает "Отправить" 3. Юзер нажимает "Отправить"
4. Действия: 4. Действия:
- kanban-state: удаляем запись для этой задачи (вернётся в IN PROGRESS по status) - kanban-state: удаляем запись для этой задачи
- task file: `status = "in_progress"` (atomic write) - task file: `status = "pending"`
- reviewState становится `needsFix`
- в UI задача возвращается в TODO/backlog path с маркером Needs Fixes
- Inbox к исходному owner: - Inbox к исходному owner:
```json ```json
{ {
@ -150,30 +172,31 @@ const tasks = await getAllTasks(teamName);
### MVP: Click-to-Move ### MVP: Click-to-Move
Для MVP вместо drag-and-drop используется **click-to-move**: каждая карточка имеет кнопку или select-dropdown для смены колонки. Это проще реализовать и достаточно для первой версии. Для текущего UI переходы между review-колонками делаются через **card actions** на карточке. Отдельный DnD сейчас используется для перестановки задач внутри колонки, а не для review state transitions.
``` ```
[Task Card] [Task Card]
Subject: Rename package in pubspec.yaml Subject: Rename package in pubspec.yaml
Owner: worker-1 Owner: worker-1
[Move to: REVIEW ▼] ← dropdown или кнопка [Approve] [Request review]
``` ```
Разрешённые переходы через click-to-move: Разрешённые review-переходы через UI actions:
| Откуда → Куда | Действие | | Откуда → Куда | Действие |
|----------------|----------| |----------------|----------|
| DONE → REVIEW | kanban-state: review + reviewStatus: pending. Inbox ревьюверу если есть | | DONE → REVIEW | kanban-state: review + reviewStatus: pending. Inbox ревьюверу если есть |
| DONE → APPROVED (Approve) | kanban-state: approved |
| REVIEW → APPROVED (Approve) | kanban-state: approved | | REVIEW → APPROVED (Approve) | kanban-state: approved |
| REVIEW → DONE (Request Changes) | Dialog → task: in_progress, kanban: remove, inbox к owner | | REVIEW → TODO/Needs Fixes (Request Changes) | Dialog → task: pending + needsFix, kanban: remove, inbox к owner |
| APPROVED → DONE | kanban-state: remove (возвращается в DONE по status) | | APPROVED → DONE | kanban-state: remove (возвращается в DONE по status) |
Не разрешено: Не разрешено:
- TODO → IN PROGRESS (агент берёт сам через TaskUpdate) - TODO → IN PROGRESS (агент берёт сам через TaskUpdate)
- IN PROGRESS → DONE (агент завершает сам через TaskUpdate) - IN PROGRESS → DONE (агент завершает сам через TaskUpdate)
### Phase 2: Полноценный D&D через @dnd-kit ### Phase 2: Полноценный D&D для state transitions
`@dnd-kit` уже есть в зависимостях проекта (используется для перетаскивания табов). В Phase 2 добавить drag-and-drop для всех разрешённых переходов. `@dnd-kit` уже используется для ordering. В Phase 2 можно добавить drag-and-drop и для самих state transitions, если это понадобится по UX.
--- ---

View file

@ -0,0 +1,268 @@
import { COLORS } from '../constants/colors';
import { HANDOFF_CARD, NODE, TASK_PILL, MIN_VISIBLE_OPACITY } from '../constants/canvas-constants';
import type { CameraTransform } from '../hooks/useGraphCamera';
import type { GraphNode } from '../ports/types';
import type { TransientHandoffCard } from '../ui/transientHandoffs';
import { truncateText } from './draw-misc';
import { hexWithAlpha, measureTextCached } from './render-cache';
export function drawHandoffCards(
ctx: CanvasRenderingContext2D,
params: {
cards: TransientHandoffCard[];
nodeMap: Map<string, GraphNode>;
time: number;
camera: CameraTransform;
viewport: { width: number; height: number };
}
): void {
const { cards, nodeMap, time, camera, viewport } = params;
if (cards.length === 0) return;
const stackIndexByDestination = 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 alpha = getCardAlpha(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 position = getCardPosition({
node: destinationNode,
camera,
viewport,
height,
stackIndex,
});
if (!position) continue;
drawCard({
ctx,
card,
previewLines,
alpha,
x: position.x,
y: position.y,
width: HANDOFF_CARD.width,
height,
});
drawnCount += 1;
}
}
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;
viewport: { width: number; height: number };
height: number;
stackIndex: number;
}): { x: number; y: number } | null {
const { node, camera, viewport, height, stackIndex } = params;
const screenX = node.x! * camera.zoom + camera.x;
const screenY = node.y! * camera.zoom + camera.y;
const visibleMargin = 80;
if (
screenX < -visibleMargin ||
screenX > viewport.width + visibleMargin ||
screenY < -visibleMargin ||
screenY > viewport.height + visibleMargin
) {
return null;
}
const anchorGap = getAnchorGap(node, camera.zoom);
const stackOffset = stackIndex * (height + HANDOFF_CARD.stackGap);
let x = screenX + anchorGap.x;
let y = screenY + anchorGap.y - stackOffset;
if (x + HANDOFF_CARD.width > viewport.width - HANDOFF_CARD.viewportPadding) {
x = screenX - HANDOFF_CARD.width - Math.abs(anchorGap.x);
}
if (x < HANDOFF_CARD.viewportPadding) {
x = HANDOFF_CARD.viewportPadding;
}
if (y < HANDOFF_CARD.viewportPadding) {
y = screenY + Math.abs(anchorGap.y) + stackOffset;
}
if (y + height > viewport.height - HANDOFF_CARD.viewportPadding) {
y = Math.max(HANDOFF_CARD.viewportPadding, viewport.height - height - HANDOFF_CARD.viewportPadding);
}
return { x, y };
}
function getAnchorGap(node: GraphNode, zoom: number): { x: number; y: number } {
switch (node.kind) {
case 'lead':
return {
x: NODE.radiusLead * zoom + HANDOFF_CARD.anchorGap,
y: -(NODE.radiusLead * zoom + HANDOFF_CARD.anchorGap),
};
case 'member':
return {
x: NODE.radiusMember * zoom + HANDOFF_CARD.anchorGap,
y: -(NODE.radiusMember * zoom + HANDOFF_CARD.anchorGap),
};
case 'task':
return {
x: TASK_PILL.width * zoom * 0.5 + HANDOFF_CARD.anchorGap,
y: -(TASK_PILL.height * zoom * 0.5 + HANDOFF_CARD.anchorGap),
};
case 'process':
return {
x: NODE.radiusProcess * zoom + HANDOFF_CARD.anchorGap,
y: -(NODE.radiusProcess * zoom + HANDOFF_CARD.anchorGap),
};
case 'crossteam':
return {
x: NODE.radiusCrossTeam * zoom + HANDOFF_CARD.anchorGap,
y: -(NODE.radiusCrossTeam * zoom + HANDOFF_CARD.anchorGap),
};
}
}
function drawCard(params: {
ctx: CanvasRenderingContext2D;
card: TransientHandoffCard;
previewLines: string[];
alpha: number;
x: number;
y: number;
width: number;
height: number;
}): void {
const { ctx, card, previewLines, alpha, x, y, width, height } = params;
const accent = card.color || COLORS.particleInboxMessage;
const radius = 10;
ctx.save();
ctx.globalAlpha = alpha;
ctx.shadowColor = hexWithAlpha(accent, 0.22 * alpha);
ctx.shadowBlur = 12;
ctx.fillStyle = hexWithAlpha('#08111f', 0.92);
ctx.strokeStyle = hexWithAlpha(accent, 0.38);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.roundRect(x, y, width, height, radius);
ctx.fill();
ctx.stroke();
ctx.shadowBlur = 0;
ctx.fillStyle = hexWithAlpha(accent, 0.14);
ctx.beginPath();
ctx.roundRect(x + 8, y + 8, 54, 16, 6);
ctx.fill();
ctx.fillStyle = hexWithAlpha(accent, 0.92);
ctx.font = 'bold 8px monospace';
ctx.textAlign = 'left';
ctx.fillText(getKindLabel(card.kind), x + 16, y + 19);
if (card.count > 1) {
const countText = `+${card.count - 1}`;
ctx.font = 'bold 8px monospace';
const countWidth = measureTextCached(ctx, ctx.font, countText) + 14;
ctx.fillStyle = hexWithAlpha(COLORS.holoBright, 0.16);
ctx.beginPath();
ctx.roundRect(x + width - countWidth - 10, y + 8, countWidth, 16, 6);
ctx.fill();
ctx.fillStyle = COLORS.holoBright;
ctx.textAlign = 'center';
ctx.fillText(countText, x + width - countWidth / 2 - 10, y + 19);
}
ctx.textAlign = 'left';
ctx.font = 'bold 10px monospace';
ctx.fillStyle = COLORS.textPrimary;
const route = truncateText(
ctx,
`${card.sourceLabel} -> ${card.destinationLabel}`,
width - 20,
ctx.font
);
ctx.fillText(route, x + 10, y + 36);
if (previewLines.length > 0) {
ctx.font = '8px monospace';
ctx.fillStyle = hexWithAlpha(COLORS.holoBright, 0.86);
for (let index = 0; index < previewLines.length; index += 1) {
ctx.fillText(
previewLines[index],
x + 10,
y + 50 + index * HANDOFF_CARD.previewLineHeight
);
}
}
ctx.restore();
}
function buildPreviewLines(ctx: CanvasRenderingContext2D, preview: string | undefined): string[] {
if (!preview) return [];
ctx.font = '8px monospace';
let remaining = preview.replace(/\s+/g, ' ').trim();
if (remaining.length === 0) return [];
const lines: string[] = [];
for (let index = 0; index < HANDOFF_CARD.previewMaxLines && remaining.length > 0; index += 1) {
if (index === HANDOFF_CARD.previewMaxLines - 1) {
lines.push(truncateText(ctx, remaining, HANDOFF_CARD.previewMaxWidth, ctx.font));
break;
}
const words = remaining.split(' ');
let line = '';
let consumedWords = 0;
for (const word of words) {
const candidate = line.length > 0 ? `${line} ${word}` : word;
if (measureTextCached(ctx, ctx.font, candidate) <= HANDOFF_CARD.previewMaxWidth) {
line = candidate;
consumedWords += 1;
continue;
}
break;
}
if (consumedWords === 0) {
lines.push(truncateText(ctx, words[0] ?? remaining, HANDOFF_CARD.previewMaxWidth, ctx.font));
break;
}
lines.push(line);
remaining = words.slice(consumedWords).join(' ').trim();
}
return lines;
}
function getKindLabel(kind: TransientHandoffCard['kind']): string {
switch (kind) {
case 'task_comment':
return 'COMMENT';
case 'task_assign':
return 'TASK';
case 'review_request':
return 'REVIEW';
case 'review_response':
return 'REPLY';
case 'inbox_message':
return 'MESSAGE';
}
}

View file

@ -211,6 +211,24 @@ export const PARTICLE_DRAW = {
lifetime: 2.0, lifetime: 2.0,
} as const; } as const;
export const HANDOFF_CARD = {
triggerProgress: 0.58,
lingerSeconds: 3.2,
fadeInSeconds: 0.14,
fadeOutSeconds: 0.35,
width: 196,
maxVisible: 6,
maxPerDestination: 2,
baseHeight: 42,
previewLineHeight: 10,
previewMaxLines: 2,
previewMaxWidth: 176,
badgeGap: 8,
stackGap: 10,
viewportPadding: 12,
anchorGap: 14,
} as const;
// ─── Hit detection ────────────────────────────────────────────────────────── // ─── Hit detection ──────────────────────────────────────────────────────────
export const HIT_DETECTION = { export const HIT_DETECTION = {

View file

@ -177,6 +177,8 @@ export interface GraphParticle {
size?: number; size?: number;
/** Short label near particle */ /** Short label near particle */
label?: string; label?: string;
/** Longer preview text for transient handoff cards */
preview?: string;
/** If true, particle travels from target → source (reverse direction) */ /** If true, particle travels from target → source (reverse direction) */
reverse?: boolean; reverse?: boolean;
} }

View file

@ -10,6 +10,7 @@ import { useRef, useEffect, useImperativeHandle, forwardRef } from 'react';
import type { GraphNode, GraphEdge, GraphParticle } from '../ports/types'; import type { GraphNode, GraphEdge, GraphParticle } from '../ports/types';
import { drawBackground, createDepthParticles, updateDepthParticles, type DepthParticle } from '../canvas/background-layer'; import { drawBackground, createDepthParticles, updateDepthParticles, type DepthParticle } from '../canvas/background-layer';
import { drawEdges } from '../canvas/draw-edges'; import { drawEdges } from '../canvas/draw-edges';
import { drawHandoffCards } from '../canvas/draw-handoff-cards';
import { drawParticles } from '../canvas/draw-particles'; import { drawParticles } from '../canvas/draw-particles';
import { drawAgents, drawCrossTeamNodes } from '../canvas/draw-agents'; import { drawAgents, drawCrossTeamNodes } from '../canvas/draw-agents';
import { drawTasks, drawColumnHeaders } from '../canvas/draw-tasks'; import { drawTasks, drawColumnHeaders } from '../canvas/draw-tasks';
@ -18,11 +19,17 @@ import { drawEffects, type VisualEffect } from '../canvas/draw-effects';
import { BloomRenderer } from '../canvas/bloom-renderer'; import { BloomRenderer } from '../canvas/bloom-renderer';
import { KanbanLayoutEngine } from '../layout/kanbanLayout'; import { KanbanLayoutEngine } from '../layout/kanbanLayout';
import { computeAdaptiveParticleBudget, selectRenderableParticles } from './selectRenderableParticles'; import { computeAdaptiveParticleBudget, selectRenderableParticles } from './selectRenderableParticles';
import {
createTransientHandoffState,
selectRenderableTransientHandoffCards,
updateTransientHandoffState,
} from './transientHandoffs';
import type { CameraTransform } from '../hooks/useGraphCamera'; import type { CameraTransform } from '../hooks/useGraphCamera';
// ─── Draw State (passed by ref, not by props — no React re-renders) ───────── // ─── Draw State (passed by ref, not by props — no React re-renders) ─────────
export interface GraphDrawState { export interface GraphDrawState {
teamName: string;
nodes: GraphNode[]; nodes: GraphNode[];
edges: GraphEdge[]; edges: GraphEdge[];
particles: GraphParticle[]; particles: GraphParticle[];
@ -123,6 +130,8 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(funct
const visibleNodeIdsCache = useRef(new Set<string>()); const visibleNodeIdsCache = useRef(new Set<string>());
const visibleEdgeIdsCache = useRef(new Set<string>()); const visibleEdgeIdsCache = useRef(new Set<string>());
const activeParticleEdgesCache = useRef(new Set<string>()); const activeParticleEdgesCache = useRef(new Set<string>());
const handoffStateRef = useRef(createTransientHandoffState());
const lastTeamNameRef = useRef<string | null>(null);
// Imperative draw function — called from RAF, NOT from React render // Imperative draw function — called from RAF, NOT from React render
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
@ -139,6 +148,10 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(funct
if (w === 0 || h === 0) return; if (w === 0 || h === 0) return;
try { try {
if (lastTeamNameRef.current !== state.teamName) {
handoffStateRef.current = createTransientHandoffState();
lastTeamNameRef.current = state.teamName;
}
const cam = state.camera; const cam = state.camera;
const zoom = cam.zoom; const zoom = cam.zoom;
@ -234,6 +247,19 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(funct
focusEdgeIds: prioritizedEdgeIds, focusEdgeIds: prioritizedEdgeIds,
budget: particleBudget, budget: particleBudget,
}); });
updateTransientHandoffState(handoffStateRef.current, {
particles: state.particles,
edgeMap,
nodeMap,
time: state.time,
});
const renderableHandoffCards = selectRenderableTransientHandoffCards(
handoffStateRef.current,
{
focusNodeIds: state.focusNodeIds,
focusEdgeIds: prioritizedEdgeIds ?? state.focusEdgeIds,
}
);
drawParticles(ctx, renderableParticles, edgeMap, nodeMap, state.time, prioritizedEdgeIds); drawParticles(ctx, renderableParticles, edgeMap, nodeMap, state.time, prioritizedEdgeIds);
// 2c. Visible nodes only (back to front: process → task → member/lead) // 2c. Visible nodes only (back to front: process → task → member/lead)
@ -282,6 +308,19 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(funct
bloomRef.current.apply(canvas, ctx); bloomRef.current.apply(canvas, ctx);
} }
if (renderableHandoffCards.length > 0) {
ctx.save();
ctx.scale(dpr, dpr);
drawHandoffCards(ctx, {
cards: renderableHandoffCards,
nodeMap,
time: state.time,
camera: cam,
viewport: { width: w, height: h },
});
ctx.restore();
}
// 4. Performance overlay (enabled via ?perf in URL) // 4. Performance overlay (enabled via ?perf in URL)
const perf = perfRef.current; const perf = perfRef.current;
const frameMs = performance.now() - frameStart; const frameMs = performance.now() - frameStart;

View file

@ -200,6 +200,7 @@ export function GraphView({
// 4. Draw canvas imperatively (NO React re-render) // 4. Draw canvas imperatively (NO React re-render)
canvasHandle.current?.draw({ canvasHandle.current?.draw({
teamName: data.teamName,
nodes: state.nodes, nodes: state.nodes,
edges: state.edges, edges: state.edges,
particles: state.particles, particles: state.particles,

View file

@ -0,0 +1,163 @@
import { HANDOFF_CARD } from '../constants/canvas-constants';
import type { GraphEdge, GraphNode, GraphParticle, GraphParticleKind } from '../ports/types';
type HandoffParticleKind = Exclude<GraphParticleKind, 'spawn'>;
export interface TransientHandoffCard {
key: string;
edgeId: string;
sourceNodeId: string;
destinationNodeId: string;
sourceLabel: string;
destinationLabel: string;
destinationKind: GraphNode['kind'];
kind: HandoffParticleKind;
color: string;
preview?: string;
count: number;
activatedAt: number;
updatedAt: number;
expiresAt: number;
}
export interface TransientHandoffState {
cardsByKey: Map<string, TransientHandoffCard>;
triggeredParticleIds: Set<string>;
}
export function createTransientHandoffState(): TransientHandoffState {
return {
cardsByKey: new Map<string, TransientHandoffCard>(),
triggeredParticleIds: new Set<string>(),
};
}
export function updateTransientHandoffState(
state: TransientHandoffState,
params: {
particles: GraphParticle[];
edgeMap: Map<string, GraphEdge>;
nodeMap: Map<string, GraphNode>;
time: number;
}
): void {
const { particles, edgeMap, nodeMap, time } = params;
const activeParticleIds = new Set<string>();
for (const particle of particles) activeParticleIds.add(particle.id);
for (const particleId of Array.from(state.triggeredParticleIds)) {
if (!activeParticleIds.has(particleId)) {
state.triggeredParticleIds.delete(particleId);
}
}
for (const [cardKey, card] of Array.from(state.cardsByKey.entries())) {
if (card.expiresAt <= time) {
state.cardsByKey.delete(cardKey);
}
}
for (const particle of particles) {
if (!isTransientHandoffKind(particle.kind)) continue;
if (particle.progress < HANDOFF_CARD.triggerProgress) continue;
if (state.triggeredParticleIds.has(particle.id)) continue;
const edge = edgeMap.get(particle.edgeId);
if (!edge) continue;
const sourceNodeId = particle.reverse ? edge.target : edge.source;
const destinationNodeId = particle.reverse ? edge.source : edge.target;
const sourceNode = nodeMap.get(sourceNodeId);
const destinationNode = nodeMap.get(destinationNodeId);
if (!sourceNode || !destinationNode) continue;
const previewText = normalizePreviewText(particle.preview ?? particle.label);
if (particle.kind === 'inbox_message' && isLowSignalInboxPreview(previewText)) {
state.triggeredParticleIds.add(particle.id);
continue;
}
const cardKey = `${edge.id}:${particle.reverse ? 'rev' : 'fwd'}:${particle.kind}`;
const existing = state.cardsByKey.get(cardKey);
const nextCount = (existing?.count ?? 0) + 1;
state.cardsByKey.set(cardKey, {
key: cardKey,
edgeId: edge.id,
sourceNodeId,
destinationNodeId,
sourceLabel: sourceNode.label,
destinationLabel: destinationNode.label,
destinationKind: destinationNode.kind,
kind: particle.kind,
color: particle.color,
preview: previewText ?? existing?.preview,
count: nextCount,
activatedAt: existing?.activatedAt ?? time,
updatedAt: time,
expiresAt: time + HANDOFF_CARD.lingerSeconds,
});
state.triggeredParticleIds.add(particle.id);
}
}
export function selectRenderableTransientHandoffCards(
state: TransientHandoffState,
options?: {
focusNodeIds?: ReadonlySet<string> | null;
focusEdgeIds?: ReadonlySet<string> | null;
}
): TransientHandoffCard[] {
const focusNodeIds = options?.focusNodeIds ?? null;
const focusEdgeIds = options?.focusEdgeIds ?? null;
const hasFocus = (focusNodeIds?.size ?? 0) > 0 || (focusEdgeIds?.size ?? 0) > 0;
const byDestination = 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);
} else {
byDestination.set(card.destinationNodeId, [card]);
}
}
const selected: TransientHandoffCard[] = [];
for (const cards of byDestination.values()) {
cards.sort((a, b) => b.updatedAt - a.updatedAt);
selected.push(...cards.slice(0, HANDOFF_CARD.maxPerDestination));
}
selected.sort((a, b) => b.updatedAt - a.updatedAt);
return selected;
}
function isTransientHandoffKind(kind: GraphParticleKind): kind is HandoffParticleKind {
return kind !== 'spawn';
}
function isCardInFocus(
card: TransientHandoffCard,
focusNodeIds: ReadonlySet<string> | null,
focusEdgeIds: ReadonlySet<string> | null
): boolean {
return (
!!focusEdgeIds?.has(card.edgeId) ||
!!focusNodeIds?.has(card.sourceNodeId) ||
!!focusNodeIds?.has(card.destinationNodeId)
);
}
function normalizePreviewText(text: string | undefined): string | undefined {
if (!text) return undefined;
const normalized = text
.replace(/^(?:|💬)\s*/u, '')
.replace(/\s+/g, ' ')
.trim();
return normalized.length > 0 ? normalized : undefined;
}
function isLowSignalInboxPreview(preview: string | undefined): boolean {
return preview === 'idle';
}

View file

@ -63,7 +63,6 @@ import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers';
import { setReviewMainWindow } from './ipc/review'; import { setReviewMainWindow } from './ipc/review';
import { import {
ApiKeyService, ApiKeyService,
RUNTIME_MANAGED_API_KEY_ENV_VARS,
ExtensionFacadeService, ExtensionFacadeService,
GlamaMcpEnrichmentService, GlamaMcpEnrichmentService,
McpCatalogAggregator, McpCatalogAggregator,
@ -74,6 +73,7 @@ import {
PluginCatalogService, PluginCatalogService,
PluginInstallationStateService, PluginInstallationStateService,
PluginInstallService, PluginInstallService,
RUNTIME_MANAGED_API_KEY_ENV_VARS,
SkillsCatalogService, SkillsCatalogService,
SkillsMutationService, SkillsMutationService,
SkillsWatcherService, SkillsWatcherService,
@ -103,6 +103,11 @@ import {
import { syncTelemetryFlag } from './sentry'; import { syncTelemetryFlag } from './sentry';
import { import {
BranchStatusService, BranchStatusService,
BoardTaskActivityRecordSource,
BoardTaskActivityService,
BoardTaskExactLogDetailService,
BoardTaskExactLogsService,
BoardTaskLogStreamService,
CliInstallerService, CliInstallerService,
configManager, configManager,
LocalFileSystemProvider, LocalFileSystemProvider,
@ -779,6 +784,13 @@ async function initializeServices(): Promise<void> {
cliInstallerService = new CliInstallerService(); cliInstallerService = new CliInstallerService();
ptyTerminalService = new PtyTerminalService(); ptyTerminalService = new PtyTerminalService();
const teamMemberLogsFinder = new TeamMemberLogsFinder(); const teamMemberLogsFinder = new TeamMemberLogsFinder();
const boardTaskActivityRecordSource = new BoardTaskActivityRecordSource();
const boardTaskActivityService = new BoardTaskActivityService(boardTaskActivityRecordSource);
const boardTaskExactLogsService = new BoardTaskExactLogsService(boardTaskActivityRecordSource);
const boardTaskExactLogDetailService = new BoardTaskExactLogDetailService(
boardTaskActivityRecordSource
);
const boardTaskLogStreamService = new BoardTaskLogStreamService(boardTaskActivityRecordSource);
const teamMemberRuntimeAdvisoryService = new TeamMemberRuntimeAdvisoryService( const teamMemberRuntimeAdvisoryService = new TeamMemberRuntimeAdvisoryService(
teamMemberLogsFinder teamMemberLogsFinder
); );
@ -924,6 +936,10 @@ async function initializeServices(): Promise<void> {
teamProvisioningService, teamProvisioningService,
teamMemberLogsFinder, teamMemberLogsFinder,
memberStatsComputer, memberStatsComputer,
boardTaskActivityService,
boardTaskLogStreamService,
boardTaskExactLogsService,
boardTaskExactLogDetailService,
teammateToolTracker ?? undefined, teammateToolTracker ?? undefined,
branchStatusService ?? undefined, branchStatusService ?? undefined,
{ {

View file

@ -8,8 +8,8 @@
*/ */
import { import {
CLI_INSTALLER_GET_STATUS,
CLI_INSTALLER_GET_PROVIDER_STATUS, CLI_INSTALLER_GET_PROVIDER_STATUS,
CLI_INSTALLER_GET_STATUS,
CLI_INSTALLER_INSTALL, CLI_INSTALLER_INSTALL,
CLI_INSTALLER_INVALIDATE_STATUS, CLI_INSTALLER_INVALIDATE_STATUS,
// eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload // eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload
@ -17,8 +17,9 @@ import {
import { getErrorMessage } from '@shared/utils/errorHandling'; import { getErrorMessage } from '@shared/utils/errorHandling';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import type { CliInstallerService } from '../services';
import { ClaudeBinaryResolver } from '../services/team/ClaudeBinaryResolver'; import { ClaudeBinaryResolver } from '../services/team/ClaudeBinaryResolver';
import type { CliInstallerService } from '../services';
import type { import type {
CliInstallationStatus, CliInstallationStatus,
CliProviderId, CliProviderId,

View file

@ -28,12 +28,12 @@ import {
} from '@preload/constants/ipcChannels'; } from '@preload/constants/ipcChannels';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import {
type ApiKeyService,
RUNTIME_MANAGED_API_KEY_ENV_VARS,
} from '../services/extensions/apikeys/ApiKeyService';
import { GitHubStarsService } from '../services/extensions/catalog/GitHubStarsService'; import { GitHubStarsService } from '../services/extensions/catalog/GitHubStarsService';
import {
RUNTIME_MANAGED_API_KEY_ENV_VARS,
type ApiKeyService,
} from '../services/extensions/apikeys/ApiKeyService';
import type { ExtensionFacadeService } from '../services/extensions/ExtensionFacadeService'; import type { ExtensionFacadeService } from '../services/extensions/ExtensionFacadeService';
import type { McpInstallService } from '../services/extensions/install/McpInstallService'; import type { McpInstallService } from '../services/extensions/install/McpInstallService';
import type { PluginInstallService } from '../services/extensions/install/PluginInstallService'; import type { PluginInstallService } from '../services/extensions/install/PluginInstallService';

View file

@ -3,7 +3,7 @@ import { getErrorMessage } from '@shared/utils/errorHandling';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { execFile } from 'child_process'; import { execFile } from 'child_process';
import type { TmuxPlatform, TmuxStatus, IpcResult } from '@shared/types'; import type { IpcResult, TmuxPlatform, TmuxStatus } from '@shared/types';
import type { IpcMain, IpcMainInvokeEvent } from 'electron'; import type { IpcMain, IpcMainInvokeEvent } from 'electron';
const logger = createLogger('IPC:tmux'); const logger = createLogger('IPC:tmux');

View file

@ -20,14 +20,15 @@ const { autoUpdater } = electronUpdater;
import { app, net } from 'electron'; import { app, net } from 'electron';
import type { UpdaterStatus } from '@shared/types';
import type { BrowserWindow } from 'electron';
import { import {
getExpectedReleaseAssetUrl, getExpectedReleaseAssetUrl,
getLatestMacMetadataUrl, getLatestMacMetadataUrl,
isLatestMacMetadataCompatible, isLatestMacMetadataCompatible,
} from './updaterReleaseMetadata'; } from './updaterReleaseMetadata';
import type { UpdaterStatus } from '@shared/types';
import type { BrowserWindow } from 'electron';
const logger = createLogger('UpdaterService'); const logger = createLogger('UpdaterService');
/** /**

View file

@ -58,7 +58,7 @@ export function parseReleaseMetadataAssetNames(metadataText: string): Set<string
for (const rawLine of metadataText.split(/\r?\n/u)) { for (const rawLine of metadataText.split(/\r?\n/u)) {
const line = rawLine.trim(); const line = rawLine.trim();
const match = line.match(/^(?:-\s+)?(url|path):\s+(.+)$/u); const match = /^(?:-\s+)?(url|path):\s+(.+)$/u.exec(line);
if (!match) { if (!match) {
continue; continue;
} }

View file

@ -1,20 +1,20 @@
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
export type GeminiGlobalConfig = { export interface GeminiGlobalConfig {
geminiBackendPreference?: 'auto' | 'api' | 'cli' | 'cli-sdk'; geminiBackendPreference?: 'auto' | 'api' | 'cli' | 'cli-sdk';
geminiResolvedBackend?: 'api' | 'cli' | 'cli-sdk'; geminiResolvedBackend?: 'api' | 'cli' | 'cli-sdk';
geminiLastAuthMethod?: string; geminiLastAuthMethod?: string;
geminiProjectId?: string; geminiProjectId?: string;
}; }
export type GeminiRuntimeAuthState = { export interface GeminiRuntimeAuthState {
authenticated: boolean; authenticated: boolean;
authMethod: string | null; authMethod: string | null;
resolvedBackend: 'auto' | 'api' | 'cli-sdk'; resolvedBackend: 'auto' | 'api' | 'cli-sdk';
projectId: string | null; projectId: string | null;
statusMessage: string | null; statusMessage: string | null;
}; }
function normalizeGeminiBackend( function normalizeGeminiBackend(
value: string | null | undefined value: string | null | undefined

View file

@ -1,7 +1,7 @@
import type { TeamProviderId } from '@shared/types';
import { ConfigManager } from '../infrastructure/ConfigManager'; import { ConfigManager } from '../infrastructure/ConfigManager';
import type { TeamProviderId } from '@shared/types';
const PROVIDER_ROUTING_ENV_KEYS = [ const PROVIDER_ROUTING_ENV_KEYS = [
'CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST', 'CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST',
'CLAUDE_CODE_ENTRY_PROVIDER', 'CLAUDE_CODE_ENTRY_PROVIDER',

View file

@ -105,7 +105,7 @@ export class BranchStatusService {
try { try {
const branch = await this.resolver.getBranch(tracked.actualPath, { forceRefresh }); const branch = await this.resolver.getBranch(tracked.actualPath, { forceRefresh });
const latestTracked = this.trackedPaths.get(normalizedPath); const latestTracked = this.trackedPaths.get(normalizedPath);
if (!latestTracked || latestTracked.token !== expectedToken) return; if (latestTracked?.token !== expectedToken) return;
const previous = this.lastEmittedBranchByPath.get(normalizedPath) ?? UNSET_BRANCH; const previous = this.lastEmittedBranchByPath.get(normalizedPath) ?? UNSET_BRANCH;
if (previous !== UNSET_BRANCH && previous === branch) { if (previous !== UNSET_BRANCH && previous === branch) {

View file

@ -1,8 +1,9 @@
import { getTeamsBasePath } from '@main/utils/pathDecoder'; import { getTeamsBasePath } from '@main/utils/pathDecoder';
import { createPersistedLaunchSnapshot } from './TeamLaunchStateEvaluator';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { createPersistedLaunchSnapshot } from './TeamLaunchStateEvaluator';
import type { import type {
PersistedTeamLaunchMemberState, PersistedTeamLaunchMemberState,
PersistedTeamLaunchSnapshot, PersistedTeamLaunchSnapshot,
@ -19,15 +20,15 @@ const MAX_BOOTSTRAP_JOURNAL_BYTES = 256 * 1024;
const MAX_BOOTSTRAP_LOCK_METADATA_BYTES = 64 * 1024; const MAX_BOOTSTRAP_LOCK_METADATA_BYTES = 64 * 1024;
const ACTIVE_BOOTSTRAP_STUCK_CLASSIFICATION_MS = 3 * 60 * 1000; const ACTIVE_BOOTSTRAP_STUCK_CLASSIFICATION_MS = 3 * 60 * 1000;
type RawBootstrapMemberState = { interface RawBootstrapMemberState {
name?: unknown; name?: unknown;
status?: unknown; status?: unknown;
lastAttemptAt?: unknown; lastAttemptAt?: unknown;
lastObservedAt?: unknown; lastObservedAt?: unknown;
failureReason?: unknown; failureReason?: unknown;
}; }
type RawBootstrapState = { interface RawBootstrapState {
version?: unknown; version?: unknown;
runId?: unknown; runId?: unknown;
teamName?: unknown; teamName?: unknown;
@ -38,7 +39,7 @@ type RawBootstrapState = {
realTaskSubmissionState?: unknown; realTaskSubmissionState?: unknown;
members?: unknown; members?: unknown;
terminal?: unknown; terminal?: unknown;
}; }
type RawBootstrapJournalRecord = type RawBootstrapJournalRecord =
| { ts?: unknown; type?: 'phase'; phase?: unknown } | { ts?: unknown; type?: 'phase'; phase?: unknown }
@ -47,31 +48,31 @@ type RawBootstrapJournalRecord =
| { ts?: unknown; type?: 'terminal'; status?: unknown; reason?: unknown } | { ts?: unknown; type?: 'terminal'; status?: unknown; reason?: unknown }
| { ts?: unknown; type?: 'real_task'; state?: unknown; detail?: unknown }; | { ts?: unknown; type?: 'real_task'; state?: unknown; detail?: unknown };
type RawBootstrapLockMetadata = { interface RawBootstrapLockMetadata {
pid?: unknown; pid?: unknown;
runId?: unknown; runId?: unknown;
requestHash?: unknown; requestHash?: unknown;
ownerStartedAt?: unknown; ownerStartedAt?: unknown;
createdAt?: unknown; createdAt?: unknown;
nonce?: unknown; nonce?: unknown;
}; }
type BootstrapStateInspection = { interface BootstrapStateInspection {
raw: RawBootstrapState | null; raw: RawBootstrapState | null;
issue?: string; issue?: string;
}; }
type BootstrapJournalInspection = { interface BootstrapJournalInspection {
warnings?: string[]; warnings?: string[];
issue?: string; issue?: string;
lastPhase?: BootstrapRuntimePhase; lastPhase?: BootstrapRuntimePhase;
}; }
type BootstrapLockMetadata = { interface BootstrapLockMetadata {
pid: number; pid: number;
runId: string; runId: string;
ownerStartedAt?: number; ownerStartedAt?: number;
}; }
type BootstrapRuntimePhase = type BootstrapRuntimePhase =
| 'validating_spec' | 'validating_spec'
@ -84,13 +85,13 @@ type BootstrapRuntimePhase =
| 'failed' | 'failed'
| 'canceled'; | 'canceled';
type ComparableStat = { interface ComparableStat {
dev?: number; dev?: number;
ino?: number; ino?: number;
size: number; size: number;
mode?: number; mode?: number;
mtimeMs?: number; mtimeMs?: number;
}; }
function isFiniteNumber(value: unknown): value is number { function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value); return typeof value === 'number' && Number.isFinite(value);

View file

@ -13,8 +13,8 @@ import { Worker } from 'node:worker_threads';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import type { MemberLogSummary, TeamData } from '@shared/types';
import type { TeamDataWorkerRequest, TeamDataWorkerResponse } from './teamDataWorkerTypes'; import type { TeamDataWorkerRequest, TeamDataWorkerResponse } from './teamDataWorkerTypes';
import type { MemberLogSummary, TeamData } from '@shared/types';
const logger = createLogger('Service:TeamDataWorkerClient'); const logger = createLogger('Service:TeamDataWorkerClient');
const WORKER_CALL_TIMEOUT_MS = 30_000; const WORKER_CALL_TIMEOUT_MS = 30_000;
@ -49,10 +49,10 @@ function resolveWorkerPath(): string | null {
return null; return null;
} }
type PendingEntry = { interface PendingEntry {
resolve: (v: unknown) => void; resolve: (v: unknown) => void;
reject: (e: Error) => void; reject: (e: Error) => void;
}; }
export class TeamDataWorkerClient { export class TeamDataWorkerClient {
private worker: Worker | null = null; private worker: Worker | null = null;

View file

@ -3,8 +3,8 @@ import { createLogger } from '@shared/utils/logger';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { normalizePersistedLaunchSnapshot } from './TeamLaunchStateEvaluator';
import { atomicWriteAsync } from './atomicWrite'; import { atomicWriteAsync } from './atomicWrite';
import { normalizePersistedLaunchSnapshot } from './TeamLaunchStateEvaluator';
import type { PersistedTeamLaunchSnapshot } from '@shared/types'; import type { PersistedTeamLaunchSnapshot } from '@shared/types';

View file

@ -1,10 +1,10 @@
import { createLogger } from '@shared/utils/logger';
import * as fs from 'fs/promises'; import * as fs from 'fs/promises';
import type { MemberRuntimeAdvisory, ResolvedTeamMember } from '@shared/types';
import { createLogger } from '@shared/utils/logger';
import { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; import { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
import type { MemberRuntimeAdvisory, ResolvedTeamMember } from '@shared/types';
const LOOKBACK_MS = 10 * 60 * 1000; const LOOKBACK_MS = 10 * 60 * 1000;
const CACHE_TTL_MS = 5_000; const CACHE_TTL_MS = 5_000;
const TAIL_BYTES = 64 * 1024; const TAIL_BYTES = 64 * 1024;
@ -102,11 +102,7 @@ export class TeamMemberRuntimeAdvisoryService {
const membersSignature = this.buildMembersSignature(activeMembers); const membersSignature = this.buildMembersSignature(activeMembers);
const now = Date.now(); const now = Date.now();
const cachedBatch = this.teamBatchCacheByTeam.get(teamKey); const cachedBatch = this.teamBatchCacheByTeam.get(teamKey);
if ( if (cachedBatch?.membersSignature === membersSignature && cachedBatch.expiresAt > now) {
cachedBatch &&
cachedBatch.membersSignature === membersSignature &&
cachedBatch.expiresAt > now
) {
return this.materializeBatchAdvisories(activeMembers, cachedBatch.value); return this.materializeBatchAdvisories(activeMembers, cachedBatch.value);
} }

View file

@ -1,7 +1,7 @@
import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead'; import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead';
import { getTeamsBasePath } from '@main/utils/pathDecoder'; import { getTeamsBasePath } from '@main/utils/pathDecoder';
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName'; import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName';
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';

View file

@ -5,12 +5,12 @@ import {
export type MainProcessIdleHandling = 'silent_noise' | 'passive_activity' | 'visible_actionable'; export type MainProcessIdleHandling = 'silent_noise' | 'passive_activity' | 'visible_actionable';
export type ClassifiedMainProcessIdle = { export interface ClassifiedMainProcessIdle {
primaryKind: MainProcessIdlePrimaryKind; primaryKind: MainProcessIdlePrimaryKind;
hasPeerSummary: boolean; hasPeerSummary: boolean;
peerSummary: string | null; peerSummary: string | null;
handling: MainProcessIdleHandling; handling: MainProcessIdleHandling;
}; }
export function classifyIdleNotificationForMainProcess( export function classifyIdleNotificationForMainProcess(
text: string text: string

View file

@ -1,11 +1,11 @@
import { createHash } from 'crypto'; import { createHash } from 'crypto';
type InboxIdentityLike = { interface InboxIdentityLike {
messageId?: unknown; messageId?: unknown;
from?: unknown; from?: unknown;
timestamp?: unknown; timestamp?: unknown;
text?: unknown; text?: unknown;
}; }
export function buildLegacyInboxMessageId(from: string, timestamp: string, text: string): string { export function buildLegacyInboxMessageId(from: string, timestamp: string, text: string): string {
return `inbox-${createHash('sha256').update(`${from}\n${timestamp}\n${text}`).digest('hex').slice(0, 16)}`; return `inbox-${createHash('sha256').update(`${from}\n${timestamp}\n${text}`).digest('hex').slice(0, 16)}`;

View file

@ -1,26 +1,26 @@
export type MemberDiffInput = { export interface MemberDiffInput {
name: string; name: string;
role?: string; role?: string;
workflow?: string; workflow?: string;
providerId?: 'anthropic' | 'codex' | 'gemini'; providerId?: 'anthropic' | 'codex' | 'gemini';
model?: string; model?: string;
removedAt?: number | string | null; removedAt?: number | string | null;
}; }
export type ReplaceMembersDiff = { export interface ReplaceMembersDiff {
added: Array<{ added: {
name: string; name: string;
role?: string; role?: string;
workflow?: string; workflow?: string;
providerId?: 'anthropic' | 'codex' | 'gemini'; providerId?: 'anthropic' | 'codex' | 'gemini';
model?: string; model?: string;
}>; }[];
removed: string[]; removed: string[];
updated: Array<{ updated: {
name: string; name: string;
changes: string[]; changes: string[];
}>; }[];
}; }
function normalizeOptionalText(value: string | undefined): string | undefined { function normalizeOptionalText(value: string | undefined): string | undefined {
const normalized = value?.trim(); const normalized = value?.trim();
@ -61,13 +61,13 @@ function describeWorkflowChange(
export function buildReplaceMembersDiff( export function buildReplaceMembersDiff(
previousMembers: MemberDiffInput[], previousMembers: MemberDiffInput[],
nextMembers: Array<{ nextMembers: {
name: string; name: string;
role?: string; role?: string;
workflow?: string; workflow?: string;
providerId?: 'anthropic' | 'codex' | 'gemini'; providerId?: 'anthropic' | 'codex' | 'gemini';
model?: string; model?: string;
}> }[]
): ReplaceMembersDiff { ): ReplaceMembersDiff {
const previousByName = new Map( const previousByName = new Map(
previousMembers previousMembers

View file

@ -1,13 +1,12 @@
import { execFile } from 'child_process';
import { parseCliArgs } from '@shared/utils/cliArgsParser'; import { parseCliArgs } from '@shared/utils/cliArgsParser';
import { execFile } from 'child_process';
const TMUX_AVAILABILITY_CACHE_TTL_MS = 10_000; const TMUX_AVAILABILITY_CACHE_TTL_MS = 10_000;
type DesktopTeammateModeDecision = { interface DesktopTeammateModeDecision {
injectedTeammateMode: 'tmux' | null; injectedTeammateMode: 'tmux' | null;
forceProcessTeammates: boolean; forceProcessTeammates: boolean;
}; }
let tmuxAvailabilityCache: { value: boolean; at: number } | null = null; let tmuxAvailabilityCache: { value: boolean; at: number } | null = null;
let tmuxAvailablePromise: Promise<boolean> | null = null; let tmuxAvailablePromise: Promise<boolean> | null = null;

View file

@ -1,8 +1,8 @@
import { deriveTaskSince } from '@shared/utils/taskChangeSince';
import { import {
getTaskChangeStateBucket, getTaskChangeStateBucket,
type TaskChangeStateBucket, type TaskChangeStateBucket,
} from '@shared/utils/taskChangeState'; } from '@shared/utils/taskChangeState';
import { deriveTaskSince } from '@shared/utils/taskChangeSince';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
export interface TaskChangePresenceInterval { export interface TaskChangePresenceInterval {

View file

@ -18,12 +18,12 @@
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { LocalFileSystemProvider } from './services/infrastructure/LocalFileSystemProvider';
import { import {
getProjectsBasePath, getProjectsBasePath,
getTodosBasePath, getTodosBasePath,
setClaudeBasePathOverride, setClaudeBasePathOverride,
} from './utils/pathDecoder'; } from './utils/pathDecoder';
import { LocalFileSystemProvider } from './services/infrastructure/LocalFileSystemProvider';
import type { HttpServices } from './http'; import type { HttpServices } from './http';
import type { HttpServer } from './services/infrastructure/HttpServer'; import type { HttpServer } from './services/infrastructure/HttpServer';

View file

@ -11,12 +11,12 @@ import { parentPort } from 'node:worker_threads';
import { TeamDataService } from '@main/services/team/TeamDataService'; import { TeamDataService } from '@main/services/team/TeamDataService';
import { TeamMemberLogsFinder } from '@main/services/team/TeamMemberLogsFinder'; import { TeamMemberLogsFinder } from '@main/services/team/TeamMemberLogsFinder';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import type { MemberLogSummary } from '@shared/types';
import type { import type {
TeamDataWorkerRequest, TeamDataWorkerRequest,
TeamDataWorkerResponse, TeamDataWorkerResponse,
} from '@main/services/team/teamDataWorkerTypes'; } from '@main/services/team/teamDataWorkerTypes';
import type { MemberLogSummary } from '@shared/types';
const logger = createLogger('Worker:TeamData'); const logger = createLogger('Worker:TeamData');

View file

@ -2,8 +2,8 @@ import * as fs from 'node:fs';
import * as path from 'node:path'; import * as path from 'node:path';
import { parentPort } from 'node:worker_threads'; import { parentPort } from 'node:worker_threads';
import { isLeadMember } from '@shared/utils/leadDetection';
import { normalizePersistedLaunchSnapshot } from '@main/services/team/TeamLaunchStateEvaluator'; import { normalizePersistedLaunchSnapshot } from '@main/services/team/TeamLaunchStateEvaluator';
import { isLeadMember } from '@shared/utils/leadDetection';
interface ListTeamsPayload { interface ListTeamsPayload {
teamsDir: string; teamsDir: string;

View file

@ -19,6 +19,7 @@ import remarkGfm from 'remark-gfm';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { CopyButton } from '../common/CopyButton'; import { CopyButton } from '../common/CopyButton';
import { extractTextFromReactNode } from './markdownCopyUtils'; import { extractTextFromReactNode } from './markdownCopyUtils';
import { import {
createSearchContext, createSearchContext,

View file

@ -20,7 +20,6 @@ import {
import { useTabUI } from '@renderer/hooks/useTabUI'; import { useTabUI } from '@renderer/hooks/useTabUI';
import { useTheme } from '@renderer/hooks/useTheme'; import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { buildDisplayItemsFromMessages, buildSummary } from '@renderer/utils/aiGroupEnhancer'; import { buildDisplayItemsFromMessages, buildSummary } from '@renderer/utils/aiGroupEnhancer';
import { computeSubagentPhaseBreakdown } from '@renderer/utils/aiGroupHelpers'; import { computeSubagentPhaseBreakdown } from '@renderer/utils/aiGroupHelpers';
import { formatDuration, formatTokensCompact } from '@renderer/utils/formatters'; import { formatDuration, formatTokensCompact } from '@renderer/utils/formatters';
@ -37,6 +36,7 @@ import {
Sigma, Sigma,
Terminal, Terminal,
} from 'lucide-react'; } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { ExecutionTrace } from './ExecutionTrace'; import { ExecutionTrace } from './ExecutionTrace';
import { MetricsPill } from './MetricsPill'; import { MetricsPill } from './MetricsPill';

View file

@ -10,7 +10,6 @@ import {
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme'; import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { detectOperationalNoise } from '@renderer/utils/agentMessageFormatting'; import { detectOperationalNoise } from '@renderer/utils/agentMessageFormatting';
import { formatTokensCompact } from '@renderer/utils/formatters'; import { formatTokensCompact } from '@renderer/utils/formatters';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
@ -19,6 +18,7 @@ import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch'; import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { ChevronRight, CornerDownLeft, MessageSquare, RefreshCw } from 'lucide-react'; import { ChevronRight, CornerDownLeft, MessageSquare, RefreshCw } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { MarkdownViewer } from '../viewers/MarkdownViewer'; import { MarkdownViewer } from '../viewers/MarkdownViewer';

View file

@ -9,7 +9,7 @@ import React from 'react';
import { type ItemStatus } from '../BaseItem'; import { type ItemStatus } from '../BaseItem';
import { CollapsibleOutputSection } from './CollapsibleOutputSection'; import { CollapsibleOutputSection } from './CollapsibleOutputSection';
import { renderInput, renderOutput } from './renderHelpers'; import { extractOutputText, renderInput, renderOutput } from './renderHelpers';
import type { LinkedToolItem } from '@renderer/types/groups'; import type { LinkedToolItem } from '@renderer/types/groups';
@ -19,6 +19,13 @@ interface DefaultToolViewerProps {
} }
export const DefaultToolViewer: React.FC<DefaultToolViewerProps> = ({ linkedTool, status }) => { export const DefaultToolViewer: React.FC<DefaultToolViewerProps> = ({ linkedTool, status }) => {
const hasMeaningfulOutput =
linkedTool.result &&
(() => {
const text = extractOutputText(linkedTool.result.content).trim();
return text.length > 0 && text !== '[]' && text !== '{}';
})();
return ( return (
<> <>
{/* Input Section */} {/* Input Section */}
@ -39,7 +46,7 @@ export const DefaultToolViewer: React.FC<DefaultToolViewerProps> = ({ linkedTool
</div> </div>
{/* Output Section — Collapsed by default */} {/* Output Section — Collapsed by default */}
{!linkedTool.isOrphaned && linkedTool.result && ( {!linkedTool.isOrphaned && linkedTool.result && hasMeaningfulOutput && (
<CollapsibleOutputSection status={status}> <CollapsibleOutputSection status={status}>
{renderOutput(linkedTool.result.content)} {renderOutput(linkedTool.result.content)}
</CollapsibleOutputSection> </CollapsibleOutputSection>

View file

@ -3,9 +3,9 @@ import React from 'react';
import { CopyButton } from '@renderer/components/common/CopyButton'; import { CopyButton } from '@renderer/components/common/CopyButton';
import { PROSE_BODY } from '@renderer/constants/cssVariables'; import { PROSE_BODY } from '@renderer/constants/cssVariables';
import { FileLink, isRelativeUrl } from './viewers/FileLink';
import { extractTextFromReactNode } from './markdownCopyUtils'; import { extractTextFromReactNode } from './markdownCopyUtils';
import { highlightSearchInChildren, type SearchContext } from './searchHighlightUtils'; import { highlightSearchInChildren, type SearchContext } from './searchHighlightUtils';
import { FileLink, isRelativeUrl } from './viewers/FileLink';
import type { Components } from 'react-markdown'; import type { Components } from 'react-markdown';

View file

@ -42,6 +42,7 @@ import {
type SearchContext, type SearchContext,
} from '../searchHighlightUtils'; } from '../searchHighlightUtils';
import { highlightLine } from '../viewers/syntaxHighlighter'; import { highlightLine } from '../viewers/syntaxHighlighter';
import { FileLink, isRelativeUrl } from './FileLink'; import { FileLink, isRelativeUrl } from './FileLink';
import { MermaidDiagram } from './MermaidDiagram'; import { MermaidDiagram } from './MermaidDiagram';

View file

@ -14,7 +14,7 @@ type BannerState =
const INITIAL_STATE: BannerState = { loading: true, status: null, error: null }; const INITIAL_STATE: BannerState = { loading: true, status: null, error: null };
function PlatformInstallMatrix(): React.JSX.Element { const PlatformInstallMatrix = (): React.JSX.Element => {
return ( return (
<div className="mt-3 grid gap-2 lg:grid-cols-3"> <div className="mt-3 grid gap-2 lg:grid-cols-3">
<div <div
@ -74,7 +74,7 @@ function PlatformInstallMatrix(): React.JSX.Element {
</div> </div>
</div> </div>
); );
} };
function getPrimaryDetail(status: TmuxStatus): string { function getPrimaryDetail(status: TmuxStatus): string {
if (status.platform === 'darwin') { if (status.platform === 'darwin') {

View file

@ -7,8 +7,8 @@ import { useEffect, useState } from 'react';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { AlertTriangle, Info, Key, Plus } from 'lucide-react'; import { AlertTriangle, Info, Key, Plus } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { ApiKeyCard } from './ApiKeyCard'; import { ApiKeyCard } from './ApiKeyCard';
import { ApiKeyFormDialog } from './ApiKeyFormDialog'; import { ApiKeyFormDialog } from './ApiKeyFormDialog';

View file

@ -13,8 +13,8 @@ import {
TooltipTrigger, TooltipTrigger,
} from '@renderer/components/ui/tooltip'; } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { Check, Loader2, Trash2 } from 'lucide-react'; import { Check, Loader2, Trash2 } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import type { ExtensionOperationState } from '@shared/types/extensions'; import type { ExtensionOperationState } from '@shared/types/extensions';

View file

@ -14,11 +14,11 @@ import {
SelectValue, SelectValue,
} from '@renderer/components/ui/select'; } from '@renderer/components/ui/select';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { formatRelativeTime } from '@renderer/utils/formatters'; import { formatRelativeTime } from '@renderer/utils/formatters';
import { CLI_NOT_FOUND_MARKER } from '@shared/constants/cli'; import { CLI_NOT_FOUND_MARKER } from '@shared/constants/cli';
import { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers'; import { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers';
import { AlertTriangle, RefreshCw, Search, Server } from 'lucide-react'; import { AlertTriangle, RefreshCw, Search, Server } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { SearchInput } from '../common/SearchInput'; import { SearchInput } from '../common/SearchInput';

View file

@ -24,13 +24,13 @@ import {
SelectValue, SelectValue,
} from '@renderer/components/ui/select'; } from '@renderer/components/ui/select';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { import {
getCapabilityLabel, getCapabilityLabel,
inferCapabilities, inferCapabilities,
normalizeCategory, normalizeCategory,
} from '@shared/utils/extensionNormalizers'; } from '@shared/utils/extensionNormalizers';
import { ExternalLink, Loader2, Mail } from 'lucide-react'; import { ExternalLink, Loader2, Mail } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { InstallButton } from '../common/InstallButton'; import { InstallButton } from '../common/InstallButton';
import { InstallCountBadge } from '../common/InstallCountBadge'; import { InstallCountBadge } from '../common/InstallCountBadge';

View file

@ -16,9 +16,9 @@ import {
SelectValue, SelectValue,
} from '@renderer/components/ui/select'; } from '@renderer/components/ui/select';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { inferCapabilities, normalizeCategory } from '@shared/utils/extensionNormalizers'; import { inferCapabilities, normalizeCategory } from '@shared/utils/extensionNormalizers';
import { ArrowUpDown, Filter, Puzzle, Search } from 'lucide-react'; import { ArrowUpDown, Filter, Puzzle, Search } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { SearchInput } from '../common/SearchInput'; import { SearchInput } from '../common/SearchInput';

View file

@ -23,8 +23,8 @@ import {
DialogTitle, DialogTitle,
} from '@renderer/components/ui/dialog'; } from '@renderer/components/ui/dialog';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { AlertTriangle, ExternalLink, FolderOpen, Pencil, Trash2 } from 'lucide-react'; import { AlertTriangle, ExternalLink, FolderOpen, Pencil, Trash2 } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
interface SkillDetailDialogProps { interface SkillDetailDialogProps {
skillId: string | null; skillId: string | null;

View file

@ -6,7 +6,6 @@ import { Button } from '@renderer/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { import {
AlertTriangle, AlertTriangle,
ArrowUpAZ, ArrowUpAZ,
@ -19,6 +18,7 @@ import {
Plus, Plus,
Search, Search,
} from 'lucide-react'; } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { SearchInput } from '../common/SearchInput'; import { SearchInput } from '../common/SearchInput';

View file

@ -1,9 +1,9 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { computeTakeaways } from '@renderer/utils/reportAssessments'; import { computeTakeaways } from '@renderer/utils/reportAssessments';
import { analyzeSession } from '@renderer/utils/sessionAnalyzer'; import { analyzeSession } from '@renderer/utils/sessionAnalyzer';
import { useShallow } from 'zustand/react/shallow';
import { CostSection } from './sections/CostSection'; import { CostSection } from './sections/CostSection';
import { ErrorSection } from './sections/ErrorSection'; import { ErrorSection } from './sections/ErrorSection';

View file

@ -1,4 +1,3 @@
import type { CliProviderStatus } from '@shared/types';
import { import {
Select, Select,
SelectContent, SelectContent,
@ -13,11 +12,13 @@ import {
TooltipTrigger, TooltipTrigger,
} from '@renderer/components/ui/tooltip'; } from '@renderer/components/ui/tooltip';
type Props = { import type { CliProviderStatus } from '@shared/types';
interface Props {
provider: CliProviderStatus; provider: CliProviderStatus;
disabled?: boolean; disabled?: boolean;
onSelect: (providerId: CliProviderStatus['providerId'], backendId: string) => void; onSelect: (providerId: CliProviderStatus['providerId'], backendId: string) => void;
}; }
export function getOptionDisplayLabel( export function getOptionDisplayLabel(
option: NonNullable<CliProviderStatus['availableBackends']>[number], option: NonNullable<CliProviderStatus['availableBackends']>[number],
@ -47,11 +48,11 @@ export function getProviderRuntimeBackendSummary(provider: CliProviderStatus): s
return getOptionDisplayLabel(selectedOption, resolvedOption); return getOptionDisplayLabel(selectedOption, resolvedOption);
} }
export function ProviderRuntimeBackendSelector({ export const ProviderRuntimeBackendSelector = ({
provider, provider,
disabled = false, disabled = false,
onSelect, onSelect,
}: Props): React.JSX.Element | null { }: Props): React.JSX.Element | null => {
const options = provider.availableBackends ?? []; const options = provider.availableBackends ?? [];
if (options.length === 0) { if (options.length === 0) {
return null; return null;
@ -191,4 +192,4 @@ export function ProviderRuntimeBackendSelector({
)} )}
</div> </div>
); );
} };

View file

@ -6,7 +6,6 @@ import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors'; import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { nameColorSet } from '@renderer/utils/projectColor'; import { nameColorSet } from '@renderer/utils/projectColor';
import { formatNextRun, getCronDescription } from '@renderer/utils/scheduleFormatters'; import { formatNextRun, getCronDescription } from '@renderer/utils/scheduleFormatters';
import { import {
@ -23,6 +22,7 @@ import {
Trash2, Trash2,
Zap, Zap,
} from 'lucide-react'; } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { LaunchTeamDialog } from '../team/dialogs/LaunchTeamDialog'; import { LaunchTeamDialog } from '../team/dialogs/LaunchTeamDialog';
import { ScheduleRunLogDialog } from '../team/schedule/ScheduleRunLogDialog'; import { ScheduleRunLogDialog } from '../team/schedule/ScheduleRunLogDialog';

View file

@ -5,7 +5,6 @@ import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme'; import { useTheme } from '@renderer/hooks/useTheme';
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount'; import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers';
import { nameColorSet } from '@renderer/utils/projectColor'; import { nameColorSet } from '@renderer/utils/projectColor';
import { projectColor } from '@renderer/utils/projectColor'; import { projectColor } from '@renderer/utils/projectColor';
@ -13,6 +12,7 @@ import { projectLabelFromPath } from '@renderer/utils/taskGrouping';
import { getTaskKanbanColumn } from '@shared/utils/reviewState'; import { getTaskKanbanColumn } from '@shared/utils/reviewState';
import { format, isThisYear, isToday, isYesterday } from 'date-fns'; import { format, isThisYear, isToday, isYesterday } from 'date-fns';
import { CheckCircle2, Circle, Eye, Loader2, ShieldCheck, Trash2 } from 'lucide-react'; import { CheckCircle2, Circle, Eye, Loader2, ShieldCheck, Trash2 } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import type { GlobalTask, TeamTaskStatus } from '@shared/types'; import type { GlobalTask, TeamTaskStatus } from '@shared/types';
import type { LucideIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react';

View file

@ -1,4 +1,5 @@
import { memo } from 'react'; import { memo } from 'react';
import { formatDistanceToNowStrict } from 'date-fns'; import { formatDistanceToNowStrict } from 'date-fns';
import { ExternalLink, Square, Terminal } from 'lucide-react'; import { ExternalLink, Square, Terminal } from 'lucide-react';

View file

@ -207,7 +207,7 @@ export const ProvisioningProgressBlock = ({
<div <div
className={cn( className={cn(
surface === 'flat' surface === 'flat'
? 'rounded-none border-0 bg-transparent px-0 py-0' ? 'rounded-none border-0 bg-transparent p-0'
: 'rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-3 py-2', : 'rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-3 py-2',
isError && 'border-red-500/40 bg-red-500/10', isError && 'border-red-500/40 bg-red-500/10',
className className

View file

@ -4,11 +4,11 @@ import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer
import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers';
import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils'; import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils';
import { getTaskKanbanColumn } from '@shared/utils/reviewState'; import { getTaskKanbanColumn } from '@shared/utils/reviewState';
import { formatTaskDisplayLabel, taskMatchesRef } from '@shared/utils/taskIdentity'; import { formatTaskDisplayLabel, taskMatchesRef } from '@shared/utils/taskIdentity';
import { useShallow } from 'zustand/react/shallow';
import type { TeamTaskWithKanban } from '@shared/types'; import type { TeamTaskWithKanban } from '@shared/types';

View file

@ -824,9 +824,11 @@ export const TeamDetailView = ({
const handler = (e: Event) => { const handler = (e: Event) => {
const detail = (e as CustomEvent).detail; const detail = (e as CustomEvent).detail;
if (detail?.teamName === teamName) { if (detail?.teamName === teamName) {
const state = useStore.getState();
const displayName = state.teamByName[teamName]?.displayName ?? teamName;
useStore.getState().openTab({ useStore.getState().openTab({
type: 'graph', type: 'graph',
label: `${teamName} Graph`, label: `${displayName} Graph`,
teamName, teamName,
}); });
} }

View file

@ -1,9 +1,9 @@
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
type TeamEmptyStateProps = { interface TeamEmptyStateProps {
canCreate: boolean; canCreate: boolean;
onCreateTeam: () => void; onCreateTeam: () => void;
}; }
export const TeamEmptyState = ({ export const TeamEmptyState = ({
canCreate, canCreate,

View file

@ -3,10 +3,10 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme'; import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { shortenDisplayPath } from '@renderer/utils/pathDisplay'; import { shortenDisplayPath } from '@renderer/utils/pathDisplay';
import { highlightLines } from '@renderer/utils/syntaxHighlighter'; import { highlightLines } from '@renderer/utils/syntaxHighlighter';
import { AlertTriangle, FileText, MessageCircleQuestion, Search, Terminal } from 'lucide-react'; import { AlertTriangle, FileText, MessageCircleQuestion, Search, Terminal } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { import {
ToolApprovalSettingsContent, ToolApprovalSettingsContent,

View file

@ -17,7 +17,6 @@ import {
import { getTeamColorSet, getThemedBorder } from '@renderer/constants/teamColors'; import { getTeamColorSet, getThemedBorder } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme'; import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { import {
getMessageTypeLabel, getMessageTypeLabel,
getStructuredMessageSummary, getStructuredMessageSummary,
@ -70,6 +69,7 @@ import {
Reply, Reply,
X, X,
} from 'lucide-react'; } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { ReplyQuoteBlock } from './ReplyQuoteBlock'; import { ReplyQuoteBlock } from './ReplyQuoteBlock';
@ -111,7 +111,7 @@ function getCommandOutputSummary(text: string): string {
function parseIdlePeerSummaryRoute(summary: string): { recipient: string | null; body: string } { function parseIdlePeerSummaryRoute(summary: string): { recipient: string | null; body: string } {
const trimmed = summary.trim(); const trimmed = summary.trim();
const match = trimmed.match(/^\[to\s+([^\]]+)\]\s*(.*)$/i); const match = /^\[to\s+([^\]]+)\]\s*(.*)$/i.exec(trimmed);
if (!match) { if (!match) {
return { recipient: null, body: trimmed }; return { recipient: null, body: trimmed };
} }

View file

@ -2,7 +2,6 @@ import { CARD_BG, CARD_BORDER_STYLE, CARD_ICON_MUTED } from '@renderer/constants
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme'; import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { import {
agentAvatarUrl, agentAvatarUrl,
@ -14,6 +13,7 @@ import {
import { nameColorSet } from '@renderer/utils/projectColor'; import { nameColorSet } from '@renderer/utils/projectColor';
import { formatDistanceToNowStrict } from 'date-fns'; import { formatDistanceToNowStrict } from 'date-fns';
import { Loader2, ShieldQuestion, Users } from 'lucide-react'; import { Loader2, ShieldQuestion, Users } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import type { ResolvedTeamMember } from '@shared/types'; import type { ResolvedTeamMember } from '@shared/types';

View file

@ -41,23 +41,24 @@ import {
normalizeCreateLaunchProviderForUi, normalizeCreateLaunchProviderForUi,
} from '@renderer/utils/geminiUiFreeze'; } from '@renderer/utils/geminiUiFreeze';
import { normalizePath } from '@renderer/utils/pathNormalize'; import { normalizePath } from '@renderer/utils/pathNormalize';
import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog';
import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability'; import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability';
import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react'; import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
import { AdvancedCliSection } from './AdvancedCliSection'; import { AdvancedCliSection } from './AdvancedCliSection';
import { OptionalSettingsSection } from './OptionalSettingsSection'; import { OptionalSettingsSection } from './OptionalSettingsSection';
import { ProjectPathSelector } from './ProjectPathSelector';
import { import {
createInitialProviderChecks, createInitialProviderChecks,
failIncompleteProviderChecks, failIncompleteProviderChecks,
getProvisioningProviderBackendSummary,
getProvisioningFailureHint, getProvisioningFailureHint,
getProvisioningProviderBackendSummary,
type ProvisioningProviderCheck,
ProvisioningProviderStatusList, ProvisioningProviderStatusList,
shouldHideProvisioningProviderStatusList, shouldHideProvisioningProviderStatusList,
updateProviderCheck, updateProviderCheck,
type ProvisioningProviderCheck,
} from './ProvisioningProviderStatusList'; } from './ProvisioningProviderStatusList';
import { ProjectPathSelector } from './ProjectPathSelector';
import { SkipPermissionsCheckbox } from './SkipPermissionsCheckbox'; import { SkipPermissionsCheckbox } from './SkipPermissionsCheckbox';
import { computeEffectiveTeamModel } from './TeamModelSelector'; import { computeEffectiveTeamModel } from './TeamModelSelector';
import { getNextSuggestedTeamName } from './teamNameSets'; import { getNextSuggestedTeamName } from './teamNameSets';
@ -111,15 +112,7 @@ function isEphemeralRenderedProjectPath(projectPath: string | null | undefined):
} }
function getProviderLabel(providerId: TeamProviderId): string { function getProviderLabel(providerId: TeamProviderId): string {
switch (providerId) { return getCatalogTeamProviderLabel(providerId) ?? 'Anthropic';
case 'codex':
return 'Codex';
case 'gemini':
return 'Gemini';
case 'anthropic':
default:
return 'Anthropic';
}
} }
export interface TeamCopyData { export interface TeamCopyData {
@ -484,9 +477,7 @@ export const CreateTeamDialog = ({
}, [members, multimodelEnabled, selectedProviderId, soloTeam, syncModelsWithLead]); }, [members, multimodelEnabled, selectedProviderId, soloTeam, syncModelsWithLead]);
const runtimeBackendSummaryByProvider = useMemo(() => { const runtimeBackendSummaryByProvider = useMemo(() => {
const entries: Array<readonly [TeamProviderId, string | null]> = ( const entries: (readonly [TeamProviderId, string | null])[] = (cliStatus?.providers ?? []).map(
cliStatus?.providers ?? []
).map(
(provider) => (provider) =>
[ [
provider.providerId as TeamProviderId, provider.providerId as TeamProviderId,

View file

@ -1,6 +1,7 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { api } from '@renderer/api'; import { api } from '@renderer/api';
import { SkipPermissionsCheckbox } from '@renderer/components/team/dialogs/SkipPermissionsCheckbox';
import { import {
buildMemberDraftColorMap, buildMemberDraftColorMap,
buildMemberDraftSuggestions, buildMemberDraftSuggestions,
@ -13,7 +14,6 @@ import {
validateMemberNameInline, validateMemberNameInline,
} from '@renderer/components/team/members/MembersEditorSection'; } from '@renderer/components/team/members/MembersEditorSection';
import { TeamRosterEditorSection } from '@renderer/components/team/members/TeamRosterEditorSection'; import { TeamRosterEditorSection } from '@renderer/components/team/members/TeamRosterEditorSection';
import { SkipPermissionsCheckbox } from '@renderer/components/team/dialogs/SkipPermissionsCheckbox';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox'; import { Checkbox } from '@renderer/components/ui/checkbox';
import { Combobox } from '@renderer/components/ui/combobox'; import { Combobox } from '@renderer/components/ui/combobox';
@ -36,16 +36,16 @@ import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions'; import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
import { useTheme } from '@renderer/hooks/useTheme'; import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice';
import { import {
isGeminiUiFrozen, isGeminiUiFrozen,
normalizeCreateLaunchProviderForUi, normalizeCreateLaunchProviderForUi,
} from '@renderer/utils/geminiUiFreeze'; } from '@renderer/utils/geminiUiFreeze';
import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice';
import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability';
import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import { useShallow } from 'zustand/react/shallow';
import { normalizePath } from '@renderer/utils/pathNormalize'; import { normalizePath } from '@renderer/utils/pathNormalize';
import { nameColorSet } from '@renderer/utils/projectColor'; import { nameColorSet } from '@renderer/utils/projectColor';
import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog';
import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability';
import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import { import {
AlertTriangle, AlertTriangle,
Check, Check,
@ -56,24 +56,25 @@ import {
RotateCcw, RotateCcw,
X, X,
} from 'lucide-react'; } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { CronScheduleInput } from '../schedule/CronScheduleInput'; import { CronScheduleInput } from '../schedule/CronScheduleInput';
import { AdvancedCliSection } from './AdvancedCliSection'; import { AdvancedCliSection } from './AdvancedCliSection';
import { EffortLevelSelector } from './EffortLevelSelector'; import { EffortLevelSelector } from './EffortLevelSelector';
import { resolveLaunchDialogPrefill } from './launchDialogPrefill';
import { OptionalSettingsSection } from './OptionalSettingsSection'; import { OptionalSettingsSection } from './OptionalSettingsSection';
import { ProjectPathSelector } from './ProjectPathSelector';
import { import {
createInitialProviderChecks, createInitialProviderChecks,
failIncompleteProviderChecks, failIncompleteProviderChecks,
getProvisioningProviderBackendSummary,
getProvisioningFailureHint, getProvisioningFailureHint,
getProvisioningProviderBackendSummary,
type ProvisioningProviderCheck,
ProvisioningProviderStatusList, ProvisioningProviderStatusList,
shouldHideProvisioningProviderStatusList, shouldHideProvisioningProviderStatusList,
updateProviderCheck, updateProviderCheck,
type ProvisioningProviderCheck,
} from './ProvisioningProviderStatusList'; } from './ProvisioningProviderStatusList';
import { ProjectPathSelector } from './ProjectPathSelector';
import { resolveLaunchDialogPrefill } from './launchDialogPrefill';
import { import {
computeEffectiveTeamModel, computeEffectiveTeamModel,
formatTeamModelSummary, formatTeamModelSummary,
@ -160,15 +161,7 @@ function getStoredTeamModel(providerId: TeamProviderId): string {
} }
function getProviderLabel(providerId: TeamProviderId): string { function getProviderLabel(providerId: TeamProviderId): string {
switch (providerId) { return getCatalogTeamProviderLabel(providerId) ?? 'Anthropic';
case 'codex':
return 'Codex';
case 'gemini':
return 'Gemini';
case 'anthropic':
default:
return 'Anthropic';
}
} }
function resolveMemberDraftRuntime( function resolveMemberDraftRuntime(
@ -339,9 +332,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
); );
const runtimeBackendSummaryByProvider = useMemo(() => { const runtimeBackendSummaryByProvider = useMemo(() => {
const entries: Array<readonly [TeamProviderId, string | null]> = ( const entries: (readonly [TeamProviderId, string | null])[] = (cliStatus?.providers ?? []).map(
cliStatus?.providers ?? []
).map(
(provider) => (provider) =>
[ [
provider.providerId as TeamProviderId, provider.providerId as TeamProviderId,
@ -643,10 +634,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
const runtimeChangeNotes = useMemo(() => { const runtimeChangeNotes = useMemo(() => {
if (!isLaunch) { if (!isLaunch) {
return [] as Array<{ key: string; memberName: string; message: string }>; return [] as { key: string; memberName: string; message: string }[];
} }
const notes: Array<{ key: string; memberName: string; message: string }> = []; const notes: { key: string; memberName: string; message: string }[] = [];
const previousLeadModel = previousLaunchParams?.model?.trim() || ''; const previousLeadModel = previousLaunchParams?.model?.trim() || '';
const previousLeadEffort = previousLaunchParams?.effort; const previousLeadEffort = previousLaunchParams?.effort;
const currentLeadDisplayModel = selectedModel.trim() || effectiveLeadRuntimeModel; const currentLeadDisplayModel = selectedModel.trim() || effectiveLeadRuntimeModel;

View file

@ -1,8 +1,10 @@
import React from 'react'; import React from 'react';
import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog';
import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react';
import type { TeamProviderId } from '@shared/types'; import type { TeamProviderId } from '@shared/types';
import type { CliProviderStatus } from '@shared/types'; import type { CliProviderStatus } from '@shared/types';
import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react';
export type ProvisioningProviderCheckStatus = 'pending' | 'checking' | 'ready' | 'notes' | 'failed'; export type ProvisioningProviderCheckStatus = 'pending' | 'checking' | 'ready' | 'notes' | 'failed';
@ -14,15 +16,7 @@ export interface ProvisioningProviderCheck {
} }
export function getProvisioningProviderLabel(providerId: TeamProviderId): string { export function getProvisioningProviderLabel(providerId: TeamProviderId): string {
switch (providerId) { return getCatalogTeamProviderLabel(providerId) ?? 'Anthropic';
case 'codex':
return 'Codex';
case 'gemini':
return 'Gemini';
case 'anthropic':
default:
return 'Anthropic';
}
} }
export function createInitialProviderChecks( export function createInitialProviderChecks(
@ -150,7 +144,7 @@ function summarizeDetail(detail: string, status: ProvisioningProviderCheckStatus
function getDisplayStatusText(check: ProvisioningProviderCheck): string { function getDisplayStatusText(check: ProvisioningProviderCheck): string {
const summary = check.details.find(Boolean) const summary = check.details.find(Boolean)
? summarizeDetail(check.details[0]!, check.status) ? summarizeDetail(check.details[0], check.status)
: null; : null;
return summary ?? getStatusLabel(check.status); return summary ?? getStatusLabel(check.status);
} }
@ -194,7 +188,7 @@ function getStatusColor(status: ProvisioningProviderCheckStatus): string {
} }
} }
function StatusIcon({ status }: { status: ProvisioningProviderCheckStatus }): React.JSX.Element { const StatusIcon = ({ status }: { status: ProvisioningProviderCheckStatus }): React.JSX.Element => {
if (status === 'checking') { if (status === 'checking') {
return <Loader2 className="size-3 animate-spin" />; return <Loader2 className="size-3 animate-spin" />;
} }
@ -205,9 +199,9 @@ function StatusIcon({ status }: { status: ProvisioningProviderCheckStatus }): Re
return <AlertTriangle className="size-3" />; return <AlertTriangle className="size-3" />;
} }
return <span className="inline-block size-1.5 rounded-full bg-current opacity-60" />; return <span className="inline-block size-1.5 rounded-full bg-current opacity-60" />;
} };
export function ProvisioningProviderStatusList({ export const ProvisioningProviderStatusList = ({
checks, checks,
className = '', className = '',
suppressDetailsMatching, suppressDetailsMatching,
@ -215,7 +209,7 @@ export function ProvisioningProviderStatusList({
checks: ProvisioningProviderCheck[]; checks: ProvisioningProviderCheck[];
className?: string; className?: string;
suppressDetailsMatching?: string | null; suppressDetailsMatching?: string | null;
}): React.JSX.Element | null { }): React.JSX.Element | null => {
if (checks.length === 0) { if (checks.length === 0) {
return null; return null;
} }
@ -253,7 +247,7 @@ export function ProvisioningProviderStatusList({
})} })}
</div> </div>
); );
} };
export function getProvisioningFailureHint( export function getProvisioningFailureHint(
message: string | null | undefined, message: string | null | undefined,

View file

@ -9,8 +9,8 @@ import {
SelectValue, SelectValue,
} from '@renderer/components/ui/select'; } from '@renderer/components/ui/select';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { ChevronDown, ChevronRight, Settings } from 'lucide-react'; import { ChevronDown, ChevronRight, Settings } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import type { ToolApprovalSettings, ToolApprovalTimeoutAction } from '@shared/types'; import type { ToolApprovalSettings, ToolApprovalTimeoutAction } from '@shared/types';

View file

@ -1,4 +1,5 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog'; import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';

View file

@ -20,9 +20,10 @@ import {
import { isLeadMember } from '@shared/utils/leadDetection'; import { isLeadMember } from '@shared/utils/leadDetection';
import { ExternalLink } from 'lucide-react'; import { ExternalLink } from 'lucide-react';
import { CurrentTaskIndicator } from './CurrentTaskIndicator';
import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from '../provisioningSteps'; import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from '../provisioningSteps';
import { CurrentTaskIndicator } from './CurrentTaskIndicator';
import type { LeadActivityState, TeamTaskWithKanban } from '@shared/types'; import type { LeadActivityState, TeamTaskWithKanban } from '@shared/types';
interface MemberHoverCardProps { interface MemberHoverCardProps {

View file

@ -6,12 +6,12 @@ import {
getTeamModelLabel, getTeamModelLabel,
getTeamProviderLabel, getTeamProviderLabel,
} from '@renderer/components/team/dialogs/TeamModelSelector'; } from '@renderer/components/team/dialogs/TeamModelSelector';
import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection'; import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
import { MemberCard } from './MemberCard'; import { MemberCard } from './MemberCard';
import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice';
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize'; import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
import type { import type {
LeadActivityState, LeadActivityState,
@ -86,8 +86,7 @@ function areTaskStatusCountsMapsEquivalent(
for (const [key, leftCounts] of left) { for (const [key, leftCounts] of left) {
const rightCounts = right.get(key); const rightCounts = right.get(key);
if ( if (
!rightCounts || leftCounts.pending !== rightCounts?.pending ||
leftCounts.pending !== rightCounts.pending ||
leftCounts.inProgress !== rightCounts.inProgress || leftCounts.inProgress !== rightCounts.inProgress ||
leftCounts.completed !== rightCounts.completed leftCounts.completed !== rightCounts.completed
) { ) {
@ -107,8 +106,7 @@ function areMemberTaskMapsEquivalent(
for (const [key, leftTask] of left) { for (const [key, leftTask] of left) {
const rightTask = right.get(key); const rightTask = right.get(key);
if ( if (
!rightTask || leftTask.id !== rightTask?.id ||
leftTask.id !== rightTask.id ||
leftTask.displayId !== rightTask.displayId || leftTask.displayId !== rightTask.displayId ||
leftTask.subject !== rightTask.subject || leftTask.subject !== rightTask.subject ||
leftTask.owner !== rightTask.owner || leftTask.owner !== rightTask.owner ||
@ -150,8 +148,7 @@ function areMemberSpawnStatusesEquivalent(
for (const [key, leftEntry] of left) { for (const [key, leftEntry] of left) {
const rightEntry = right.get(key); const rightEntry = right.get(key);
if ( if (
!rightEntry || leftEntry.status !== rightEntry?.status ||
leftEntry.status !== rightEntry.status ||
leftEntry.launchState !== rightEntry.launchState || leftEntry.launchState !== rightEntry.launchState ||
leftEntry.error !== rightEntry.error || leftEntry.error !== rightEntry.error ||
leftEntry.livenessSource !== rightEntry.livenessSource || leftEntry.livenessSource !== rightEntry.livenessSource ||

View file

@ -1,8 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useStore } from '@renderer/store';
import { useTabIdOptional } from '@renderer/contexts/useTabUIContext';
import { api } from '@renderer/api'; import { api } from '@renderer/api';
import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog'; import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog';
import { import {
@ -11,6 +8,8 @@ import {
} from '@renderer/components/team/members/SubagentRecentMessagesPreview'; } from '@renderer/components/team/members/SubagentRecentMessagesPreview';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors'; import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useTabIdOptional } from '@renderer/contexts/useTabUIContext';
import { useStore } from '@renderer/store';
import { asEnhancedChunkArray } from '@renderer/types/data'; import { asEnhancedChunkArray } from '@renderer/types/data';
import { enhanceAIGroup } from '@renderer/utils/aiGroupEnhancer'; import { enhanceAIGroup } from '@renderer/utils/aiGroupEnhancer';
import { formatDuration } from '@renderer/utils/formatters'; import { formatDuration } from '@renderer/utils/formatters';

View file

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { MembersEditorSection } from './MembersEditorSection';
import { LeadModelRow } from './LeadModelRow'; import { LeadModelRow } from './LeadModelRow';
import { MembersEditorSection } from './MembersEditorSection';
import type { MemberDraft } from './membersEditorTypes'; import type { MemberDraft } from './membersEditorTypes';
import type { MentionSuggestion } from '@renderer/types/mention'; import type { MentionSuggestion } from '@renderer/types/mention';

View file

@ -1,14 +1,14 @@
import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles'; import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
import { normalizeCreateLaunchProviderForUi } from '@renderer/utils/geminiUiFreeze';
import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability';
import { serializeChipsWithText } from '@renderer/types/inlineChip'; import { serializeChipsWithText } from '@renderer/types/inlineChip';
import { normalizeCreateLaunchProviderForUi } from '@renderer/utils/geminiUiFreeze';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability';
import { isLeadMember } from '@shared/utils/leadDetection'; import { isLeadMember } from '@shared/utils/leadDetection';
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import type { MemberDraft } from './membersEditorTypes'; import type { MemberDraft } from './membersEditorTypes';
import type { MentionSuggestion } from '@renderer/types/mention'; import type { MentionSuggestion } from '@renderer/types/mention';
import type { EffortLevel, TeamProvisioningMemberInput, TeamProviderId } from '@shared/types'; import type { EffortLevel, TeamProviderId, TeamProvisioningMemberInput } from '@shared/types';
function isValidMemberName(name: string): boolean { function isValidMemberName(name: string): boolean {
if (name.length < 1 || name.length > 128) return false; if (name.length < 1 || name.length > 128) return false;

View file

@ -14,7 +14,6 @@ import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions'; import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
import { cn } from '@renderer/lib/utils'; import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice'; import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice';
import { serializeChipsWithText } from '@renderer/types/inlineChip'; import { serializeChipsWithText } from '@renderer/types/inlineChip';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { formatAgentRole } from '@renderer/utils/formatAgentRole';
@ -28,6 +27,7 @@ import { MAX_TEXT_LENGTH } from '@shared/constants';
import { isLeadMember } from '@shared/utils/leadDetection'; import { isLeadMember } from '@shared/utils/leadDetection';
import { KNOWN_SLASH_COMMANDS, parseStandaloneSlashCommand } from '@shared/utils/slashCommands'; import { KNOWN_SLASH_COMMANDS, parseStandaloneSlashCommand } from '@shared/utils/slashCommands';
import { AlertCircle, Check, ChevronDown, Mic, Paperclip, Search, Send } from 'lucide-react'; import { AlertCircle, Check, ChevronDown, Mic, Paperclip, Search, Send } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import type { ActionMode } from '@renderer/components/team/messages/ActionModeSelector'; import type { ActionMode } from '@renderer/components/team/messages/ActionModeSelector';
import type { MentionSuggestion } from '@renderer/types/mention'; import type { MentionSuggestion } from '@renderer/types/mention';

View file

@ -6,9 +6,9 @@ import { Checkbox } from '@renderer/components/ui/checkbox';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { Filter } from 'lucide-react'; import { Filter } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import type { InboxMessage, ResolvedTeamMember } from '@shared/types'; import type { InboxMessage, ResolvedTeamMember } from '@shared/types';

View file

@ -9,11 +9,10 @@ import { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages'; import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages';
import { useShallow } from 'zustand/react/shallow';
import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering'; import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { toMessageKey } from '@renderer/utils/teamMessageKey';
import { createLogger } from '@shared/utils/logger';
import { shouldExcludeInboxTextFromReplyCandidates } from '@shared/utils/idleNotificationSemantics'; import { shouldExcludeInboxTextFromReplyCandidates } from '@shared/utils/idleNotificationSemantics';
import { createLogger } from '@shared/utils/logger';
import { import {
CheckCheck, CheckCheck,
ChevronsDownUp, ChevronsDownUp,
@ -24,6 +23,7 @@ import {
Search, Search,
X, X,
} from 'lucide-react'; } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { ActivityTimeline } from '../activity/ActivityTimeline'; import { ActivityTimeline } from '../activity/ActivityTimeline';
import { getThoughtGroupKey, groupTimelineItems } from '../activity/LeadThoughtsGroup'; import { getThoughtGroupKey, groupTimelineItems } from '../activity/LeadThoughtsGroup';

View file

@ -17,13 +17,13 @@ export const DISPLAY_STEPS = [
export const DISPLAY_COMPLETE_STEP_INDEX = DISPLAY_STEPS.length; export const DISPLAY_COMPLETE_STEP_INDEX = DISPLAY_STEPS.length;
export type LaunchJoinMilestones = { export interface LaunchJoinMilestones {
expectedTeammateCount: number; expectedTeammateCount: number;
heartbeatConfirmedCount: number; heartbeatConfirmedCount: number;
processOnlyAliveCount: number; processOnlyAliveCount: number;
pendingSpawnCount: number; pendingSpawnCount: number;
failedSpawnCount: number; failedSpawnCount: number;
}; }
type DisplayStepMilestones = LaunchJoinMilestones & { type DisplayStepMilestones = LaunchJoinMilestones & {
progress: Pick<TeamProvisioningProgress, 'configReady' | 'pid' | 'state'>; progress: Pick<TeamProvisioningProgress, 'configReady' | 'pid' | 'state'>;

View file

@ -10,8 +10,8 @@ import {
DialogTitle, DialogTitle,
} from '@renderer/components/ui/dialog'; } from '@renderer/components/ui/dialog';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { AlertTriangle, Clock, Loader2, Terminal } from 'lucide-react'; import { AlertTriangle, Clock, Loader2, Terminal } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { CliLogsRichView } from '../CliLogsRichView'; import { CliLogsRichView } from '../CliLogsRichView';

View file

@ -4,7 +4,6 @@ import { Button } from '@renderer/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { formatNextRun, getCronDescription } from '@renderer/utils/scheduleFormatters'; import { formatNextRun, getCronDescription } from '@renderer/utils/scheduleFormatters';
import { import {
ChevronDown, ChevronDown,
@ -17,6 +16,7 @@ import {
Trash2, Trash2,
Zap, Zap,
} from 'lucide-react'; } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { LaunchTeamDialog } from '../dialogs/LaunchTeamDialog'; import { LaunchTeamDialog } from '../dialogs/LaunchTeamDialog';

View file

@ -2,6 +2,7 @@ import { memo, useState } from 'react';
import { ClaudeLogsSection } from '../ClaudeLogsSection'; import { ClaudeLogsSection } from '../ClaudeLogsSection';
import { MessagesPanel } from '../messages/MessagesPanel'; import { MessagesPanel } from '../messages/MessagesPanel';
import type { MouseEventHandler } from 'react'; import type { MouseEventHandler } from 'react';
import type { ComponentProps } from 'react'; import type { ComponentProps } from 'react';

View file

@ -211,23 +211,22 @@ export function getThemedBorder(colorSet: TeamColorSet, isLight: boolean): strin
export function scaleColorAlpha(color: string, factor: number): string { export function scaleColorAlpha(color: string, factor: number): string {
const safeFactor = Math.max(0, factor); const safeFactor = Math.max(0, factor);
const rgbaMatch = color.match( const rgbaMatch = /^rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*([0-9]*\.?[0-9]+)\s*\)$/i.exec(
/^rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*([0-9]*\.?[0-9]+)\s*\)$/i color
); );
if (rgbaMatch) { if (rgbaMatch) {
const [, r, g, b, alpha] = rgbaMatch; const [, r, g, b, alpha] = rgbaMatch;
return `rgba(${r}, ${g}, ${b}, ${Number(alpha) * safeFactor})`; return `rgba(${r}, ${g}, ${b}, ${Number(alpha) * safeFactor})`;
} }
const hslaMatch = color.match( const hslaMatch =
/^hsla\(\s*([^,]+)\s*,\s*([^,]+)\s*,\s*([^,]+)\s*,\s*([0-9]*\.?[0-9]+)\s*\)$/i /^hsla\(\s*([^,]+)\s*,\s*([^,]+)\s*,\s*([^,]+)\s*,\s*([0-9]*\.?[0-9]+)\s*\)$/i.exec(color);
);
if (hslaMatch) { if (hslaMatch) {
const [, hue, saturation, lightness, alpha] = hslaMatch; const [, hue, saturation, lightness, alpha] = hslaMatch;
return `hsla(${hue}, ${saturation}, ${lightness}, ${Number(alpha) * safeFactor})`; return `hsla(${hue}, ${saturation}, ${lightness}, ${Number(alpha) * safeFactor})`;
} }
const hexAlphaMatch = color.match(/^#([\da-f]{6})([\da-f]{2})$/i); const hexAlphaMatch = /^#([\da-f]{6})([\da-f]{2})$/i.exec(color);
if (hexAlphaMatch) { if (hexAlphaMatch) {
const [, hex, alphaHex] = hexAlphaMatch; const [, hex, alphaHex] = hexAlphaMatch;
const alpha = parseInt(alphaHex, 16) / 255; const alpha = parseInt(alphaHex, 16) / 255;

View file

@ -661,6 +661,9 @@ export class TeamGraphAdapter {
kind: 'inbox_message', kind: 'inbox_message',
color: '#cc88ff', color: '#cc88ff',
label, label,
preview:
getIdleGraphLabel(msg.text ?? '') ??
TeamGraphAdapter.#buildParticlePreview(msg.summary ?? cleanText),
reverse: !isIncoming, // ghost→lead edge: incoming = forward, sent = reverse reverse: !isIncoming, // ghost→lead edge: incoming = forward, sent = reverse
}); });
continue; continue;
@ -690,6 +693,9 @@ export class TeamGraphAdapter {
kind: 'inbox_message', kind: 'inbox_message',
color: msg.color ?? '#66ccff', color: msg.color ?? '#66ccff',
label: particleLabel, label: particleLabel,
preview:
getIdleGraphLabel(msgText) ??
TeamGraphAdapter.#buildParticlePreview(msg.summary ?? msg.text),
reverse: isFromTeammate, reverse: isFromTeammate,
}); });
} }
@ -783,6 +789,7 @@ export class TeamGraphAdapter {
kind: 'task_comment', kind: 'task_comment',
color: memberColors.get(newComment.author) ?? '#cc88ff', color: memberColors.get(newComment.author) ?? '#cc88ff',
label: TeamGraphAdapter.#buildParticleLabel(newComment.text, 'comment'), label: TeamGraphAdapter.#buildParticleLabel(newComment.text, 'comment'),
preview: TeamGraphAdapter.#buildParticlePreview(newComment.text),
}); });
} }
} }
@ -978,13 +985,7 @@ export class TeamGraphAdapter {
kind: 'inbox' | 'comment', kind: 'inbox' | 'comment',
max = 52 max = 52
): string | undefined { ): string | undefined {
let normalized = text?.replace(/\s+/g, ' ').trim(); const normalized = TeamGraphAdapter.#normalizeParticleText(text);
// Clean up raw task ID hashes like "#363e78de done|sent to review" → "done | sent to review"
if (normalized) {
normalized = normalized.replace(/#[a-f0-9]{6,}\s*/gi, '').trim();
// Clean pipe separators
normalized = normalized.replace(/\|/g, ' - ');
}
const prefix = kind === 'comment' ? '\u{1F4AC}' : '\u{2709}'; const prefix = kind === 'comment' ? '\u{1F4AC}' : '\u{2709}';
if (!normalized) return prefix; if (!normalized) return prefix;
const clipped = const clipped =
@ -994,6 +995,22 @@ export class TeamGraphAdapter {
return `${prefix} ${clipped}`; return `${prefix} ${clipped}`;
} }
static #buildParticlePreview(text: string | undefined, max = 180): string | undefined {
const normalized = TeamGraphAdapter.#normalizeParticleText(text);
if (!normalized) return undefined;
return normalized.length > max
? `${normalized.slice(0, Math.max(0, max - 1)).trimEnd()}\u2026`
: normalized;
}
static #normalizeParticleText(text: string | undefined): string | undefined {
let normalized = text?.replace(/\s+/g, ' ').trim();
if (!normalized) return normalized;
normalized = normalized.replace(/#[a-f0-9]{6,}\s*/gi, '').trim();
normalized = normalized.replace(/\|/g, ' - ');
return normalized;
}
static #getMessageParticleKey(msg: InboxMessage): string { static #getMessageParticleKey(msg: InboxMessage): string {
if (msg.messageId && msg.messageId.trim().length > 0) { if (msg.messageId && msg.messageId.trim().length > 0) {
return msg.messageId; return msg.messageId;

View file

@ -13,21 +13,21 @@ const DEFAULT_MAX_WIDTH = 500;
const DEFAULT_MIN_HEIGHT = 120; const DEFAULT_MIN_HEIGHT = 120;
const DEFAULT_MAX_HEIGHT = 520; const DEFAULT_MAX_HEIGHT = 520;
type HorizontalResizeOptions = { interface HorizontalResizeOptions {
width: number; width: number;
onWidthChange: (width: number) => void; onWidthChange: (width: number) => void;
minWidth?: number; minWidth?: number;
maxWidth?: number; maxWidth?: number;
side: 'left' | 'right'; side: 'left' | 'right';
}; }
type VerticalResizeOptions = { interface VerticalResizeOptions {
height: number; height: number;
onHeightChange: (height: number) => void; onHeightChange: (height: number) => void;
minHeight?: number; minHeight?: number;
maxHeight?: number; maxHeight?: number;
side: 'top' | 'bottom'; side: 'top' | 'bottom';
}; }
type UseResizablePanelOptions = HorizontalResizeOptions | VerticalResizeOptions; type UseResizablePanelOptions = HorizontalResizeOptions | VerticalResizeOptions;

View file

@ -1,9 +1,9 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { createEncodedTaskReference } from '@renderer/utils/taskReferenceUtils'; import { createEncodedTaskReference } from '@renderer/utils/taskReferenceUtils';
import { getTaskDisplayId } from '@shared/utils/taskIdentity'; import { getTaskDisplayId } from '@shared/utils/taskIdentity';
import { useShallow } from 'zustand/react/shallow';
import type { MentionSuggestion } from '@renderer/types/mention'; import type { MentionSuggestion } from '@renderer/types/mention';
import type { GlobalTask, TeamTaskWithKanban } from '@shared/types'; import type { GlobalTask, TeamTaskWithKanban } from '@shared/types';

View file

@ -1903,9 +1903,14 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
// Sync tab label with the team's display name from config // Sync tab label with the team's display name from config
const displayName = data.config.name || teamName; const displayName = data.config.name || teamName;
const allTabs = get().getAllPaneTabs(); const allTabs = get().getAllPaneTabs();
const teamTab = allTabs.find((tab) => tab.type === 'team' && tab.teamName === teamName); const relatedTabs = allTabs.filter(
if (teamTab && teamTab.label !== displayName) { (tab) => (tab.type === 'team' || tab.type === 'graph') && tab.teamName === teamName
get().updateTabLabel(teamTab.id, displayName); );
for (const tab of relatedTabs) {
const nextLabel = tab.type === 'graph' ? `${displayName} Graph` : displayName;
if (tab.label !== nextLabel) {
get().updateTabLabel(tab.id, nextLabel);
}
} }
if (opts?.skipProjectAutoSelect) { if (opts?.skipProjectAutoSelect) {

View file

@ -1,5 +1,5 @@
import type { CliProviderId } from '@shared/types/cliInstaller';
import type { TeamProviderId } from '@shared/types'; import type { TeamProviderId } from '@shared/types';
import type { CliProviderId } from '@shared/types/cliInstaller';
export const GEMINI_UI_FROZEN = true; export const GEMINI_UI_FROZEN = true;
export const GEMINI_UI_DISABLED_REASON = 'Gemini in development'; export const GEMINI_UI_DISABLED_REASON = 'Gemini in development';

View file

@ -7,7 +7,7 @@ import type {
IdleNotificationPrimaryKind, IdleNotificationPrimaryKind,
} from '@shared/utils/idleNotificationSemantics'; } from '@shared/utils/idleNotificationSemantics';
export type ClassifiedIdleNotification = { export interface ClassifiedIdleNotification {
payload: IdleNotificationPayload; payload: IdleNotificationPayload;
primaryKind: IdleNotificationPrimaryKind; primaryKind: IdleNotificationPrimaryKind;
hasPeerSummary: boolean; hasPeerSummary: boolean;
@ -15,7 +15,7 @@ export type ClassifiedIdleNotification = {
countsAsBootstrapConfirmation: boolean; countsAsBootstrapConfirmation: boolean;
liveDelivery: 'silent_finalize' | 'passive_activity' | 'visible_actionable'; liveDelivery: 'silent_finalize' | 'passive_activity' | 'visible_actionable';
uiPresentation: 'heartbeat' | 'peer_summary' | 'interrupted' | 'task_terminal' | 'failure'; uiPresentation: 'heartbeat' | 'peer_summary' | 'interrupted' | 'task_terminal' | 'failure';
}; }
export function classifyIdleNotification( export function classifyIdleNotification(
value: string | Pick<InboxMessage, 'text'> | Record<string, unknown> | IdleNotificationPayload value: string | Pick<InboxMessage, 'text'> | Record<string, unknown> | IdleNotificationPayload
@ -44,7 +44,7 @@ export function classifyIdleNotification(
: shared.primaryKind; : shared.primaryKind;
return { return {
...(shared as SharedClassifiedIdleNotification), ...shared,
liveDelivery, liveDelivery,
uiPresentation, uiPresentation,
}; };

View file

@ -1,10 +1,10 @@
export { export {
getTeamModelUiDisabledReason,
GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL, GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL,
GPT_5_1_CODEX_MINI_UI_DISABLED_REASON, GPT_5_1_CODEX_MINI_UI_DISABLED_REASON,
GPT_5_3_CODEX_SPARK_UI_DISABLED_MODEL, GPT_5_3_CODEX_SPARK_UI_DISABLED_MODEL,
GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON, GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON,
TEAM_MODEL_UI_DISABLED_BADGE_LABEL,
getTeamModelUiDisabledReason,
isTeamModelUiDisabled, isTeamModelUiDisabled,
normalizeTeamModelForUi, normalizeTeamModelForUi,
TEAM_MODEL_UI_DISABLED_BADGE_LABEL,
} from './teamModelCatalog'; } from './teamModelCatalog';

View file

@ -1,10 +1,11 @@
import type { TeamProviderId } from '@shared/types';
import { import {
doesTeamModelCarryProviderBrand, doesTeamModelCarryProviderBrand,
getTeamModelLabel, getTeamModelLabel,
getTeamProviderLabel, getTeamProviderLabel,
} from './teamModelCatalog'; } from './teamModelCatalog';
import type { TeamProviderId } from '@shared/types';
export function getTeamRuntimeModelLabel(model: string | undefined): string | undefined { export function getTeamRuntimeModelLabel(model: string | undefined): string | undefined {
return getTeamModelLabel(model); return getTeamModelLabel(model);
} }

View file

@ -1,6 +1,6 @@
import { isInboxNoiseMessage, parseInboxJson } from './inboxNoise'; import { isInboxNoiseMessage, parseInboxJson } from './inboxNoise';
export type IdleNotificationPayload = { export interface IdleNotificationPayload {
type: 'idle_notification'; type: 'idle_notification';
from?: string; from?: string;
timestamp?: string; timestamp?: string;
@ -9,17 +9,17 @@ export type IdleNotificationPayload = {
completedTaskId?: string; completedTaskId?: string;
completedStatus?: 'resolved' | 'blocked' | 'failed'; completedStatus?: 'resolved' | 'blocked' | 'failed';
failureReason?: string; failureReason?: string;
}; }
export type IdleNotificationPrimaryKind = 'heartbeat' | 'interrupted' | 'task_terminal' | 'failure'; export type IdleNotificationPrimaryKind = 'heartbeat' | 'interrupted' | 'task_terminal' | 'failure';
export type ClassifiedIdleNotification = { export interface ClassifiedIdleNotification {
payload: IdleNotificationPayload; payload: IdleNotificationPayload;
primaryKind: IdleNotificationPrimaryKind; primaryKind: IdleNotificationPrimaryKind;
hasPeerSummary: boolean; hasPeerSummary: boolean;
peerSummary: string | null; peerSummary: string | null;
countsAsBootstrapConfirmation: boolean; countsAsBootstrapConfirmation: boolean;
}; }
function getTrimmedOptionalString(value: unknown): string | null { function getTrimmedOptionalString(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;

View file

@ -82,7 +82,7 @@ export interface ParsedPermissionRequest {
*/ */
export function parsePermissionRequest(text: string): ParsedPermissionRequest | null { export function parsePermissionRequest(text: string): ParsedPermissionRequest | null {
const parsed = parseInboxJson(text); const parsed = parseInboxJson(text);
if (!parsed || parsed.type !== 'permission_request') return null; if (parsed?.type !== 'permission_request') return null;
const requestId = typeof parsed.request_id === 'string' ? parsed.request_id : null; const requestId = typeof parsed.request_id === 'string' ? parsed.request_id : null;
const agentId = typeof parsed.agent_id === 'string' ? parsed.agent_id : null; const agentId = typeof parsed.agent_id === 'string' ? parsed.agent_id : null;

View file

@ -1,12 +1,12 @@
const TASK_SINCE_GRACE_MS = 2 * 60 * 1000; const TASK_SINCE_GRACE_MS = 2 * 60 * 1000;
type TaskChangeIntervalLike = { interface TaskChangeIntervalLike {
startedAt?: string | null; startedAt?: string | null;
}; }
type TaskChangeHistoryEventLike = { interface TaskChangeHistoryEventLike {
timestamp?: string | null; timestamp?: string | null;
}; }
export interface TaskChangeSinceLike< export interface TaskChangeSinceLike<
TInterval extends TaskChangeIntervalLike = TaskChangeIntervalLike, TInterval extends TaskChangeIntervalLike = TaskChangeIntervalLike,

View file

@ -0,0 +1,48 @@
{
"uuid": "message-1",
"timestamp": "2026-04-12T10:00:00.000Z",
"sessionId": "session-1",
"boardTaskLinks": [
{
"schemaVersion": 1,
"toolUseId": "tool-1",
"task": {
"ref": "abcd1234",
"refKind": "display",
"canonicalId": "123e4567-e89b-12d3-a456-426614174000"
},
"targetRole": "subject",
"linkKind": "lifecycle",
"taskArgumentSlot": "taskId",
"actorContext": {
"relation": "idle"
}
},
{
"schemaVersion": 1,
"task": {
"ref": "",
"refKind": "display"
},
"targetRole": "subject",
"linkKind": "lifecycle",
"actorContext": {
"relation": "idle"
}
}
],
"boardTaskToolActions": [
{
"schemaVersion": 1,
"toolUseId": "tool-1",
"canonicalToolName": "task_add_comment",
"resultRefs": {
"commentId": "comment-1"
}
},
{
"schemaVersion": 1,
"canonicalToolName": "task_add_comment"
}
]
}

View file

@ -0,0 +1,185 @@
import { describe, expect, it } from 'vitest';
import type { GraphEdge, GraphNode, GraphParticle } from '@claude-teams/agent-graph';
import {
createTransientHandoffState,
selectRenderableTransientHandoffCards,
updateTransientHandoffState,
} from '../../../../packages/agent-graph/src/ui/transientHandoffs';
const leadNode: GraphNode = {
id: 'lead:team-a',
kind: 'lead',
label: 'team-a',
state: 'active',
x: 0,
y: 0,
domainRef: { kind: 'lead', teamName: 'team-a', memberName: 'team-lead' },
};
const aliceNode: GraphNode = {
id: 'member:team-a:alice',
kind: 'member',
label: 'alice',
state: 'active',
x: 100,
y: 0,
domainRef: { kind: 'member', teamName: 'team-a', memberName: 'alice' },
};
const taskNode: GraphNode = {
id: 'task:team-a:42',
kind: 'task',
label: '#42',
sublabel: 'Fix queue',
state: 'active',
x: 200,
y: 100,
domainRef: { kind: 'task', teamName: 'team-a', taskId: '42' },
};
const nodeMap = new Map<string, GraphNode>([
[leadNode.id, leadNode],
[aliceNode.id, aliceNode],
[taskNode.id, taskNode],
]);
const edgeMap = new Map<string, GraphEdge>([
[
'edge:lead:alice',
{
id: 'edge:lead:alice',
source: leadNode.id,
target: aliceNode.id,
type: 'parent-child',
},
],
[
'edge:alice:task',
{
id: 'edge:alice:task',
source: aliceNode.id,
target: taskNode.id,
type: 'message',
},
],
]);
function makeParticle(overrides?: Partial<GraphParticle>): GraphParticle {
return {
id: 'particle-1',
edgeId: 'edge:lead:alice',
progress: 0.7,
kind: 'inbox_message',
color: '#66ccff',
label: '✉ Ship the patch after green CI',
preview: 'Ship the patch after green CI and send the changelog',
reverse: true,
...overrides,
};
}
describe('transient handoff cards', () => {
it('creates one readable handoff card when a particle reaches the recipient zone', () => {
const state = createTransientHandoffState();
updateTransientHandoffState(state, {
particles: [makeParticle()],
edgeMap,
nodeMap,
time: 10,
});
const cards = selectRenderableTransientHandoffCards(state);
expect(cards).toHaveLength(1);
expect(cards[0]).toMatchObject({
edgeId: 'edge:lead:alice',
sourceNodeId: aliceNode.id,
destinationNodeId: leadNode.id,
kind: 'inbox_message',
count: 1,
preview: 'Ship the patch after green CI and send the changelog',
});
});
it('aggregates repeated sends on the same edge and keeps the latest preview', () => {
const state = createTransientHandoffState();
updateTransientHandoffState(state, {
particles: [makeParticle({ id: 'particle-1' })],
edgeMap,
nodeMap,
time: 20,
});
updateTransientHandoffState(state, {
particles: [
makeParticle({
id: 'particle-2',
label: '✉ Follow-up with the release note diff',
preview: 'Follow-up with the release note diff and deployment checklist',
}),
],
edgeMap,
nodeMap,
time: 21,
});
const cards = selectRenderableTransientHandoffCards(state);
expect(cards).toHaveLength(1);
expect(cards[0]).toMatchObject({
count: 2,
preview: 'Follow-up with the release note diff and deployment checklist',
});
});
it('expires old cards and caps renderables per destination', () => {
const state = createTransientHandoffState();
updateTransientHandoffState(state, {
particles: [
makeParticle({ id: 'comment-1', edgeId: 'edge:alice:task', kind: 'task_comment', reverse: false }),
makeParticle({ id: 'comment-2', edgeId: 'edge:alice:task', kind: 'task_comment', reverse: false }),
makeParticle({ id: 'comment-3', edgeId: 'edge:alice:task', kind: 'review_request', reverse: false }),
],
edgeMap,
nodeMap,
time: 30,
});
const cards = selectRenderableTransientHandoffCards(state);
expect(cards).toHaveLength(2);
expect(new Set(cards.map((card) => card.kind))).toEqual(
new Set(['task_comment', 'review_request'])
);
updateTransientHandoffState(state, {
particles: [],
edgeMap,
nodeMap,
time: 34,
});
expect(selectRenderableTransientHandoffCards(state)).toHaveLength(0);
});
it('does not create a card for generic idle inbox noise', () => {
const state = createTransientHandoffState();
updateTransientHandoffState(state, {
particles: [
makeParticle({
id: 'idle-1',
label: 'idle',
preview: 'idle',
}),
],
edgeMap,
nodeMap,
time: 40,
});
expect(selectRenderableTransientHandoffCards(state)).toHaveLength(0);
});
});

View file

@ -78,6 +78,7 @@ function createSliceStore() {
}, },
openTab: vi.fn(), openTab: vi.fn(),
setActiveTab: vi.fn(), setActiveTab: vi.fn(),
updateTabLabel: vi.fn(),
getAllPaneTabs: vi.fn(() => []), getAllPaneTabs: vi.fn(() => []),
warmTaskChangeSummaries: vi.fn(async () => undefined), warmTaskChangeSummaries: vi.fn(async () => undefined),
invalidateTaskChangePresence: vi.fn(), invalidateTaskChangePresence: vi.fn(),
@ -220,6 +221,35 @@ describe('teamSlice actions', () => {
expect(store.getState().warmTaskChangeSummaries).not.toHaveBeenCalled(); expect(store.getState().warmTaskChangeSummaries).not.toHaveBeenCalled();
}); });
it('syncs both team and graph tab labels when the team display name changes', async () => {
const store = createSliceStore();
const getAllPaneTabs = vi.fn(() => [
{ id: 'team-tab', type: 'team', teamName: 'my-team', label: 'my-team' },
{ id: 'graph-tab', type: 'graph', teamName: 'my-team', label: 'my-team Graph' },
]);
const updateTabLabel = vi.fn();
store.setState({
getAllPaneTabs,
updateTabLabel,
});
hoisted.getData.mockResolvedValue({
teamName: 'my-team',
config: { name: 'Northstar', members: [], projectPath: '/repo' },
tasks: [],
members: [],
messages: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
});
await store.getState().selectTeam('my-team');
expect(updateTabLabel).toHaveBeenCalledWith('team-tab', 'Northstar');
expect(updateTabLabel).toHaveBeenCalledWith('graph-tab', 'Northstar Graph');
});
it('removes non-selected team cache entries on permanent delete', async () => { it('removes non-selected team cache entries on permanent delete', async () => {
const store = createSliceStore(); const store = createSliceStore();
store.setState({ store.setState({