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 messages = require('./messages.js');
const runtimeHelpers = require('./runtimeHelpers.js');
const tasks = require('./tasks.js');
const { wrapAgentBlock } = require('./agentBlocks.js');
@ -17,19 +15,7 @@ function getReviewer(context, flags) {
}
function resolveLeadSessionId(context, flags) {
if (typeof flags.leadSessionId === 'string' && flags.leadSessionId.trim()) {
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;
}
return runtimeHelpers.resolveCanonicalLeadSessionId(context.paths, flags.leadSessionId);
}
function getCurrentReviewState(task) {

View file

@ -299,6 +299,24 @@ function resolveLeadSessionId(paths) {
: 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) {
try {
process.kill(pid, 0);
@ -497,6 +515,7 @@ module.exports = {
readTeamConfig,
resolveTeamMembers,
getCurrentRuntimeMemberIdentity,
resolveCanonicalLeadSessionId,
resolveLeadSessionId,
saveTaskAttachmentFile,
};

View file

@ -530,6 +530,25 @@ describe('agent-teams-controller API', () => {
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', () => {
const claudeDir = makeClaudeDir();
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');
});
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 () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });

View file

@ -1,5 +1,10 @@
# Итерация 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`.
Основание:
@ -263,4 +268,3 @@
- **EXDEV/rename нюансы**: в atomic write добавляем fallback copy+unlink.
- **Синхронизация UI**: после `updateKanban` делаем `refreshTeamData(teamName)` (и всё равно придёт watcher-событие; refresh должен быть идемпотентен).
- **Шум от fs.watch**: kanban-write может вызвать два refresh (ручной + watcher). Это ок, но store должен coalesce, а `refreshTeamData` — быть безопасным при частых вызовах.

View file

@ -1,5 +1,10 @@
# Итерация 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**.
Основание:
@ -293,4 +298,3 @@ Guards:
- **Race condition inbox**: атомарная запись не решает overwrite race, поэтому делаем `messageId verify` + retry/backoff, плюс in-process `withInboxLock`.
- **Конфликт при записи task.status**: после write делаем verify; если agent перезаписал — показываем warning в UI, не делаем silent fail.
- **Большие inbox**: ограничиваем количество отображаемых сообщений (например 200) и добавляем “Show more” позже (итерация 05).

View file

@ -1,5 +1,10 @@
# Итерация 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.
Основание:
@ -204,4 +209,3 @@ test/
4) Request Review → карточка в REVIEW + (если reviewer задан) сообщение ушло
5) Request Changes → task.status стал `in_progress` + owner получил сообщение
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-файлы
- Review flow: автоматическое назначение ревьюверов или ручное ревью
- Review flow: запрос ревью, ручное ревью и прямое manual approval из DONE
- 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-файлы
Единственный способ общаться с **запущенными** тиммейтами. SDK и CLI создают новые сессии, а не подключаются к существующим. Подробности: [research-messaging.md](./research-messaging.md)
@ -36,8 +38,9 @@ Kanban-позиция (REVIEW, APPROVED) хранится в `kanban-state.json`
### 3. Review Flow: Approve / Request Changes
- Есть ревьюверы в команде → автоматическое назначение через inbox
- Юзер также может вручную одобрить задачу напрямую из `DONE` без отдельного захода в `REVIEW`
- Нет ревьюверов → ручное ревью юзером (Approve / Request Changes в UI)
- При Request Changes → юзер описывает проблему (опционально) → задача к исходному owner
- При Request Changes → юзер описывает проблему (опционально) → задача возвращается owner'у в `pending` с `needsFix`
### 4. Atomic Write
Все записи через tmp + rename для предотвращения corrupted JSON.
@ -62,6 +65,10 @@ Kanban-позиция (REVIEW, APPROVED) хранится в `kanban-state.json`
### Review Flow: Approve / Request Changes
- Кнопки переименованы: **Approve** (вместо OK) и **Request Changes** (вместо Error)
- Комментарий при 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
### Members: полный список через union
@ -75,8 +82,9 @@ Kanban-позиция (REVIEW, APPROVED) хранится в `kanban-state.json`
- `IDLE`: idle > 5 минут
- `TERMINATED`: получен `shutdown_response` с `approve: true`
### @dnd-kit: click-to-move для MVP
- MVP: выбор колонки через select/dropdown (click-to-move) — проще и надёжнее
### @dnd-kit and review transitions
- Переходы между review-колонками делаются через card actions в UI
- `@dnd-kit` сейчас используется в первую очередь для перестановки задач внутри колонки
- Phase 2: полноценный D&D через `@dnd-kit`
---

