diff --git a/agent-teams-controller/src/internal/review.js b/agent-teams-controller/src/internal/review.js index 6349cc48..cfc3e257 100644 --- a/agent-teams-controller/src/internal/review.js +++ b/agent-teams-controller/src/internal/review.js @@ -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) { diff --git a/agent-teams-controller/src/internal/runtimeHelpers.js b/agent-teams-controller/src/internal/runtimeHelpers.js index e6869831..f8fadc91 100644 --- a/agent-teams-controller/src/internal/runtimeHelpers.js +++ b/agent-teams-controller/src/internal/runtimeHelpers.js @@ -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, }; diff --git a/agent-teams-controller/test/controller.test.js b/agent-teams-controller/test/controller.test.js index a17fba61..2632bdd8 100644 --- a/agent-teams-controller/test/controller.test.js +++ b/agent-teams-controller/test/controller.test.js @@ -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 }); diff --git a/docs/iterations/iteration-03-kanban-board.md b/docs/iterations/iteration-03-kanban-board.md index 5f14a654..56830552 100644 --- a/docs/iterations/iteration-03-kanban-board.md +++ b/docs/iterations/iteration-03-kanban-board.md @@ -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` — быть безопасным при частых вызовах. - diff --git a/docs/iterations/iteration-04-messaging-review.md b/docs/iterations/iteration-04-messaging-review.md index 37a4763d..86f87934 100644 --- a/docs/iterations/iteration-04-messaging-review.md +++ b/docs/iterations/iteration-04-messaging-review.md @@ -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). - diff --git a/docs/iterations/iteration-05-testing-polish.md b/docs/iterations/iteration-05-testing-polish.md index 750bb42a..db7f4c6f 100644 --- a/docs/iterations/iteration-05-testing-polish.md +++ b/docs/iterations/iteration-05-testing-polish.md @@ -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с - diff --git a/docs/team-management/README.md b/docs/team-management/README.md index 8778577a..514fd0ae 100644 --- a/docs/team-management/README.md +++ b/docs/team-management/README.md @@ -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` --- diff --git a/docs/team-management/implementation.md b/docs/team-management/implementation.md index 5cde66e3..4f82b9ec 100644 --- a/docs/team-management/implementation.md +++ b/docs/team-management/implementation.md @@ -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). diff --git a/docs/team-management/kanban-design.md b/docs/team-management/kanban-design.md index e0459668..568e3f0e 100644 --- a/docs/team-management/kanban-design.md +++ b/docs/team-management/kanban-design.md @@ -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. --- diff --git a/packages/agent-graph/src/canvas/draw-handoff-cards.ts b/packages/agent-graph/src/canvas/draw-handoff-cards.ts new file mode 100644 index 00000000..2985699c --- /dev/null +++ b/packages/agent-graph/src/canvas/draw-handoff-cards.ts @@ -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; + 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(); + 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'; + } +} diff --git a/packages/agent-graph/src/constants/canvas-constants.ts b/packages/agent-graph/src/constants/canvas-constants.ts index f56a236f..f41066b3 100644 --- a/packages/agent-graph/src/constants/canvas-constants.ts +++ b/packages/agent-graph/src/constants/canvas-constants.ts @@ -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 = { diff --git a/packages/agent-graph/src/ports/types.ts b/packages/agent-graph/src/ports/types.ts index 1deb3cce..3ad3ece2 100644 --- a/packages/agent-graph/src/ports/types.ts +++ b/packages/agent-graph/src/ports/types.ts @@ -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; } diff --git a/packages/agent-graph/src/ui/GraphCanvas.tsx b/packages/agent-graph/src/ui/GraphCanvas.tsx index eee3c1ba..74bb5ed6 100644 --- a/packages/agent-graph/src/ui/GraphCanvas.tsx +++ b/packages/agent-graph/src/ui/GraphCanvas.tsx @@ -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(funct const visibleNodeIdsCache = useRef(new Set()); const visibleEdgeIdsCache = useRef(new Set()); const activeParticleEdgesCache = useRef(new Set()); + const handoffStateRef = useRef(createTransientHandoffState()); + const lastTeamNameRef = useRef(null); // Imperative draw function — called from RAF, NOT from React render useImperativeHandle(ref, () => ({ @@ -139,6 +148,10 @@ export const GraphCanvas = forwardRef(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(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(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; diff --git a/packages/agent-graph/src/ui/GraphView.tsx b/packages/agent-graph/src/ui/GraphView.tsx index 35e0290d..92893c81 100644 --- a/packages/agent-graph/src/ui/GraphView.tsx +++ b/packages/agent-graph/src/ui/GraphView.tsx @@ -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, diff --git a/packages/agent-graph/src/ui/transientHandoffs.ts b/packages/agent-graph/src/ui/transientHandoffs.ts new file mode 100644 index 00000000..78465e0f --- /dev/null +++ b/packages/agent-graph/src/ui/transientHandoffs.ts @@ -0,0 +1,163 @@ +import { HANDOFF_CARD } from '../constants/canvas-constants'; +import type { GraphEdge, GraphNode, GraphParticle, GraphParticleKind } from '../ports/types'; + +type HandoffParticleKind = Exclude; + +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; + triggeredParticleIds: Set; +} + +export function createTransientHandoffState(): TransientHandoffState { + return { + cardsByKey: new Map(), + triggeredParticleIds: new Set(), + }; +} + +export function updateTransientHandoffState( + state: TransientHandoffState, + params: { + particles: GraphParticle[]; + edgeMap: Map; + nodeMap: Map; + time: number; + } +): void { + const { particles, edgeMap, nodeMap, time } = params; + + const activeParticleIds = new Set(); + 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 | null; + focusEdgeIds?: ReadonlySet | 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(); + 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 | null, + focusEdgeIds: ReadonlySet | 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'; +} diff --git a/src/main/index.ts b/src/main/index.ts index ad118cec..980e216e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 { 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 { teamProvisioningService, teamMemberLogsFinder, memberStatsComputer, + boardTaskActivityService, + boardTaskLogStreamService, + boardTaskExactLogsService, + boardTaskExactLogDetailService, teammateToolTracker ?? undefined, branchStatusService ?? undefined, { diff --git a/src/main/ipc/cliInstaller.ts b/src/main/ipc/cliInstaller.ts index 2906e42d..c1ddec7b 100644 --- a/src/main/ipc/cliInstaller.ts +++ b/src/main/ipc/cliInstaller.ts @@ -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, diff --git a/src/main/ipc/extensions.ts b/src/main/ipc/extensions.ts index 514a9a15..7b51dfc1 100644 --- a/src/main/ipc/extensions.ts +++ b/src/main/ipc/extensions.ts @@ -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'; diff --git a/src/main/ipc/tmux.ts b/src/main/ipc/tmux.ts index 1b6301fc..ff98ee84 100644 --- a/src/main/ipc/tmux.ts +++ b/src/main/ipc/tmux.ts @@ -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'); diff --git a/src/main/services/infrastructure/UpdaterService.ts b/src/main/services/infrastructure/UpdaterService.ts index 8f093da0..a479024d 100644 --- a/src/main/services/infrastructure/UpdaterService.ts +++ b/src/main/services/infrastructure/UpdaterService.ts @@ -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'); /** diff --git a/src/main/services/infrastructure/updaterReleaseMetadata.ts b/src/main/services/infrastructure/updaterReleaseMetadata.ts index 8a32524c..cdded6b7 100644 --- a/src/main/services/infrastructure/updaterReleaseMetadata.ts +++ b/src/main/services/infrastructure/updaterReleaseMetadata.ts @@ -58,7 +58,7 @@ export function parseReleaseMetadataAssetNames(metadataText: string): Set void; reject: (e: Error) => void; -}; +} export class TeamDataWorkerClient { private worker: Worker | null = null; diff --git a/src/main/services/team/TeamLaunchStateStore.ts b/src/main/services/team/TeamLaunchStateStore.ts index 0f154c5b..556f3b0f 100644 --- a/src/main/services/team/TeamLaunchStateStore.ts +++ b/src/main/services/team/TeamLaunchStateStore.ts @@ -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'; diff --git a/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts b/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts index 4b4e4560..4e221bc2 100644 --- a/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts +++ b/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts @@ -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); } diff --git a/src/main/services/team/TeamMembersMetaStore.ts b/src/main/services/team/TeamMembersMetaStore.ts index f03cc458..4baf9776 100644 --- a/src/main/services/team/TeamMembersMetaStore.ts +++ b/src/main/services/team/TeamMembersMetaStore.ts @@ -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'; diff --git a/src/main/services/team/idleNotificationMainProcessSemantics.ts b/src/main/services/team/idleNotificationMainProcessSemantics.ts index 36f336e0..e8eaf311 100644 --- a/src/main/services/team/idleNotificationMainProcessSemantics.ts +++ b/src/main/services/team/idleNotificationMainProcessSemantics.ts @@ -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 diff --git a/src/main/services/team/inboxMessageIdentity.ts b/src/main/services/team/inboxMessageIdentity.ts index 8e699d86..cae49e5f 100644 --- a/src/main/services/team/inboxMessageIdentity.ts +++ b/src/main/services/team/inboxMessageIdentity.ts @@ -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)}`; diff --git a/src/main/services/team/memberUpdateNotifications.ts b/src/main/services/team/memberUpdateNotifications.ts index c9cbbf77..bb5acde8 100644 --- a/src/main/services/team/memberUpdateNotifications.ts +++ b/src/main/services/team/memberUpdateNotifications.ts @@ -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 diff --git a/src/main/services/team/runtimeTeammateMode.ts b/src/main/services/team/runtimeTeammateMode.ts index 2c743d6c..4dbed40b 100644 --- a/src/main/services/team/runtimeTeammateMode.ts +++ b/src/main/services/team/runtimeTeammateMode.ts @@ -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 | null = null; diff --git a/src/main/services/team/taskChangePresenceUtils.ts b/src/main/services/team/taskChangePresenceUtils.ts index 3ef3b3c8..869140c3 100644 --- a/src/main/services/team/taskChangePresenceUtils.ts +++ b/src/main/services/team/taskChangePresenceUtils.ts @@ -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 { diff --git a/src/main/standalone.ts b/src/main/standalone.ts index 71a56b19..39d44938 100644 --- a/src/main/standalone.ts +++ b/src/main/standalone.ts @@ -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'; diff --git a/src/main/workers/team-data-worker.ts b/src/main/workers/team-data-worker.ts index 06fa12c2..99d1a0dd 100644 --- a/src/main/workers/team-data-worker.ts +++ b/src/main/workers/team-data-worker.ts @@ -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'); diff --git a/src/main/workers/team-fs-worker.ts b/src/main/workers/team-fs-worker.ts index e5517368..89c83b83 100644 --- a/src/main/workers/team-fs-worker.ts +++ b/src/main/workers/team-fs-worker.ts @@ -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; diff --git a/src/renderer/components/chat/UserChatGroup.tsx b/src/renderer/components/chat/UserChatGroup.tsx index 4adad158..7cd94409 100644 --- a/src/renderer/components/chat/UserChatGroup.tsx +++ b/src/renderer/components/chat/UserChatGroup.tsx @@ -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, diff --git a/src/renderer/components/chat/items/SubagentItem.tsx b/src/renderer/components/chat/items/SubagentItem.tsx index 69bf60ae..c2aeeca4 100644 --- a/src/renderer/components/chat/items/SubagentItem.tsx +++ b/src/renderer/components/chat/items/SubagentItem.tsx @@ -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'; diff --git a/src/renderer/components/chat/items/TeammateMessageItem.tsx b/src/renderer/components/chat/items/TeammateMessageItem.tsx index 69a58aaf..81ab8195 100644 --- a/src/renderer/components/chat/items/TeammateMessageItem.tsx +++ b/src/renderer/components/chat/items/TeammateMessageItem.tsx @@ -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'; diff --git a/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx b/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx index c0a06fcf..38f8ec70 100644 --- a/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx +++ b/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx @@ -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 = ({ 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 = ({ linkedTool {/* Output Section — Collapsed by default */} - {!linkedTool.isOrphaned && linkedTool.result && ( + {!linkedTool.isOrphaned && linkedTool.result && hasMeaningfulOutput && ( {renderOutput(linkedTool.result.content)} diff --git a/src/renderer/components/chat/markdownComponents.tsx b/src/renderer/components/chat/markdownComponents.tsx index 1298010a..940c07bf 100644 --- a/src/renderer/components/chat/markdownComponents.tsx +++ b/src/renderer/components/chat/markdownComponents.tsx @@ -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'; diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index 980e03f6..a9b9454c 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -42,6 +42,7 @@ import { type SearchContext, } from '../searchHighlightUtils'; import { highlightLine } from '../viewers/syntaxHighlighter'; + import { FileLink, isRelativeUrl } from './FileLink'; import { MermaidDiagram } from './MermaidDiagram'; diff --git a/src/renderer/components/dashboard/TmuxStatusBanner.tsx b/src/renderer/components/dashboard/TmuxStatusBanner.tsx index 0392d99f..86784408 100644 --- a/src/renderer/components/dashboard/TmuxStatusBanner.tsx +++ b/src/renderer/components/dashboard/TmuxStatusBanner.tsx @@ -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 (
); -} +}; function getPrimaryDetail(status: TmuxStatus): string { if (status.platform === 'darwin') { diff --git a/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx b/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx index f4a9f824..951aecb0 100644 --- a/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx +++ b/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx @@ -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'; diff --git a/src/renderer/components/extensions/common/InstallButton.tsx b/src/renderer/components/extensions/common/InstallButton.tsx index f7e7ee3b..93bdf907 100644 --- a/src/renderer/components/extensions/common/InstallButton.tsx +++ b/src/renderer/components/extensions/common/InstallButton.tsx @@ -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'; diff --git a/src/renderer/components/extensions/mcp/McpServersPanel.tsx b/src/renderer/components/extensions/mcp/McpServersPanel.tsx index 468df397..60ae4157 100644 --- a/src/renderer/components/extensions/mcp/McpServersPanel.tsx +++ b/src/renderer/components/extensions/mcp/McpServersPanel.tsx @@ -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'; diff --git a/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx b/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx index 500e4e98..23721946 100644 --- a/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx +++ b/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx @@ -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'; diff --git a/src/renderer/components/extensions/plugins/PluginsPanel.tsx b/src/renderer/components/extensions/plugins/PluginsPanel.tsx index 258c35ce..ab8d94e9 100644 --- a/src/renderer/components/extensions/plugins/PluginsPanel.tsx +++ b/src/renderer/components/extensions/plugins/PluginsPanel.tsx @@ -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'; diff --git a/src/renderer/components/extensions/skills/SkillDetailDialog.tsx b/src/renderer/components/extensions/skills/SkillDetailDialog.tsx index 1f4c3e25..d70238d6 100644 --- a/src/renderer/components/extensions/skills/SkillDetailDialog.tsx +++ b/src/renderer/components/extensions/skills/SkillDetailDialog.tsx @@ -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; diff --git a/src/renderer/components/extensions/skills/SkillsPanel.tsx b/src/renderer/components/extensions/skills/SkillsPanel.tsx index 1898f14d..f2fd8792 100644 --- a/src/renderer/components/extensions/skills/SkillsPanel.tsx +++ b/src/renderer/components/extensions/skills/SkillsPanel.tsx @@ -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'; diff --git a/src/renderer/components/report/SessionReportTab.tsx b/src/renderer/components/report/SessionReportTab.tsx index 4ddf47bf..df2e9aa0 100644 --- a/src/renderer/components/report/SessionReportTab.tsx +++ b/src/renderer/components/report/SessionReportTab.tsx @@ -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'; diff --git a/src/renderer/components/runtime/ProviderRuntimeBackendSelector.tsx b/src/renderer/components/runtime/ProviderRuntimeBackendSelector.tsx index c7316f5b..3c90d885 100644 --- a/src/renderer/components/runtime/ProviderRuntimeBackendSelector.tsx +++ b/src/renderer/components/runtime/ProviderRuntimeBackendSelector.tsx @@ -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[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({ )}
); -} +}; diff --git a/src/renderer/components/schedules/SchedulesView.tsx b/src/renderer/components/schedules/SchedulesView.tsx index 7e1369e9..4ee09057 100644 --- a/src/renderer/components/schedules/SchedulesView.tsx +++ b/src/renderer/components/schedules/SchedulesView.tsx @@ -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'; diff --git a/src/renderer/components/sidebar/SidebarTaskItem.tsx b/src/renderer/components/sidebar/SidebarTaskItem.tsx index 6ede9398..5267a413 100644 --- a/src/renderer/components/sidebar/SidebarTaskItem.tsx +++ b/src/renderer/components/sidebar/SidebarTaskItem.tsx @@ -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'; diff --git a/src/renderer/components/team/ProcessesSection.tsx b/src/renderer/components/team/ProcessesSection.tsx index 81b4cca9..2316fafc 100644 --- a/src/renderer/components/team/ProcessesSection.tsx +++ b/src/renderer/components/team/ProcessesSection.tsx @@ -1,4 +1,5 @@ import { memo } from 'react'; + import { formatDistanceToNowStrict } from 'date-fns'; import { ExternalLink, Square, Terminal } from 'lucide-react'; diff --git a/src/renderer/components/team/ProvisioningProgressBlock.tsx b/src/renderer/components/team/ProvisioningProgressBlock.tsx index dbb0d319..497b4a35 100644 --- a/src/renderer/components/team/ProvisioningProgressBlock.tsx +++ b/src/renderer/components/team/ProvisioningProgressBlock.tsx @@ -207,7 +207,7 @@ export const ProvisioningProgressBlock = ({
{ 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, }); } diff --git a/src/renderer/components/team/TeamEmptyState.tsx b/src/renderer/components/team/TeamEmptyState.tsx index 72a3b8e6..fc02d12f 100644 --- a/src/renderer/components/team/TeamEmptyState.tsx +++ b/src/renderer/components/team/TeamEmptyState.tsx @@ -1,9 +1,9 @@ import { Button } from '@renderer/components/ui/button'; -type TeamEmptyStateProps = { +interface TeamEmptyStateProps { canCreate: boolean; onCreateTeam: () => void; -}; +} export const TeamEmptyState = ({ canCreate, diff --git a/src/renderer/components/team/ToolApprovalSheet.tsx b/src/renderer/components/team/ToolApprovalSheet.tsx index 99ebfc62..5666e93d 100644 --- a/src/renderer/components/team/ToolApprovalSheet.tsx +++ b/src/renderer/components/team/ToolApprovalSheet.tsx @@ -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, diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index 7b524fae..2b80003f 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -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 }; } diff --git a/src/renderer/components/team/activity/PendingRepliesBlock.tsx b/src/renderer/components/team/activity/PendingRepliesBlock.tsx index 89ef016a..28a9ea58 100644 --- a/src/renderer/components/team/activity/PendingRepliesBlock.tsx +++ b/src/renderer/components/team/activity/PendingRepliesBlock.tsx @@ -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'; diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 5c4ed0d7..b83ab498 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -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 = ( - cliStatus?.providers ?? [] - ).map( + const entries: (readonly [TeamProviderId, string | null])[] = (cliStatus?.providers ?? []).map( (provider) => [ provider.providerId as TeamProviderId, diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 6ad798d8..5a81a1a9 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -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 = ( - 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; diff --git a/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx index 195a6344..dcf79f8d 100644 --- a/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx +++ b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx @@ -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 ; } @@ -205,9 +199,9 @@ function StatusIcon({ status }: { status: ProvisioningProviderCheckStatus }): Re return ; } return ; -} +}; -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({ })}
); -} +}; export function getProvisioningFailureHint( message: string | null | undefined, diff --git a/src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx b/src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx index 617f55cb..38003e2f 100644 --- a/src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx +++ b/src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx @@ -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'; diff --git a/src/renderer/components/team/members/MemberDetailDialog.tsx b/src/renderer/components/team/members/MemberDetailDialog.tsx index 402a8236..632f641e 100644 --- a/src/renderer/components/team/members/MemberDetailDialog.tsx +++ b/src/renderer/components/team/members/MemberDetailDialog.tsx @@ -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'; diff --git a/src/renderer/components/team/members/MemberHoverCard.tsx b/src/renderer/components/team/members/MemberHoverCard.tsx index bf911bd5..1834a571 100644 --- a/src/renderer/components/team/members/MemberHoverCard.tsx +++ b/src/renderer/components/team/members/MemberHoverCard.tsx @@ -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 { diff --git a/src/renderer/components/team/members/MemberList.tsx b/src/renderer/components/team/members/MemberList.tsx index f4a98b18..c19f4b10 100644 --- a/src/renderer/components/team/members/MemberList.tsx +++ b/src/renderer/components/team/members/MemberList.tsx @@ -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 || diff --git a/src/renderer/components/team/members/MemberLogsTab.tsx b/src/renderer/components/team/members/MemberLogsTab.tsx index 4943d506..316c2c17 100644 --- a/src/renderer/components/team/members/MemberLogsTab.tsx +++ b/src/renderer/components/team/members/MemberLogsTab.tsx @@ -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'; diff --git a/src/renderer/components/team/members/TeamRosterEditorSection.tsx b/src/renderer/components/team/members/TeamRosterEditorSection.tsx index 252c3e34..e02a2262 100644 --- a/src/renderer/components/team/members/TeamRosterEditorSection.tsx +++ b/src/renderer/components/team/members/TeamRosterEditorSection.tsx @@ -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'; diff --git a/src/renderer/components/team/members/membersEditorUtils.ts b/src/renderer/components/team/members/membersEditorUtils.ts index 6ab1484d..9fafc7db 100644 --- a/src/renderer/components/team/members/membersEditorUtils.ts +++ b/src/renderer/components/team/members/membersEditorUtils.ts @@ -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; diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index 2f2fa1e1..0f039b3a 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -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'; diff --git a/src/renderer/components/team/messages/MessagesFilterPopover.tsx b/src/renderer/components/team/messages/MessagesFilterPopover.tsx index 9716b134..83daddc6 100644 --- a/src/renderer/components/team/messages/MessagesFilterPopover.tsx +++ b/src/renderer/components/team/messages/MessagesFilterPopover.tsx @@ -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'; diff --git a/src/renderer/components/team/messages/MessagesPanel.tsx b/src/renderer/components/team/messages/MessagesPanel.tsx index 71ec1294..13729a74 100644 --- a/src/renderer/components/team/messages/MessagesPanel.tsx +++ b/src/renderer/components/team/messages/MessagesPanel.tsx @@ -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'; diff --git a/src/renderer/components/team/provisioningSteps.ts b/src/renderer/components/team/provisioningSteps.ts index 6edd3771..bb5eb924 100644 --- a/src/renderer/components/team/provisioningSteps.ts +++ b/src/renderer/components/team/provisioningSteps.ts @@ -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; diff --git a/src/renderer/components/team/schedule/ScheduleRunLogDialog.tsx b/src/renderer/components/team/schedule/ScheduleRunLogDialog.tsx index 2566d72c..e26cc6c2 100644 --- a/src/renderer/components/team/schedule/ScheduleRunLogDialog.tsx +++ b/src/renderer/components/team/schedule/ScheduleRunLogDialog.tsx @@ -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'; diff --git a/src/renderer/components/team/schedule/ScheduleSection.tsx b/src/renderer/components/team/schedule/ScheduleSection.tsx index 02204416..b8f44bd4 100644 --- a/src/renderer/components/team/schedule/ScheduleSection.tsx +++ b/src/renderer/components/team/schedule/ScheduleSection.tsx @@ -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'; diff --git a/src/renderer/components/team/sidebar/TeamSidebarRail.tsx b/src/renderer/components/team/sidebar/TeamSidebarRail.tsx index 2a8f8bbc..c72db17e 100644 --- a/src/renderer/components/team/sidebar/TeamSidebarRail.tsx +++ b/src/renderer/components/team/sidebar/TeamSidebarRail.tsx @@ -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'; diff --git a/src/renderer/constants/teamColors.ts b/src/renderer/constants/teamColors.ts index bc462313..b6f53552 100644 --- a/src/renderer/constants/teamColors.ts +++ b/src/renderer/constants/teamColors.ts @@ -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; diff --git a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts index 9dd764a0..e172af8b 100644 --- a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts +++ b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts @@ -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; diff --git a/src/renderer/hooks/useResizablePanel.ts b/src/renderer/hooks/useResizablePanel.ts index c88cf47b..04f0a0b7 100644 --- a/src/renderer/hooks/useResizablePanel.ts +++ b/src/renderer/hooks/useResizablePanel.ts @@ -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; diff --git a/src/renderer/hooks/useTaskSuggestions.ts b/src/renderer/hooks/useTaskSuggestions.ts index 0c21e6e3..0a6f3218 100644 --- a/src/renderer/hooks/useTaskSuggestions.ts +++ b/src/renderer/hooks/useTaskSuggestions.ts @@ -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'; diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 742e6084..54cf155f 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -1903,9 +1903,14 @@ export const createTeamSlice: StateCreator = (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) { diff --git a/src/renderer/utils/geminiUiFreeze.ts b/src/renderer/utils/geminiUiFreeze.ts index 52653672..e96e2bf2 100644 --- a/src/renderer/utils/geminiUiFreeze.ts +++ b/src/renderer/utils/geminiUiFreeze.ts @@ -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'; diff --git a/src/renderer/utils/idleNotificationSemantics.ts b/src/renderer/utils/idleNotificationSemantics.ts index 8941bf08..7590bbb8 100644 --- a/src/renderer/utils/idleNotificationSemantics.ts +++ b/src/renderer/utils/idleNotificationSemantics.ts @@ -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 | Record | IdleNotificationPayload @@ -44,7 +44,7 @@ export function classifyIdleNotification( : shared.primaryKind; return { - ...(shared as SharedClassifiedIdleNotification), + ...shared, liveDelivery, uiPresentation, }; diff --git a/src/renderer/utils/teamModelAvailability.ts b/src/renderer/utils/teamModelAvailability.ts index 133281c1..292baa03 100644 --- a/src/renderer/utils/teamModelAvailability.ts +++ b/src/renderer/utils/teamModelAvailability.ts @@ -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'; diff --git a/src/renderer/utils/teamRuntimeSummary.ts b/src/renderer/utils/teamRuntimeSummary.ts index 17b0ed8e..e016f1e0 100644 --- a/src/renderer/utils/teamRuntimeSummary.ts +++ b/src/renderer/utils/teamRuntimeSummary.ts @@ -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); } diff --git a/src/shared/utils/idleNotificationSemantics.ts b/src/shared/utils/idleNotificationSemantics.ts index 227c535d..0e61080c 100644 --- a/src/shared/utils/idleNotificationSemantics.ts +++ b/src/shared/utils/idleNotificationSemantics.ts @@ -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; diff --git a/src/shared/utils/inboxNoise.ts b/src/shared/utils/inboxNoise.ts index 36c2cdaa..adb65511 100644 --- a/src/shared/utils/inboxNoise.ts +++ b/src/shared/utils/inboxNoise.ts @@ -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; diff --git a/src/shared/utils/taskChangeSince.ts b/src/shared/utils/taskChangeSince.ts index b21b92ac..aaff4948 100644 --- a/src/shared/utils/taskChangeSince.ts +++ b/src/shared/utils/taskChangeSince.ts @@ -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, diff --git a/test/fixtures/team/board-task-activity-message-v1.json b/test/fixtures/team/board-task-activity-message-v1.json new file mode 100644 index 00000000..8d627687 --- /dev/null +++ b/test/fixtures/team/board-task-activity-message-v1.json @@ -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" + } + ] +} diff --git a/test/renderer/features/agent-graph/transientHandoffs.test.ts b/test/renderer/features/agent-graph/transientHandoffs.test.ts new file mode 100644 index 00000000..1d5fd771 --- /dev/null +++ b/test/renderer/features/agent-graph/transientHandoffs.test.ts @@ -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([ + [leadNode.id, leadNode], + [aliceNode.id, aliceNode], + [taskNode.id, taskNode], +]); + +const edgeMap = new Map([ + [ + '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 { + 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); + }); +}); diff --git a/test/renderer/store/teamSlice.test.ts b/test/renderer/store/teamSlice.test.ts index 8a352043..c0efa73a 100644 --- a/test/renderer/store/teamSlice.test.ts +++ b/test/renderer/store/teamSlice.test.ts @@ -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({