View file

@ -1,5 +1,9 @@
# 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).

View file

@ -3,9 +3,11 @@
## Flow
```
TODO → IN PROGRESS → DONE → [юзер] → REVIEW → [юзер] → APPROVED
↑ |
└── Fix (error) ←──┘
TODO → IN PROGRESS → DONE ───────────────→ APPROVED
│ ↑
└→ REVIEW ───────────┘
└→ pending + needsFix
```
## Колонки
@ -15,8 +17,8 @@ TODO → IN PROGRESS → DONE → [юзер] → REVIEW → [юзер] → APPRO
| **TODO** | task.status = pending | Автоматически | Задачи ожидающие исполнителя |
| **IN PROGRESS** | task.status = in_progress | Автоматически | Агент работает |
| **DONE** | task.status = completed | Автоматически | Агент завершил |
| **REVIEW** | kanban-state.json | Юзер (drag-and-drop) | На проверке |
| **APPROVED** | kanban-state.json | Юзер (drag-and-drop) | Одобрено |
| **REVIEW** | kanban-state.json | Юзер/UI actions | На проверке |
| **APPROVED** | kanban-state.json | Юзер/UI actions | Одобрено |
---
@ -90,9 +92,20 @@ const tasks = await getAllTasks(teamName);
## 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
1. Юзер перетаскивает карточку из DONE в REVIEW
1. Юзер переводит карточку из DONE в REVIEW через UI action
2. Проверяем `kanbanState.reviewers[]`
3. **Есть ревьюверы**:
- Берём первого свободного (round-robin с балансировкой по количеству активных ревью)
@ -109,7 +122,14 @@ const tasks = await getAllTasks(teamName);
```
4. **Нет ревьюверов**:
- Записываем в 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
@ -129,8 +149,10 @@ const tasks = await getAllTasks(teamName);
2. Появляется ReviewDialog — textarea для описания проблемы (опционально)
3. Юзер нажимает "Отправить"
4. Действия:
- kanban-state: удаляем запись для этой задачи (вернётся в IN PROGRESS по status)
- task file: `status = "in_progress"` (atomic write)
- kanban-state: удаляем запись для этой задачи
- task file: `status = "pending"`
- reviewState становится `needsFix`
- в UI задача возвращается в TODO/backlog path с маркером Needs Fixes
- Inbox к исходному owner:
```json
{
@ -150,30 +172,31 @@ const tasks = await getAllTasks(teamName);
### MVP: Click-to-Move
Для MVP вместо drag-and-drop используется **click-to-move**: каждая карточка имеет кнопку или select-dropdown для смены колонки. Это проще реализовать и достаточно для первой версии.
Для текущего UI переходы между review-колонками делаются через **card actions** на карточке. Отдельный DnD сейчас используется для перестановки задач внутри колонки, а не для review state transitions.
```
[Task Card]
Subject: Rename package in pubspec.yaml
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 → 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) |
Не разрешено:
- TODO → IN PROGRESS (агент берёт сам через 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,
} 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 ──────────────────────────────────────────────────────────
export const HIT_DETECTION = {

View file

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

View file

@ -10,6 +10,7 @@ import { useRef, useEffect, useImperativeHandle, forwardRef } from 'react';
import type { GraphNode, GraphEdge, GraphParticle } from '../ports/types';
import { drawBackground, createDepthParticles, updateDepthParticles, type DepthParticle } from '../canvas/background-layer';
import { drawEdges } from '../canvas/draw-edges';
import { drawHandoffCards } from '../canvas/draw-handoff-cards';
import { drawParticles } from '../canvas/draw-particles';
import { drawAgents, drawCrossTeamNodes } from '../canvas/draw-agents';
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 { KanbanLayoutEngine } from '../layout/kanbanLayout';
import { computeAdaptiveParticleBudget, selectRenderableParticles } from './selectRenderableParticles';
import {
createTransientHandoffState,
selectRenderableTransientHandoffCards,
updateTransientHandoffState,
} from './transientHandoffs';
import type { CameraTransform } from '../hooks/useGraphCamera';
// ─── Draw State (passed by ref, not by props — no React re-renders) ─────────
export interface GraphDrawState {
teamName: string;
nodes: GraphNode[];
edges: GraphEdge[];
particles: GraphParticle[];
@ -123,6 +130,8 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(funct
const visibleNodeIdsCache = useRef(new Set<string>());
const visibleEdgeIdsCache = 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
useImperativeHandle(ref, () => ({
@ -139,6 +148,10 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(funct
if (w === 0 || h === 0) return;
try {
if (lastTeamNameRef.current !== state.teamName) {
handoffStateRef.current = createTransientHandoffState();
lastTeamNameRef.current = state.teamName;
}
const cam = state.camera;
const zoom = cam.zoom;
@ -234,6 +247,19 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(funct
focusEdgeIds: prioritizedEdgeIds,
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);
// 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);
}
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)
const perf = perfRef.current;
const frameMs = performance.now() - frameStart;

View file

@ -200,6 +200,7 @@ export function GraphView({
// 4. Draw canvas imperatively (NO React re-render)
canvasHandle.current?.draw({
teamName: data.teamName,
nodes: state.nodes,
edges: state.edges,
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 {
ApiKeyService,
RUNTIME_MANAGED_API_KEY_ENV_VARS,
ExtensionFacadeService,
GlamaMcpEnrichmentService,
McpCatalogAggregator,
@ -74,6 +73,7 @@ import {
PluginCatalogService,
PluginInstallationStateService,
PluginInstallService,
RUNTIME_MANAGED_API_KEY_ENV_VARS,
SkillsCatalogService,
SkillsMutationService,
SkillsWatcherService,
@ -103,6 +103,11 @@ import {
import { syncTelemetryFlag } from './sentry';
import {
BranchStatusService,
BoardTaskActivityRecordSource,
BoardTaskActivityService,
BoardTaskExactLogDetailService,
BoardTaskExactLogsService,
BoardTaskLogStreamService,
CliInstallerService,
configManager,
LocalFileSystemProvider,
@ -779,6 +784,13 @@ async function initializeServices(): Promise<void> {
cliInstallerService = new CliInstallerService();
ptyTerminalService = new PtyTerminalService();
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(
teamMemberLogsFinder
);
@ -924,6 +936,10 @@ async function initializeServices(): Promise<void> {
teamProvisioningService,
teamMemberLogsFinder,
memberStatsComputer,
boardTaskActivityService,
boardTaskLogStreamService,
boardTaskExactLogsService,
boardTaskExactLogDetailService,
teammateToolTracker ?? undefined,
branchStatusService ?? undefined,
{

View file

@ -8,8 +8,8 @@
*/
import {
CLI_INSTALLER_GET_STATUS,
CLI_INSTALLER_GET_PROVIDER_STATUS,
CLI_INSTALLER_GET_STATUS,
CLI_INSTALLER_INSTALL,
CLI_INSTALLER_INVALIDATE_STATUS,
// 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 { createLogger } from '@shared/utils/logger';
import type { CliInstallerService } from '../services';
import { ClaudeBinaryResolver } from '../services/team/ClaudeBinaryResolver';
import type { CliInstallerService } from '../services';
import type {
CliInstallationStatus,
CliProviderId,

View file

@ -28,12 +28,12 @@ import {
} from '@preload/constants/ipcChannels';
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 {
RUNTIME_MANAGED_API_KEY_ENV_VARS,
type ApiKeyService,
} from '../services/extensions/apikeys/ApiKeyService';
import type { ExtensionFacadeService } from '../services/extensions/ExtensionFacadeService';
import type { McpInstallService } from '../services/extensions/install/McpInstallService';
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 { 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';
const logger = createLogger('IPC:tmux');

View file

@ -20,14 +20,15 @@ const { autoUpdater } = electronUpdater;
import { app, net } from 'electron';
import type { UpdaterStatus } from '@shared/types';
import type { BrowserWindow } from 'electron';
import {
getExpectedReleaseAssetUrl,
getLatestMacMetadataUrl,
isLatestMacMetadataCompatible,
} from './updaterReleaseMetadata';
import type { UpdaterStatus } from '@shared/types';
import type { BrowserWindow } from 'electron';
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)) {
const line = rawLine.trim();
const match = line.match(/^(?:-\s+)?(url|path):\s+(.+)$/u);
const match = /^(?:-\s+)?(url|path):\s+(.+)$/u.exec(line);
if (!match) {
continue;
}

View file

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

View file

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

View file

@ -105,7 +105,7 @@ export class BranchStatusService {
try {
const branch = await this.resolver.getBranch(tracked.actualPath, { forceRefresh });
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;
if (previous !== UNSET_BRANCH && previous === branch) {

View file

@ -1,8 +1,9 @@
import { getTeamsBasePath } from '@main/utils/pathDecoder';
import { createPersistedLaunchSnapshot } from './TeamLaunchStateEvaluator';
import * as fs from 'fs';
import * as path from 'path';
import { createPersistedLaunchSnapshot } from './TeamLaunchStateEvaluator';
import type {
PersistedTeamLaunchMemberState,
PersistedTeamLaunchSnapshot,
@ -19,15 +20,15 @@ const MAX_BOOTSTRAP_JOURNAL_BYTES = 256 * 1024;
const MAX_BOOTSTRAP_LOCK_METADATA_BYTES = 64 * 1024;
const ACTIVE_BOOTSTRAP_STUCK_CLASSIFICATION_MS = 3 * 60 * 1000;
type RawBootstrapMemberState = {
interface RawBootstrapMemberState {
name?: unknown;
status?: unknown;
lastAttemptAt?: unknown;
lastObservedAt?: unknown;
failureReason?: unknown;
};
}
type RawBootstrapState = {
interface RawBootstrapState {
version?: unknown;
runId?: unknown;
teamName?: unknown;
@ -38,7 +39,7 @@ type RawBootstrapState = {
realTaskSubmissionState?: unknown;
members?: unknown;
terminal?: unknown;
};
}
type RawBootstrapJournalRecord =
| { ts?: unknown; type?: 'phase'; phase?: unknown }
@ -47,31 +48,31 @@ type RawBootstrapJournalRecord =
| { ts?: unknown; type?: 'terminal'; status?: unknown; reason?: unknown }
| { ts?: unknown; type?: 'real_task'; state?: unknown; detail?: unknown };
type RawBootstrapLockMetadata = {
interface RawBootstrapLockMetadata {
pid?: unknown;
runId?: unknown;
requestHash?: unknown;
ownerStartedAt?: unknown;
createdAt?: unknown;
nonce?: unknown;
};
}
type BootstrapStateInspection = {
interface BootstrapStateInspection {
raw: RawBootstrapState | null;
issue?: string;
};
}
type BootstrapJournalInspection = {
interface BootstrapJournalInspection {
warnings?: string[];
issue?: string;
lastPhase?: BootstrapRuntimePhase;
};
}
type BootstrapLockMetadata = {
interface BootstrapLockMetadata {
pid: number;
runId: string;
ownerStartedAt?: number;
};
}
type BootstrapRuntimePhase =
| 'validating_spec'
@ -84,13 +85,13 @@ type BootstrapRuntimePhase =
| 'failed'
| 'canceled';
type ComparableStat = {
interface ComparableStat {
dev?: number;
ino?: number;
size: number;
mode?: number;
mtimeMs?: number;
};
}
function isFiniteNumber(value: unknown): value is number {
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 type { MemberLogSummary, TeamData } from '@shared/types';
import type { TeamDataWorkerRequest, TeamDataWorkerResponse } from './teamDataWorkerTypes';
import type { MemberLogSummary, TeamData } from '@shared/types';
const logger = createLogger('Service:TeamDataWorkerClient');
const WORKER_CALL_TIMEOUT_MS = 30_000;
@ -49,10 +49,10 @@ function resolveWorkerPath(): string | null {
return null;
}
type PendingEntry = {
interface PendingEntry {
resolve: (v: unknown) => void;
reject: (e: Error) => void;
};
}
export class TeamDataWorkerClient {
private worker: Worker | null = null;

View file

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

View file

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

View file

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

View file

@ -1,11 +1,11 @@
import { createHash } from 'crypto';
type InboxIdentityLike = {
interface InboxIdentityLike {
messageId?: unknown;
from?: unknown;
timestamp?: unknown;
text?: unknown;
};
}
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)}`;

View file

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

View file

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

View file

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

View file

@ -18,12 +18,12 @@
import { createLogger } from '@shared/utils/logger';
import { LocalFileSystemProvider } from './services/infrastructure/LocalFileSystemProvider';
import {
getProjectsBasePath,
getTodosBasePath,
setClaudeBasePathOverride,
} from './utils/pathDecoder';
import { LocalFileSystemProvider } from './services/infrastructure/LocalFileSystemProvider';
import type { HttpServices } from './http';
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 { TeamMemberLogsFinder } from '@main/services/team/TeamMemberLogsFinder';
import { createLogger } from '@shared/utils/logger';
import type { MemberLogSummary } from '@shared/types';
import type {
TeamDataWorkerRequest,
TeamDataWorkerResponse,
} from '@main/services/team/teamDataWorkerTypes';
import type { MemberLogSummary } from '@shared/types';
const logger = createLogger('Worker:TeamData');

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -23,8 +23,8 @@ import {
DialogTitle,
} from '@renderer/components/ui/dialog';
import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { AlertTriangle, ExternalLink, FolderOpen, Pencil, Trash2 } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
interface SkillDetailDialogProps {
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 { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import {
AlertTriangle,
ArrowUpAZ,
@ -19,6 +18,7 @@ import {
Plus,
Search,
} from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { SearchInput } from '../common/SearchInput';

View file

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

View file

@ -1,4 +1,3 @@
import type { CliProviderStatus } from '@shared/types';
import {
Select,
SelectContent,
@ -13,11 +12,13 @@ import {
TooltipTrigger,
} from '@renderer/components/ui/tooltip';
type Props = {
import type { CliProviderStatus } from '@shared/types';
interface Props {
provider: CliProviderStatus;
disabled?: boolean;
onSelect: (providerId: CliProviderStatus['providerId'], backendId: string) => void;
};
}
export function getOptionDisplayLabel(
option: NonNullable<CliProviderStatus['availableBackends']>[number],
@ -47,11 +48,11 @@ export function getProviderRuntimeBackendSummary(provider: CliProviderStatus): s
return getOptionDisplayLabel(selectedOption, resolvedOption);
}
export function ProviderRuntimeBackendSelector({
export const ProviderRuntimeBackendSelector = ({
provider,
disabled = false,
onSelect,
}: Props): React.JSX.Element | null {
}: Props): React.JSX.Element | null => {
const options = provider.availableBackends ?? [];
if (options.length === 0) {
return null;
@ -191,4 +192,4 @@ export function ProviderRuntimeBackendSelector({
)}
</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 { getTeamColorSet } from '@renderer/constants/teamColors';
import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { nameColorSet } from '@renderer/utils/projectColor';
import { formatNextRun, getCronDescription } from '@renderer/utils/scheduleFormatters';
import {
@ -23,6 +22,7 @@ import {
Trash2,
Zap,
} from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { LaunchTeamDialog } from '../team/dialogs/LaunchTeamDialog';
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 { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers';
import { nameColorSet } 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 { format, isThisYear, isToday, isYesterday } from 'date-fns';
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 { LucideIcon } from 'lucide-react';

View file

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

View file

@ -207,7 +207,7 @@ export const ProvisioningProgressBlock = ({
<div
className={cn(
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',
isError && 'border-red-500/40 bg-red-500/10',
className

View file

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

View file

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

View file

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

View file

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

View file

@ -17,7 +17,6 @@ import {
import { getTeamColorSet, getThemedBorder } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import {
getMessageTypeLabel,
getStructuredMessageSummary,
@ -70,6 +69,7 @@ import {
Reply,
X,
} from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { ReplyQuoteBlock } from './ReplyQuoteBlock';
@ -111,7 +111,7 @@ function getCommandOutputSummary(text: string): string {
function parseIdlePeerSummaryRoute(summary: string): { recipient: string | null; body: string } {
const trimmed = summary.trim();
const match = trimmed.match(/^\[to\s+([^\]]+)\]\s*(.*)$/i);
const match = /^\[to\s+([^\]]+)\]\s*(.*)$/i.exec(trimmed);
if (!match) {
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 { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import {
agentAvatarUrl,
@ -14,6 +13,7 @@ import {
import { nameColorSet } from '@renderer/utils/projectColor';
import { formatDistanceToNowStrict } from 'date-fns';
import { Loader2, ShieldQuestion, Users } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import type { ResolvedTeamMember } from '@shared/types';

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,8 +1,5 @@
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 { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog';
import {
@ -11,6 +8,8 @@ import {
} from '@renderer/components/team/members/SubagentRecentMessagesPreview';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useTabIdOptional } from '@renderer/contexts/useTabUIContext';
import { useStore } from '@renderer/store';
import { asEnhancedChunkArray } from '@renderer/types/data';
import { enhanceAIGroup } from '@renderer/utils/aiGroupEnhancer';
import { formatDuration } from '@renderer/utils/formatters';

View file

@ -1,7 +1,7 @@
import React from 'react';
import { MembersEditorSection } from './MembersEditorSection';
import { LeadModelRow } from './LeadModelRow';
import { MembersEditorSection } from './MembersEditorSection';
import type { MemberDraft } from './membersEditorTypes';
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 { normalizeCreateLaunchProviderForUi } from '@renderer/utils/geminiUiFreeze';
import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability';
import { serializeChipsWithText } from '@renderer/types/inlineChip';
import { normalizeCreateLaunchProviderForUi } from '@renderer/utils/geminiUiFreeze';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability';
import { isLeadMember } from '@shared/utils/leadDetection';
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import type { MemberDraft } from './membersEditorTypes';
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 {
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 { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice';
import { serializeChipsWithText } from '@renderer/types/inlineChip';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
@ -28,6 +27,7 @@ import { MAX_TEXT_LENGTH } from '@shared/constants';
import { isLeadMember } from '@shared/utils/leadDetection';
import { KNOWN_SLASH_COMMANDS, parseStandaloneSlashCommand } from '@shared/utils/slashCommands';
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 { 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 { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { Filter } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
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 { useStore } from '@renderer/store';
import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages';
import { useShallow } from 'zustand/react/shallow';
import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
import { toMessageKey } from '@renderer/utils/teamMessageKey';
import { createLogger } from '@shared/utils/logger';
import { shouldExcludeInboxTextFromReplyCandidates } from '@shared/utils/idleNotificationSemantics';
import { createLogger } from '@shared/utils/logger';
import {
CheckCheck,
ChevronsDownUp,
@ -24,6 +23,7 @@ import {
Search,
X,
} from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { ActivityTimeline } from '../activity/ActivityTimeline';
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 type LaunchJoinMilestones = {
export interface LaunchJoinMilestones {
expectedTeammateCount: number;
heartbeatConfirmedCount: number;
processOnlyAliveCount: number;
pendingSpawnCount: number;
failedSpawnCount: number;
};
}
type DisplayStepMilestones = LaunchJoinMilestones & {
progress: Pick<TeamProvisioningProgress, 'configReady' | 'pid' | 'state'>;

View file

@ -10,8 +10,8 @@ import {
DialogTitle,
} from '@renderer/components/ui/dialog';
import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { AlertTriangle, Clock, Loader2, Terminal } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
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 { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { formatNextRun, getCronDescription } from '@renderer/utils/scheduleFormatters';
import {
ChevronDown,
@ -17,6 +16,7 @@ import {
Trash2,
Zap,
} from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { LaunchTeamDialog } from '../dialogs/LaunchTeamDialog';

View file

@ -2,6 +2,7 @@ import { memo, useState } from 'react';
import { ClaudeLogsSection } from '../ClaudeLogsSection';
import { MessagesPanel } from '../messages/MessagesPanel';
import type { MouseEventHandler } 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 {
const safeFactor = Math.max(0, factor);
const rgbaMatch = color.match(
/^rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*([0-9]*\.?[0-9]+)\s*\)$/i
const rgbaMatch = /^rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*([0-9]*\.?[0-9]+)\s*\)$/i.exec(
color
);
if (rgbaMatch) {
const [, r, g, b, alpha] = rgbaMatch;
return `rgba(${r}, ${g}, ${b}, ${Number(alpha) * safeFactor})`;
}
const hslaMatch = color.match(
/^hsla\(\s*([^,]+)\s*,\s*([^,]+)\s*,\s*([^,]+)\s*,\s*([0-9]*\.?[0-9]+)\s*\)$/i
);
const hslaMatch =
/^hsla\(\s*([^,]+)\s*,\s*([^,]+)\s*,\s*([^,]+)\s*,\s*([0-9]*\.?[0-9]+)\s*\)$/i.exec(color);
if (hslaMatch) {
const [, hue, saturation, lightness, alpha] = hslaMatch;
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) {
const [, hex, alphaHex] = hexAlphaMatch;
const alpha = parseInt(alphaHex, 16) / 255;

View file

@ -661,6 +661,9 @@ export class TeamGraphAdapter {
kind: 'inbox_message',
color: '#cc88ff',
label,
preview:
getIdleGraphLabel(msg.text ?? '') ??
TeamGraphAdapter.#buildParticlePreview(msg.summary ?? cleanText),
reverse: !isIncoming, // ghost→lead edge: incoming = forward, sent = reverse
});
continue;
@ -690,6 +693,9 @@ export class TeamGraphAdapter {
kind: 'inbox_message',
color: msg.color ?? '#66ccff',
label: particleLabel,
preview:
getIdleGraphLabel(msgText) ??
TeamGraphAdapter.#buildParticlePreview(msg.summary ?? msg.text),
reverse: isFromTeammate,
});
}
@ -783,6 +789,7 @@ export class TeamGraphAdapter {
kind: 'task_comment',
color: memberColors.get(newComment.author) ?? '#cc88ff',
label: TeamGraphAdapter.#buildParticleLabel(newComment.text, 'comment'),
preview: TeamGraphAdapter.#buildParticlePreview(newComment.text),
});
}
}
@ -978,13 +985,7 @@ export class TeamGraphAdapter {
kind: 'inbox' | 'comment',
max = 52
): string | undefined {
let normalized = text?.replace(/\s+/g, ' ').trim();
// 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 normalized = TeamGraphAdapter.#normalizeParticleText(text);
const prefix = kind === 'comment' ? '\u{1F4AC}' : '\u{2709}';
if (!normalized) return prefix;
const clipped =
@ -994,6 +995,22 @@ export class TeamGraphAdapter {
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 {
if (msg.messageId && msg.messageId.trim().length > 0) {
return msg.messageId;

View file

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

View file

@ -1,9 +1,9 @@
import { useMemo } from 'react';
import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { createEncodedTaskReference } from '@renderer/utils/taskReferenceUtils';
import { getTaskDisplayId } from '@shared/utils/taskIdentity';
import { useShallow } from 'zustand/react/shallow';
import type { MentionSuggestion } from '@renderer/types/mention';
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
const displayName = data.config.name || teamName;
const allTabs = get().getAllPaneTabs();
const teamTab = allTabs.find((tab) => tab.type === 'team' && tab.teamName === teamName);
if (teamTab && teamTab.label !== displayName) {
get().updateTabLabel(teamTab.id, displayName);
const relatedTabs = allTabs.filter(
(tab) => (tab.type === 'team' || tab.type === 'graph') && tab.teamName === teamName
);
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) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,12 +1,12 @@
const TASK_SINCE_GRACE_MS = 2 * 60 * 1000;
type TaskChangeIntervalLike = {
interface TaskChangeIntervalLike {
startedAt?: string | null;
};
}
type TaskChangeHistoryEventLike = {
interface TaskChangeHistoryEventLike {
timestamp?: string | null;
};
}
export interface TaskChangeSinceLike<
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(),
setActiveTab: vi.fn(),
updateTabLabel: vi.fn(),
getAllPaneTabs: vi.fn(() => []),
warmTaskChangeSummaries: vi.fn(async () => undefined),
invalidateTaskChangePresence: vi.fn(),
@ -220,6 +221,35 @@ describe('teamSlice actions', () => {
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 () => {
const store = createSliceStore();
store.setState({