chore(workspace): checkpoint remaining claude team changes
This commit is contained in:
parent
32cea2a927
commit
9ca8055695
95 changed files with 1144 additions and 254 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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` — быть безопасным при частых вызовах.
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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с
|
||||
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
268
packages/agent-graph/src/canvas/draw-handoff-cards.ts
Normal file
268
packages/agent-graph/src/canvas/draw-handoff-cards.ts
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
import { COLORS } from '../constants/colors';
|
||||
import { HANDOFF_CARD, NODE, TASK_PILL, MIN_VISIBLE_OPACITY } from '../constants/canvas-constants';
|
||||
import type { CameraTransform } from '../hooks/useGraphCamera';
|
||||
import type { GraphNode } from '../ports/types';
|
||||
import type { TransientHandoffCard } from '../ui/transientHandoffs';
|
||||
import { truncateText } from './draw-misc';
|
||||
import { hexWithAlpha, measureTextCached } from './render-cache';
|
||||
|
||||
export function drawHandoffCards(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
params: {
|
||||
cards: TransientHandoffCard[];
|
||||
nodeMap: Map<string, GraphNode>;
|
||||
time: number;
|
||||
camera: CameraTransform;
|
||||
viewport: { width: number; height: number };
|
||||
}
|
||||
): void {
|
||||
const { cards, nodeMap, time, camera, viewport } = params;
|
||||
if (cards.length === 0) return;
|
||||
|
||||
const stackIndexByDestination = new Map<string, number>();
|
||||
let drawnCount = 0;
|
||||
|
||||
for (const card of cards) {
|
||||
if (drawnCount >= HANDOFF_CARD.maxVisible) break;
|
||||
const destinationNode = nodeMap.get(card.destinationNodeId);
|
||||
if (!destinationNode || destinationNode.x == null || destinationNode.y == null) continue;
|
||||
|
||||
const alpha = getCardAlpha(card, time);
|
||||
if (alpha <= MIN_VISIBLE_OPACITY) continue;
|
||||
|
||||
const previewLines = buildPreviewLines(ctx, card.preview);
|
||||
const height = HANDOFF_CARD.baseHeight + previewLines.length * HANDOFF_CARD.previewLineHeight;
|
||||
const stackIndex = stackIndexByDestination.get(card.destinationNodeId) ?? 0;
|
||||
stackIndexByDestination.set(card.destinationNodeId, stackIndex + 1);
|
||||
|
||||
const position = getCardPosition({
|
||||
node: destinationNode,
|
||||
camera,
|
||||
viewport,
|
||||
height,
|
||||
stackIndex,
|
||||
});
|
||||
if (!position) continue;
|
||||
|
||||
drawCard({
|
||||
ctx,
|
||||
card,
|
||||
previewLines,
|
||||
alpha,
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
width: HANDOFF_CARD.width,
|
||||
height,
|
||||
});
|
||||
drawnCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function getCardAlpha(card: TransientHandoffCard, time: number): number {
|
||||
const fadeIn = Math.min(1, (time - card.activatedAt) / HANDOFF_CARD.fadeInSeconds);
|
||||
const fadeOutRemaining = card.expiresAt - time;
|
||||
const fadeOut = fadeOutRemaining <= HANDOFF_CARD.fadeOutSeconds
|
||||
? Math.max(0, fadeOutRemaining / HANDOFF_CARD.fadeOutSeconds)
|
||||
: 1;
|
||||
return Math.max(0, Math.min(1, fadeIn * fadeOut));
|
||||
}
|
||||
|
||||
function getCardPosition(params: {
|
||||
node: GraphNode;
|
||||
camera: CameraTransform;
|
||||
viewport: { width: number; height: number };
|
||||
height: number;
|
||||
stackIndex: number;
|
||||
}): { x: number; y: number } | null {
|
||||
const { node, camera, viewport, height, stackIndex } = params;
|
||||
const screenX = node.x! * camera.zoom + camera.x;
|
||||
const screenY = node.y! * camera.zoom + camera.y;
|
||||
|
||||
const visibleMargin = 80;
|
||||
if (
|
||||
screenX < -visibleMargin ||
|
||||
screenX > viewport.width + visibleMargin ||
|
||||
screenY < -visibleMargin ||
|
||||
screenY > viewport.height + visibleMargin
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const anchorGap = getAnchorGap(node, camera.zoom);
|
||||
const stackOffset = stackIndex * (height + HANDOFF_CARD.stackGap);
|
||||
let x = screenX + anchorGap.x;
|
||||
let y = screenY + anchorGap.y - stackOffset;
|
||||
|
||||
if (x + HANDOFF_CARD.width > viewport.width - HANDOFF_CARD.viewportPadding) {
|
||||
x = screenX - HANDOFF_CARD.width - Math.abs(anchorGap.x);
|
||||
}
|
||||
if (x < HANDOFF_CARD.viewportPadding) {
|
||||
x = HANDOFF_CARD.viewportPadding;
|
||||
}
|
||||
|
||||
if (y < HANDOFF_CARD.viewportPadding) {
|
||||
y = screenY + Math.abs(anchorGap.y) + stackOffset;
|
||||
}
|
||||
if (y + height > viewport.height - HANDOFF_CARD.viewportPadding) {
|
||||
y = Math.max(HANDOFF_CARD.viewportPadding, viewport.height - height - HANDOFF_CARD.viewportPadding);
|
||||
}
|
||||
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
function getAnchorGap(node: GraphNode, zoom: number): { x: number; y: number } {
|
||||
switch (node.kind) {
|
||||
case 'lead':
|
||||
return {
|
||||
x: NODE.radiusLead * zoom + HANDOFF_CARD.anchorGap,
|
||||
y: -(NODE.radiusLead * zoom + HANDOFF_CARD.anchorGap),
|
||||
};
|
||||
case 'member':
|
||||
return {
|
||||
x: NODE.radiusMember * zoom + HANDOFF_CARD.anchorGap,
|
||||
y: -(NODE.radiusMember * zoom + HANDOFF_CARD.anchorGap),
|
||||
};
|
||||
case 'task':
|
||||
return {
|
||||
x: TASK_PILL.width * zoom * 0.5 + HANDOFF_CARD.anchorGap,
|
||||
y: -(TASK_PILL.height * zoom * 0.5 + HANDOFF_CARD.anchorGap),
|
||||
};
|
||||
case 'process':
|
||||
return {
|
||||
x: NODE.radiusProcess * zoom + HANDOFF_CARD.anchorGap,
|
||||
y: -(NODE.radiusProcess * zoom + HANDOFF_CARD.anchorGap),
|
||||
};
|
||||
case 'crossteam':
|
||||
return {
|
||||
x: NODE.radiusCrossTeam * zoom + HANDOFF_CARD.anchorGap,
|
||||
y: -(NODE.radiusCrossTeam * zoom + HANDOFF_CARD.anchorGap),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function drawCard(params: {
|
||||
ctx: CanvasRenderingContext2D;
|
||||
card: TransientHandoffCard;
|
||||
previewLines: string[];
|
||||
alpha: number;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}): void {
|
||||
const { ctx, card, previewLines, alpha, x, y, width, height } = params;
|
||||
const accent = card.color || COLORS.particleInboxMessage;
|
||||
const radius = 10;
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.shadowColor = hexWithAlpha(accent, 0.22 * alpha);
|
||||
ctx.shadowBlur = 12;
|
||||
ctx.fillStyle = hexWithAlpha('#08111f', 0.92);
|
||||
ctx.strokeStyle = hexWithAlpha(accent, 0.38);
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x, y, width, height, radius);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.fillStyle = hexWithAlpha(accent, 0.14);
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x + 8, y + 8, 54, 16, 6);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = hexWithAlpha(accent, 0.92);
|
||||
ctx.font = 'bold 8px monospace';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(getKindLabel(card.kind), x + 16, y + 19);
|
||||
|
||||
if (card.count > 1) {
|
||||
const countText = `+${card.count - 1}`;
|
||||
ctx.font = 'bold 8px monospace';
|
||||
const countWidth = measureTextCached(ctx, ctx.font, countText) + 14;
|
||||
ctx.fillStyle = hexWithAlpha(COLORS.holoBright, 0.16);
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x + width - countWidth - 10, y + 8, countWidth, 16, 6);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = COLORS.holoBright;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(countText, x + width - countWidth / 2 - 10, y + 19);
|
||||
}
|
||||
|
||||
ctx.textAlign = 'left';
|
||||
ctx.font = 'bold 10px monospace';
|
||||
ctx.fillStyle = COLORS.textPrimary;
|
||||
const route = truncateText(
|
||||
ctx,
|
||||
`${card.sourceLabel} -> ${card.destinationLabel}`,
|
||||
width - 20,
|
||||
ctx.font
|
||||
);
|
||||
ctx.fillText(route, x + 10, y + 36);
|
||||
|
||||
if (previewLines.length > 0) {
|
||||
ctx.font = '8px monospace';
|
||||
ctx.fillStyle = hexWithAlpha(COLORS.holoBright, 0.86);
|
||||
for (let index = 0; index < previewLines.length; index += 1) {
|
||||
ctx.fillText(
|
||||
previewLines[index],
|
||||
x + 10,
|
||||
y + 50 + index * HANDOFF_CARD.previewLineHeight
|
||||
);
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function buildPreviewLines(ctx: CanvasRenderingContext2D, preview: string | undefined): string[] {
|
||||
if (!preview) return [];
|
||||
ctx.font = '8px monospace';
|
||||
let remaining = preview.replace(/\s+/g, ' ').trim();
|
||||
if (remaining.length === 0) return [];
|
||||
const lines: string[] = [];
|
||||
for (let index = 0; index < HANDOFF_CARD.previewMaxLines && remaining.length > 0; index += 1) {
|
||||
if (index === HANDOFF_CARD.previewMaxLines - 1) {
|
||||
lines.push(truncateText(ctx, remaining, HANDOFF_CARD.previewMaxWidth, ctx.font));
|
||||
break;
|
||||
}
|
||||
|
||||
const words = remaining.split(' ');
|
||||
let line = '';
|
||||
let consumedWords = 0;
|
||||
for (const word of words) {
|
||||
const candidate = line.length > 0 ? `${line} ${word}` : word;
|
||||
if (measureTextCached(ctx, ctx.font, candidate) <= HANDOFF_CARD.previewMaxWidth) {
|
||||
line = candidate;
|
||||
consumedWords += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (consumedWords === 0) {
|
||||
lines.push(truncateText(ctx, words[0] ?? remaining, HANDOFF_CARD.previewMaxWidth, ctx.font));
|
||||
break;
|
||||
}
|
||||
|
||||
lines.push(line);
|
||||
remaining = words.slice(consumedWords).join(' ').trim();
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
function getKindLabel(kind: TransientHandoffCard['kind']): string {
|
||||
switch (kind) {
|
||||
case 'task_comment':
|
||||
return 'COMMENT';
|
||||
case 'task_assign':
|
||||
return 'TASK';
|
||||
case 'review_request':
|
||||
return 'REVIEW';
|
||||
case 'review_response':
|
||||
return 'REPLY';
|
||||
case 'inbox_message':
|
||||
return 'MESSAGE';
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { useRef, useEffect, useImperativeHandle, forwardRef } from 'react';
|
|||
import type { GraphNode, GraphEdge, GraphParticle } from '../ports/types';
|
||||
import { drawBackground, createDepthParticles, updateDepthParticles, type DepthParticle } from '../canvas/background-layer';
|
||||
import { drawEdges } from '../canvas/draw-edges';
|
||||
import { drawHandoffCards } from '../canvas/draw-handoff-cards';
|
||||
import { drawParticles } from '../canvas/draw-particles';
|
||||
import { drawAgents, drawCrossTeamNodes } from '../canvas/draw-agents';
|
||||
import { drawTasks, drawColumnHeaders } from '../canvas/draw-tasks';
|
||||
|
|
@ -18,11 +19,17 @@ import { drawEffects, type VisualEffect } from '../canvas/draw-effects';
|
|||
import { BloomRenderer } from '../canvas/bloom-renderer';
|
||||
import { KanbanLayoutEngine } from '../layout/kanbanLayout';
|
||||
import { computeAdaptiveParticleBudget, selectRenderableParticles } from './selectRenderableParticles';
|
||||
import {
|
||||
createTransientHandoffState,
|
||||
selectRenderableTransientHandoffCards,
|
||||
updateTransientHandoffState,
|
||||
} from './transientHandoffs';
|
||||
import type { CameraTransform } from '../hooks/useGraphCamera';
|
||||
|
||||
// ─── Draw State (passed by ref, not by props — no React re-renders) ─────────
|
||||
|
||||
export interface GraphDrawState {
|
||||
teamName: string;
|
||||
nodes: GraphNode[];
|
||||
edges: GraphEdge[];
|
||||
particles: GraphParticle[];
|
||||
|
|
@ -123,6 +130,8 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(funct
|
|||
const visibleNodeIdsCache = useRef(new Set<string>());
|
||||
const visibleEdgeIdsCache = useRef(new Set<string>());
|
||||
const activeParticleEdgesCache = useRef(new Set<string>());
|
||||
const handoffStateRef = useRef(createTransientHandoffState());
|
||||
const lastTeamNameRef = useRef<string | null>(null);
|
||||
|
||||
// Imperative draw function — called from RAF, NOT from React render
|
||||
useImperativeHandle(ref, () => ({
|
||||
|
|
@ -139,6 +148,10 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(funct
|
|||
if (w === 0 || h === 0) return;
|
||||
|
||||
try {
|
||||
if (lastTeamNameRef.current !== state.teamName) {
|
||||
handoffStateRef.current = createTransientHandoffState();
|
||||
lastTeamNameRef.current = state.teamName;
|
||||
}
|
||||
|
||||
const cam = state.camera;
|
||||
const zoom = cam.zoom;
|
||||
|
|
@ -234,6 +247,19 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(funct
|
|||
focusEdgeIds: prioritizedEdgeIds,
|
||||
budget: particleBudget,
|
||||
});
|
||||
updateTransientHandoffState(handoffStateRef.current, {
|
||||
particles: state.particles,
|
||||
edgeMap,
|
||||
nodeMap,
|
||||
time: state.time,
|
||||
});
|
||||
const renderableHandoffCards = selectRenderableTransientHandoffCards(
|
||||
handoffStateRef.current,
|
||||
{
|
||||
focusNodeIds: state.focusNodeIds,
|
||||
focusEdgeIds: prioritizedEdgeIds ?? state.focusEdgeIds,
|
||||
}
|
||||
);
|
||||
drawParticles(ctx, renderableParticles, edgeMap, nodeMap, state.time, prioritizedEdgeIds);
|
||||
|
||||
// 2c. Visible nodes only (back to front: process → task → member/lead)
|
||||
|
|
@ -282,6 +308,19 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(funct
|
|||
bloomRef.current.apply(canvas, ctx);
|
||||
}
|
||||
|
||||
if (renderableHandoffCards.length > 0) {
|
||||
ctx.save();
|
||||
ctx.scale(dpr, dpr);
|
||||
drawHandoffCards(ctx, {
|
||||
cards: renderableHandoffCards,
|
||||
nodeMap,
|
||||
time: state.time,
|
||||
camera: cam,
|
||||
viewport: { width: w, height: h },
|
||||
});
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// 4. Performance overlay (enabled via ?perf in URL)
|
||||
const perf = perfRef.current;
|
||||
const frameMs = performance.now() - frameStart;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
163
packages/agent-graph/src/ui/transientHandoffs.ts
Normal file
163
packages/agent-graph/src/ui/transientHandoffs.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import { HANDOFF_CARD } from '../constants/canvas-constants';
|
||||
import type { GraphEdge, GraphNode, GraphParticle, GraphParticleKind } from '../ports/types';
|
||||
|
||||
type HandoffParticleKind = Exclude<GraphParticleKind, 'spawn'>;
|
||||
|
||||
export interface TransientHandoffCard {
|
||||
key: string;
|
||||
edgeId: string;
|
||||
sourceNodeId: string;
|
||||
destinationNodeId: string;
|
||||
sourceLabel: string;
|
||||
destinationLabel: string;
|
||||
destinationKind: GraphNode['kind'];
|
||||
kind: HandoffParticleKind;
|
||||
color: string;
|
||||
preview?: string;
|
||||
count: number;
|
||||
activatedAt: number;
|
||||
updatedAt: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export interface TransientHandoffState {
|
||||
cardsByKey: Map<string, TransientHandoffCard>;
|
||||
triggeredParticleIds: Set<string>;
|
||||
}
|
||||
|
||||
export function createTransientHandoffState(): TransientHandoffState {
|
||||
return {
|
||||
cardsByKey: new Map<string, TransientHandoffCard>(),
|
||||
triggeredParticleIds: new Set<string>(),
|
||||
};
|
||||
}
|
||||
|
||||
export function updateTransientHandoffState(
|
||||
state: TransientHandoffState,
|
||||
params: {
|
||||
particles: GraphParticle[];
|
||||
edgeMap: Map<string, GraphEdge>;
|
||||
nodeMap: Map<string, GraphNode>;
|
||||
time: number;
|
||||
}
|
||||
): void {
|
||||
const { particles, edgeMap, nodeMap, time } = params;
|
||||
|
||||
const activeParticleIds = new Set<string>();
|
||||
for (const particle of particles) activeParticleIds.add(particle.id);
|
||||
for (const particleId of Array.from(state.triggeredParticleIds)) {
|
||||
if (!activeParticleIds.has(particleId)) {
|
||||
state.triggeredParticleIds.delete(particleId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [cardKey, card] of Array.from(state.cardsByKey.entries())) {
|
||||
if (card.expiresAt <= time) {
|
||||
state.cardsByKey.delete(cardKey);
|
||||
}
|
||||
}
|
||||
|
||||
for (const particle of particles) {
|
||||
if (!isTransientHandoffKind(particle.kind)) continue;
|
||||
if (particle.progress < HANDOFF_CARD.triggerProgress) continue;
|
||||
if (state.triggeredParticleIds.has(particle.id)) continue;
|
||||
|
||||
const edge = edgeMap.get(particle.edgeId);
|
||||
if (!edge) continue;
|
||||
|
||||
const sourceNodeId = particle.reverse ? edge.target : edge.source;
|
||||
const destinationNodeId = particle.reverse ? edge.source : edge.target;
|
||||
const sourceNode = nodeMap.get(sourceNodeId);
|
||||
const destinationNode = nodeMap.get(destinationNodeId);
|
||||
if (!sourceNode || !destinationNode) continue;
|
||||
|
||||
const previewText = normalizePreviewText(particle.preview ?? particle.label);
|
||||
if (particle.kind === 'inbox_message' && isLowSignalInboxPreview(previewText)) {
|
||||
state.triggeredParticleIds.add(particle.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
const cardKey = `${edge.id}:${particle.reverse ? 'rev' : 'fwd'}:${particle.kind}`;
|
||||
const existing = state.cardsByKey.get(cardKey);
|
||||
const nextCount = (existing?.count ?? 0) + 1;
|
||||
|
||||
state.cardsByKey.set(cardKey, {
|
||||
key: cardKey,
|
||||
edgeId: edge.id,
|
||||
sourceNodeId,
|
||||
destinationNodeId,
|
||||
sourceLabel: sourceNode.label,
|
||||
destinationLabel: destinationNode.label,
|
||||
destinationKind: destinationNode.kind,
|
||||
kind: particle.kind,
|
||||
color: particle.color,
|
||||
preview: previewText ?? existing?.preview,
|
||||
count: nextCount,
|
||||
activatedAt: existing?.activatedAt ?? time,
|
||||
updatedAt: time,
|
||||
expiresAt: time + HANDOFF_CARD.lingerSeconds,
|
||||
});
|
||||
state.triggeredParticleIds.add(particle.id);
|
||||
}
|
||||
}
|
||||
|
||||
export function selectRenderableTransientHandoffCards(
|
||||
state: TransientHandoffState,
|
||||
options?: {
|
||||
focusNodeIds?: ReadonlySet<string> | null;
|
||||
focusEdgeIds?: ReadonlySet<string> | null;
|
||||
}
|
||||
): TransientHandoffCard[] {
|
||||
const focusNodeIds = options?.focusNodeIds ?? null;
|
||||
const focusEdgeIds = options?.focusEdgeIds ?? null;
|
||||
const hasFocus = (focusNodeIds?.size ?? 0) > 0 || (focusEdgeIds?.size ?? 0) > 0;
|
||||
|
||||
const byDestination = new Map<string, TransientHandoffCard[]>();
|
||||
for (const card of state.cardsByKey.values()) {
|
||||
if (hasFocus && !isCardInFocus(card, focusNodeIds, focusEdgeIds)) continue;
|
||||
const destinationCards = byDestination.get(card.destinationNodeId);
|
||||
if (destinationCards) {
|
||||
destinationCards.push(card);
|
||||
} else {
|
||||
byDestination.set(card.destinationNodeId, [card]);
|
||||
}
|
||||
}
|
||||
|
||||
const selected: TransientHandoffCard[] = [];
|
||||
for (const cards of byDestination.values()) {
|
||||
cards.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
selected.push(...cards.slice(0, HANDOFF_CARD.maxPerDestination));
|
||||
}
|
||||
|
||||
selected.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
return selected;
|
||||
}
|
||||
|
||||
function isTransientHandoffKind(kind: GraphParticleKind): kind is HandoffParticleKind {
|
||||
return kind !== 'spawn';
|
||||
}
|
||||
|
||||
function isCardInFocus(
|
||||
card: TransientHandoffCard,
|
||||
focusNodeIds: ReadonlySet<string> | null,
|
||||
focusEdgeIds: ReadonlySet<string> | null
|
||||
): boolean {
|
||||
return (
|
||||
!!focusEdgeIds?.has(card.edgeId) ||
|
||||
!!focusNodeIds?.has(card.sourceNodeId) ||
|
||||
!!focusNodeIds?.has(card.destinationNodeId)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizePreviewText(text: string | undefined): string | undefined {
|
||||
if (!text) return undefined;
|
||||
const normalized = text
|
||||
.replace(/^(?:✉|💬)\s*/u, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
function isLowSignalInboxPreview(preview: string | undefined): boolean {
|
||||
return preview === 'idle';
|
||||
}
|
||||
|
|
@ -63,7 +63,6 @@ import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers';
|
|||
import { setReviewMainWindow } from './ipc/review';
|
||||
import {
|
||||
ApiKeyService,
|
||||
RUNTIME_MANAGED_API_KEY_ENV_VARS,
|
||||
ExtensionFacadeService,
|
||||
GlamaMcpEnrichmentService,
|
||||
McpCatalogAggregator,
|
||||
|
|
@ -74,6 +73,7 @@ import {
|
|||
PluginCatalogService,
|
||||
PluginInstallationStateService,
|
||||
PluginInstallService,
|
||||
RUNTIME_MANAGED_API_KEY_ENV_VARS,
|
||||
SkillsCatalogService,
|
||||
SkillsMutationService,
|
||||
SkillsWatcherService,
|
||||
|
|
@ -103,6 +103,11 @@ import {
|
|||
import { syncTelemetryFlag } from './sentry';
|
||||
import {
|
||||
BranchStatusService,
|
||||
BoardTaskActivityRecordSource,
|
||||
BoardTaskActivityService,
|
||||
BoardTaskExactLogDetailService,
|
||||
BoardTaskExactLogsService,
|
||||
BoardTaskLogStreamService,
|
||||
CliInstallerService,
|
||||
configManager,
|
||||
LocalFileSystemProvider,
|
||||
|
|
@ -779,6 +784,13 @@ async function initializeServices(): Promise<void> {
|
|||
cliInstallerService = new CliInstallerService();
|
||||
ptyTerminalService = new PtyTerminalService();
|
||||
const teamMemberLogsFinder = new TeamMemberLogsFinder();
|
||||
const boardTaskActivityRecordSource = new BoardTaskActivityRecordSource();
|
||||
const boardTaskActivityService = new BoardTaskActivityService(boardTaskActivityRecordSource);
|
||||
const boardTaskExactLogsService = new BoardTaskExactLogsService(boardTaskActivityRecordSource);
|
||||
const boardTaskExactLogDetailService = new BoardTaskExactLogDetailService(
|
||||
boardTaskActivityRecordSource
|
||||
);
|
||||
const boardTaskLogStreamService = new BoardTaskLogStreamService(boardTaskActivityRecordSource);
|
||||
const teamMemberRuntimeAdvisoryService = new TeamMemberRuntimeAdvisoryService(
|
||||
teamMemberLogsFinder
|
||||
);
|
||||
|
|
@ -924,6 +936,10 @@ async function initializeServices(): Promise<void> {
|
|||
teamProvisioningService,
|
||||
teamMemberLogsFinder,
|
||||
memberStatsComputer,
|
||||
boardTaskActivityService,
|
||||
boardTaskLogStreamService,
|
||||
boardTaskExactLogsService,
|
||||
boardTaskExactLogDetailService,
|
||||
teammateToolTracker ?? undefined,
|
||||
branchStatusService ?? undefined,
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ export function parseReleaseMetadataAssetNames(metadataText: string): Set<string
|
|||
|
||||
for (const rawLine of metadataText.split(/\r?\n/u)) {
|
||||
const line = rawLine.trim();
|
||||
const match = line.match(/^(?:-\s+)?(url|path):\s+(.+)$/u);
|
||||
const match = /^(?:-\s+)?(url|path):\s+(.+)$/u.exec(line);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,20 @@
|
|||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
export type GeminiGlobalConfig = {
|
||||
export interface GeminiGlobalConfig {
|
||||
geminiBackendPreference?: 'auto' | 'api' | 'cli' | 'cli-sdk';
|
||||
geminiResolvedBackend?: 'api' | 'cli' | 'cli-sdk';
|
||||
geminiLastAuthMethod?: string;
|
||||
geminiProjectId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type GeminiRuntimeAuthState = {
|
||||
export interface GeminiRuntimeAuthState {
|
||||
authenticated: boolean;
|
||||
authMethod: string | null;
|
||||
resolvedBackend: 'auto' | 'api' | 'cli-sdk';
|
||||
projectId: string | null;
|
||||
statusMessage: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeGeminiBackend(
|
||||
value: string | null | undefined
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { TeamProviderId } from '@shared/types';
|
||||
|
||||
import { ConfigManager } from '../infrastructure/ConfigManager';
|
||||
|
||||
import type { TeamProviderId } from '@shared/types';
|
||||
|
||||
const PROVIDER_ROUTING_ENV_KEYS = [
|
||||
'CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST',
|
||||
'CLAUDE_CODE_ENTRY_PROVIDER',
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ export class BranchStatusService {
|
|||
try {
|
||||
const branch = await this.resolver.getBranch(tracked.actualPath, { forceRefresh });
|
||||
const latestTracked = this.trackedPaths.get(normalizedPath);
|
||||
if (!latestTracked || latestTracked.token !== expectedToken) return;
|
||||
if (latestTracked?.token !== expectedToken) return;
|
||||
|
||||
const previous = this.lastEmittedBranchByPath.get(normalizedPath) ?? UNSET_BRANCH;
|
||||
if (previous !== UNSET_BRANCH && previous === branch) {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||
import { createPersistedLaunchSnapshot } from './TeamLaunchStateEvaluator';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { createPersistedLaunchSnapshot } from './TeamLaunchStateEvaluator';
|
||||
|
||||
import type {
|
||||
PersistedTeamLaunchMemberState,
|
||||
PersistedTeamLaunchSnapshot,
|
||||
|
|
@ -19,15 +20,15 @@ const MAX_BOOTSTRAP_JOURNAL_BYTES = 256 * 1024;
|
|||
const MAX_BOOTSTRAP_LOCK_METADATA_BYTES = 64 * 1024;
|
||||
const ACTIVE_BOOTSTRAP_STUCK_CLASSIFICATION_MS = 3 * 60 * 1000;
|
||||
|
||||
type RawBootstrapMemberState = {
|
||||
interface RawBootstrapMemberState {
|
||||
name?: unknown;
|
||||
status?: unknown;
|
||||
lastAttemptAt?: unknown;
|
||||
lastObservedAt?: unknown;
|
||||
failureReason?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
type RawBootstrapState = {
|
||||
interface RawBootstrapState {
|
||||
version?: unknown;
|
||||
runId?: unknown;
|
||||
teamName?: unknown;
|
||||
|
|
@ -38,7 +39,7 @@ type RawBootstrapState = {
|
|||
realTaskSubmissionState?: unknown;
|
||||
members?: unknown;
|
||||
terminal?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
type RawBootstrapJournalRecord =
|
||||
| { ts?: unknown; type?: 'phase'; phase?: unknown }
|
||||
|
|
@ -47,31 +48,31 @@ type RawBootstrapJournalRecord =
|
|||
| { ts?: unknown; type?: 'terminal'; status?: unknown; reason?: unknown }
|
||||
| { ts?: unknown; type?: 'real_task'; state?: unknown; detail?: unknown };
|
||||
|
||||
type RawBootstrapLockMetadata = {
|
||||
interface RawBootstrapLockMetadata {
|
||||
pid?: unknown;
|
||||
runId?: unknown;
|
||||
requestHash?: unknown;
|
||||
ownerStartedAt?: unknown;
|
||||
createdAt?: unknown;
|
||||
nonce?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
type BootstrapStateInspection = {
|
||||
interface BootstrapStateInspection {
|
||||
raw: RawBootstrapState | null;
|
||||
issue?: string;
|
||||
};
|
||||
}
|
||||
|
||||
type BootstrapJournalInspection = {
|
||||
interface BootstrapJournalInspection {
|
||||
warnings?: string[];
|
||||
issue?: string;
|
||||
lastPhase?: BootstrapRuntimePhase;
|
||||
};
|
||||
}
|
||||
|
||||
type BootstrapLockMetadata = {
|
||||
interface BootstrapLockMetadata {
|
||||
pid: number;
|
||||
runId: string;
|
||||
ownerStartedAt?: number;
|
||||
};
|
||||
}
|
||||
|
||||
type BootstrapRuntimePhase =
|
||||
| 'validating_spec'
|
||||
|
|
@ -84,13 +85,13 @@ type BootstrapRuntimePhase =
|
|||
| 'failed'
|
||||
| 'canceled';
|
||||
|
||||
type ComparableStat = {
|
||||
interface ComparableStat {
|
||||
dev?: number;
|
||||
ino?: number;
|
||||
size: number;
|
||||
mode?: number;
|
||||
mtimeMs?: number;
|
||||
};
|
||||
}
|
||||
|
||||
function isFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value);
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ import { Worker } from 'node:worker_threads';
|
|||
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import type { MemberLogSummary, TeamData } from '@shared/types';
|
||||
import type { TeamDataWorkerRequest, TeamDataWorkerResponse } from './teamDataWorkerTypes';
|
||||
import type { MemberLogSummary, TeamData } from '@shared/types';
|
||||
|
||||
const logger = createLogger('Service:TeamDataWorkerClient');
|
||||
const WORKER_CALL_TIMEOUT_MS = 30_000;
|
||||
|
|
@ -49,10 +49,10 @@ function resolveWorkerPath(): string | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
type PendingEntry = {
|
||||
interface PendingEntry {
|
||||
resolve: (v: unknown) => void;
|
||||
reject: (e: Error) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export class TeamDataWorkerClient {
|
||||
private worker: Worker | null = null;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)}`;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
import { execFile } from 'child_process';
|
||||
|
||||
import { parseCliArgs } from '@shared/utils/cliArgsParser';
|
||||
import { execFile } from 'child_process';
|
||||
|
||||
const TMUX_AVAILABILITY_CACHE_TTL_MS = 10_000;
|
||||
|
||||
type DesktopTeammateModeDecision = {
|
||||
interface DesktopTeammateModeDecision {
|
||||
injectedTeammateMode: 'tmux' | null;
|
||||
forceProcessTeammates: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
let tmuxAvailabilityCache: { value: boolean; at: number } | null = null;
|
||||
let tmuxAvailablePromise: Promise<boolean> | null = null;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import React from 'react';
|
|||
import { type ItemStatus } from '../BaseItem';
|
||||
|
||||
import { CollapsibleOutputSection } from './CollapsibleOutputSection';
|
||||
import { renderInput, renderOutput } from './renderHelpers';
|
||||
import { extractOutputText, renderInput, renderOutput } from './renderHelpers';
|
||||
|
||||
import type { LinkedToolItem } from '@renderer/types/groups';
|
||||
|
||||
|
|
@ -19,6 +19,13 @@ interface DefaultToolViewerProps {
|
|||
}
|
||||
|
||||
export const DefaultToolViewer: React.FC<DefaultToolViewerProps> = ({ linkedTool, status }) => {
|
||||
const hasMeaningfulOutput =
|
||||
linkedTool.result &&
|
||||
(() => {
|
||||
const text = extractOutputText(linkedTool.result.content).trim();
|
||||
return text.length > 0 && text !== '[]' && text !== '{}';
|
||||
})();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Input Section */}
|
||||
|
|
@ -39,7 +46,7 @@ export const DefaultToolViewer: React.FC<DefaultToolViewerProps> = ({ linkedTool
|
|||
</div>
|
||||
|
||||
{/* Output Section — Collapsed by default */}
|
||||
{!linkedTool.isOrphaned && linkedTool.result && (
|
||||
{!linkedTool.isOrphaned && linkedTool.result && hasMeaningfulOutput && (
|
||||
<CollapsibleOutputSection status={status}>
|
||||
{renderOutput(linkedTool.result.content)}
|
||||
</CollapsibleOutputSection>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import {
|
|||
type SearchContext,
|
||||
} from '../searchHighlightUtils';
|
||||
import { highlightLine } from '../viewers/syntaxHighlighter';
|
||||
|
||||
import { FileLink, isRelativeUrl } from './FileLink';
|
||||
import { MermaidDiagram } from './MermaidDiagram';
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ type BannerState =
|
|||
|
||||
const INITIAL_STATE: BannerState = { loading: true, status: null, error: null };
|
||||
|
||||
function PlatformInstallMatrix(): React.JSX.Element {
|
||||
const PlatformInstallMatrix = (): React.JSX.Element => {
|
||||
return (
|
||||
<div className="mt-3 grid gap-2 lg:grid-cols-3">
|
||||
<div
|
||||
|
|
@ -74,7 +74,7 @@ function PlatformInstallMatrix(): React.JSX.Element {
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
function getPrimaryDetail(status: TmuxStatus): string {
|
||||
if (status.platform === 'darwin') {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import type { CliProviderStatus } from '@shared/types';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
|
@ -13,11 +12,13 @@ import {
|
|||
TooltipTrigger,
|
||||
} from '@renderer/components/ui/tooltip';
|
||||
|
||||
type Props = {
|
||||
import type { CliProviderStatus } from '@shared/types';
|
||||
|
||||
interface Props {
|
||||
provider: CliProviderStatus;
|
||||
disabled?: boolean;
|
||||
onSelect: (providerId: CliProviderStatus['providerId'], backendId: string) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export function getOptionDisplayLabel(
|
||||
option: NonNullable<CliProviderStatus['availableBackends']>[number],
|
||||
|
|
@ -47,11 +48,11 @@ export function getProviderRuntimeBackendSummary(provider: CliProviderStatus): s
|
|||
return getOptionDisplayLabel(selectedOption, resolvedOption);
|
||||
}
|
||||
|
||||
export function ProviderRuntimeBackendSelector({
|
||||
export const ProviderRuntimeBackendSelector = ({
|
||||
provider,
|
||||
disabled = false,
|
||||
onSelect,
|
||||
}: Props): React.JSX.Element | null {
|
||||
}: Props): React.JSX.Element | null => {
|
||||
const options = provider.availableBackends ?? [];
|
||||
if (options.length === 0) {
|
||||
return null;
|
||||
|
|
@ -191,4 +192,4 @@ export function ProviderRuntimeBackendSelector({
|
|||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { memo } from 'react';
|
||||
|
||||
import { formatDistanceToNowStrict } from 'date-fns';
|
||||
import { ExternalLink, Square, Terminal } from 'lucide-react';
|
||||
|
||||
|
|
|
|||
|
|
@ -207,7 +207,7 @@ export const ProvisioningProgressBlock = ({
|
|||
<div
|
||||
className={cn(
|
||||
surface === 'flat'
|
||||
? 'rounded-none border-0 bg-transparent px-0 py-0'
|
||||
? 'rounded-none border-0 bg-transparent p-0'
|
||||
: 'rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-3 py-2',
|
||||
isError && 'border-red-500/40 bg-red-500/10',
|
||||
className
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@ import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer
|
|||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers';
|
||||
import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils';
|
||||
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
||||
import { formatTaskDisplayLabel, taskMatchesRef } from '@shared/utils/taskIdentity';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import type { TeamTaskWithKanban } from '@shared/types';
|
||||
|
||||
|
|
|
|||
|
|
@ -824,9 +824,11 @@ export const TeamDetailView = ({
|
|||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
if (detail?.teamName === teamName) {
|
||||
const state = useStore.getState();
|
||||
const displayName = state.teamByName[teamName]?.displayName ?? teamName;
|
||||
useStore.getState().openTab({
|
||||
type: 'graph',
|
||||
label: `${teamName} Graph`,
|
||||
label: `${displayName} Graph`,
|
||||
teamName,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { Button } from '@renderer/components/ui/button';
|
||||
|
||||
type TeamEmptyStateProps = {
|
||||
interface TeamEmptyStateProps {
|
||||
canCreate: boolean;
|
||||
onCreateTeam: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
export const TeamEmptyState = ({
|
||||
canCreate,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -41,23 +41,24 @@ import {
|
|||
normalizeCreateLaunchProviderForUi,
|
||||
} from '@renderer/utils/geminiUiFreeze';
|
||||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||
import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog';
|
||||
import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability';
|
||||
import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
|
||||
|
||||
import { AdvancedCliSection } from './AdvancedCliSection';
|
||||
import { OptionalSettingsSection } from './OptionalSettingsSection';
|
||||
import { ProjectPathSelector } from './ProjectPathSelector';
|
||||
import {
|
||||
createInitialProviderChecks,
|
||||
failIncompleteProviderChecks,
|
||||
getProvisioningProviderBackendSummary,
|
||||
getProvisioningFailureHint,
|
||||
getProvisioningProviderBackendSummary,
|
||||
type ProvisioningProviderCheck,
|
||||
ProvisioningProviderStatusList,
|
||||
shouldHideProvisioningProviderStatusList,
|
||||
updateProviderCheck,
|
||||
type ProvisioningProviderCheck,
|
||||
} from './ProvisioningProviderStatusList';
|
||||
import { ProjectPathSelector } from './ProjectPathSelector';
|
||||
import { SkipPermissionsCheckbox } from './SkipPermissionsCheckbox';
|
||||
import { computeEffectiveTeamModel } from './TeamModelSelector';
|
||||
import { getNextSuggestedTeamName } from './teamNameSets';
|
||||
|
|
@ -111,15 +112,7 @@ function isEphemeralRenderedProjectPath(projectPath: string | null | undefined):
|
|||
}
|
||||
|
||||
function getProviderLabel(providerId: TeamProviderId): string {
|
||||
switch (providerId) {
|
||||
case 'codex':
|
||||
return 'Codex';
|
||||
case 'gemini':
|
||||
return 'Gemini';
|
||||
case 'anthropic':
|
||||
default:
|
||||
return 'Anthropic';
|
||||
}
|
||||
return getCatalogTeamProviderLabel(providerId) ?? 'Anthropic';
|
||||
}
|
||||
|
||||
export interface TeamCopyData {
|
||||
|
|
@ -484,9 +477,7 @@ export const CreateTeamDialog = ({
|
|||
}, [members, multimodelEnabled, selectedProviderId, soloTeam, syncModelsWithLead]);
|
||||
|
||||
const runtimeBackendSummaryByProvider = useMemo(() => {
|
||||
const entries: Array<readonly [TeamProviderId, string | null]> = (
|
||||
cliStatus?.providers ?? []
|
||||
).map(
|
||||
const entries: (readonly [TeamProviderId, string | null])[] = (cliStatus?.providers ?? []).map(
|
||||
(provider) =>
|
||||
[
|
||||
provider.providerId as TeamProviderId,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { SkipPermissionsCheckbox } from '@renderer/components/team/dialogs/SkipPermissionsCheckbox';
|
||||
import {
|
||||
buildMemberDraftColorMap,
|
||||
buildMemberDraftSuggestions,
|
||||
|
|
@ -13,7 +14,6 @@ import {
|
|||
validateMemberNameInline,
|
||||
} from '@renderer/components/team/members/MembersEditorSection';
|
||||
import { TeamRosterEditorSection } from '@renderer/components/team/members/TeamRosterEditorSection';
|
||||
import { SkipPermissionsCheckbox } from '@renderer/components/team/dialogs/SkipPermissionsCheckbox';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Checkbox } from '@renderer/components/ui/checkbox';
|
||||
import { Combobox } from '@renderer/components/ui/combobox';
|
||||
|
|
@ -36,16 +36,16 @@ import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
|
|||
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice';
|
||||
import {
|
||||
isGeminiUiFrozen,
|
||||
normalizeCreateLaunchProviderForUi,
|
||||
} from '@renderer/utils/geminiUiFreeze';
|
||||
import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice';
|
||||
import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability';
|
||||
import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||
import { nameColorSet } from '@renderer/utils/projectColor';
|
||||
import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog';
|
||||
import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability';
|
||||
import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
|
|
@ -56,24 +56,25 @@ import {
|
|||
RotateCcw,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { CronScheduleInput } from '../schedule/CronScheduleInput';
|
||||
|
||||
import { AdvancedCliSection } from './AdvancedCliSection';
|
||||
import { EffortLevelSelector } from './EffortLevelSelector';
|
||||
import { resolveLaunchDialogPrefill } from './launchDialogPrefill';
|
||||
import { OptionalSettingsSection } from './OptionalSettingsSection';
|
||||
import { ProjectPathSelector } from './ProjectPathSelector';
|
||||
import {
|
||||
createInitialProviderChecks,
|
||||
failIncompleteProviderChecks,
|
||||
getProvisioningProviderBackendSummary,
|
||||
getProvisioningFailureHint,
|
||||
getProvisioningProviderBackendSummary,
|
||||
type ProvisioningProviderCheck,
|
||||
ProvisioningProviderStatusList,
|
||||
shouldHideProvisioningProviderStatusList,
|
||||
updateProviderCheck,
|
||||
type ProvisioningProviderCheck,
|
||||
} from './ProvisioningProviderStatusList';
|
||||
import { ProjectPathSelector } from './ProjectPathSelector';
|
||||
import { resolveLaunchDialogPrefill } from './launchDialogPrefill';
|
||||
import {
|
||||
computeEffectiveTeamModel,
|
||||
formatTeamModelSummary,
|
||||
|
|
@ -160,15 +161,7 @@ function getStoredTeamModel(providerId: TeamProviderId): string {
|
|||
}
|
||||
|
||||
function getProviderLabel(providerId: TeamProviderId): string {
|
||||
switch (providerId) {
|
||||
case 'codex':
|
||||
return 'Codex';
|
||||
case 'gemini':
|
||||
return 'Gemini';
|
||||
case 'anthropic':
|
||||
default:
|
||||
return 'Anthropic';
|
||||
}
|
||||
return getCatalogTeamProviderLabel(providerId) ?? 'Anthropic';
|
||||
}
|
||||
|
||||
function resolveMemberDraftRuntime(
|
||||
|
|
@ -339,9 +332,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
);
|
||||
|
||||
const runtimeBackendSummaryByProvider = useMemo(() => {
|
||||
const entries: Array<readonly [TeamProviderId, string | null]> = (
|
||||
cliStatus?.providers ?? []
|
||||
).map(
|
||||
const entries: (readonly [TeamProviderId, string | null])[] = (cliStatus?.providers ?? []).map(
|
||||
(provider) =>
|
||||
[
|
||||
provider.providerId as TeamProviderId,
|
||||
|
|
@ -643,10 +634,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
|
||||
const runtimeChangeNotes = useMemo(() => {
|
||||
if (!isLaunch) {
|
||||
return [] as Array<{ key: string; memberName: string; message: string }>;
|
||||
return [] as { key: string; memberName: string; message: string }[];
|
||||
}
|
||||
|
||||
const notes: Array<{ key: string; memberName: string; message: string }> = [];
|
||||
const notes: { key: string; memberName: string; message: string }[] = [];
|
||||
const previousLeadModel = previousLaunchParams?.model?.trim() || '';
|
||||
const previousLeadEffort = previousLaunchParams?.effort;
|
||||
const currentLeadDisplayModel = selectedModel.trim() || effectiveLeadRuntimeModel;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import React from 'react';
|
||||
|
||||
import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog';
|
||||
import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react';
|
||||
|
||||
import type { TeamProviderId } from '@shared/types';
|
||||
import type { CliProviderStatus } from '@shared/types';
|
||||
import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react';
|
||||
|
||||
export type ProvisioningProviderCheckStatus = 'pending' | 'checking' | 'ready' | 'notes' | 'failed';
|
||||
|
||||
|
|
@ -14,15 +16,7 @@ export interface ProvisioningProviderCheck {
|
|||
}
|
||||
|
||||
export function getProvisioningProviderLabel(providerId: TeamProviderId): string {
|
||||
switch (providerId) {
|
||||
case 'codex':
|
||||
return 'Codex';
|
||||
case 'gemini':
|
||||
return 'Gemini';
|
||||
case 'anthropic':
|
||||
default:
|
||||
return 'Anthropic';
|
||||
}
|
||||
return getCatalogTeamProviderLabel(providerId) ?? 'Anthropic';
|
||||
}
|
||||
|
||||
export function createInitialProviderChecks(
|
||||
|
|
@ -150,7 +144,7 @@ function summarizeDetail(detail: string, status: ProvisioningProviderCheckStatus
|
|||
|
||||
function getDisplayStatusText(check: ProvisioningProviderCheck): string {
|
||||
const summary = check.details.find(Boolean)
|
||||
? summarizeDetail(check.details[0]!, check.status)
|
||||
? summarizeDetail(check.details[0], check.status)
|
||||
: null;
|
||||
return summary ?? getStatusLabel(check.status);
|
||||
}
|
||||
|
|
@ -194,7 +188,7 @@ function getStatusColor(status: ProvisioningProviderCheckStatus): string {
|
|||
}
|
||||
}
|
||||
|
||||
function StatusIcon({ status }: { status: ProvisioningProviderCheckStatus }): React.JSX.Element {
|
||||
const StatusIcon = ({ status }: { status: ProvisioningProviderCheckStatus }): React.JSX.Element => {
|
||||
if (status === 'checking') {
|
||||
return <Loader2 className="size-3 animate-spin" />;
|
||||
}
|
||||
|
|
@ -205,9 +199,9 @@ function StatusIcon({ status }: { status: ProvisioningProviderCheckStatus }): Re
|
|||
return <AlertTriangle className="size-3" />;
|
||||
}
|
||||
return <span className="inline-block size-1.5 rounded-full bg-current opacity-60" />;
|
||||
}
|
||||
};
|
||||
|
||||
export function ProvisioningProviderStatusList({
|
||||
export const ProvisioningProviderStatusList = ({
|
||||
checks,
|
||||
className = '',
|
||||
suppressDetailsMatching,
|
||||
|
|
@ -215,7 +209,7 @@ export function ProvisioningProviderStatusList({
|
|||
checks: ProvisioningProviderCheck[];
|
||||
className?: string;
|
||||
suppressDetailsMatching?: string | null;
|
||||
}): React.JSX.Element | null {
|
||||
}): React.JSX.Element | null => {
|
||||
if (checks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -253,7 +247,7 @@ export function ProvisioningProviderStatusList({
|
|||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export function getProvisioningFailureHint(
|
||||
message: string | null | undefined,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -17,13 +17,13 @@ export const DISPLAY_STEPS = [
|
|||
|
||||
export const DISPLAY_COMPLETE_STEP_INDEX = DISPLAY_STEPS.length;
|
||||
|
||||
export type LaunchJoinMilestones = {
|
||||
export interface LaunchJoinMilestones {
|
||||
expectedTeammateCount: number;
|
||||
heartbeatConfirmedCount: number;
|
||||
processOnlyAliveCount: number;
|
||||
pendingSpawnCount: number;
|
||||
failedSpawnCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
type DisplayStepMilestones = LaunchJoinMilestones & {
|
||||
progress: Pick<TeamProvisioningProgress, 'configReady' | 'pid' | 'state'>;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -1903,9 +1903,14 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
// Sync tab label with the team's display name from config
|
||||
const displayName = data.config.name || teamName;
|
||||
const allTabs = get().getAllPaneTabs();
|
||||
const teamTab = allTabs.find((tab) => tab.type === 'team' && tab.teamName === teamName);
|
||||
if (teamTab && teamTab.label !== displayName) {
|
||||
get().updateTabLabel(teamTab.id, displayName);
|
||||
const relatedTabs = allTabs.filter(
|
||||
(tab) => (tab.type === 'team' || tab.type === 'graph') && tab.teamName === teamName
|
||||
);
|
||||
for (const tab of relatedTabs) {
|
||||
const nextLabel = tab.type === 'graph' ? `${displayName} Graph` : displayName;
|
||||
if (tab.label !== nextLabel) {
|
||||
get().updateTabLabel(tab.id, nextLabel);
|
||||
}
|
||||
}
|
||||
|
||||
if (opts?.skipProjectAutoSelect) {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import type {
|
|||
IdleNotificationPrimaryKind,
|
||||
} from '@shared/utils/idleNotificationSemantics';
|
||||
|
||||
export type ClassifiedIdleNotification = {
|
||||
export interface ClassifiedIdleNotification {
|
||||
payload: IdleNotificationPayload;
|
||||
primaryKind: IdleNotificationPrimaryKind;
|
||||
hasPeerSummary: boolean;
|
||||
|
|
@ -15,7 +15,7 @@ export type ClassifiedIdleNotification = {
|
|||
countsAsBootstrapConfirmation: boolean;
|
||||
liveDelivery: 'silent_finalize' | 'passive_activity' | 'visible_actionable';
|
||||
uiPresentation: 'heartbeat' | 'peer_summary' | 'interrupted' | 'task_terminal' | 'failure';
|
||||
};
|
||||
}
|
||||
|
||||
export function classifyIdleNotification(
|
||||
value: string | Pick<InboxMessage, 'text'> | Record<string, unknown> | IdleNotificationPayload
|
||||
|
|
@ -44,7 +44,7 @@ export function classifyIdleNotification(
|
|||
: shared.primaryKind;
|
||||
|
||||
return {
|
||||
...(shared as SharedClassifiedIdleNotification),
|
||||
...shared,
|
||||
liveDelivery,
|
||||
uiPresentation,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
48
test/fixtures/team/board-task-activity-message-v1.json
vendored
Normal file
48
test/fixtures/team/board-task-activity-message-v1.json
vendored
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
{
|
||||
"uuid": "message-1",
|
||||
"timestamp": "2026-04-12T10:00:00.000Z",
|
||||
"sessionId": "session-1",
|
||||
"boardTaskLinks": [
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"toolUseId": "tool-1",
|
||||
"task": {
|
||||
"ref": "abcd1234",
|
||||
"refKind": "display",
|
||||
"canonicalId": "123e4567-e89b-12d3-a456-426614174000"
|
||||
},
|
||||
"targetRole": "subject",
|
||||
"linkKind": "lifecycle",
|
||||
"taskArgumentSlot": "taskId",
|
||||
"actorContext": {
|
||||
"relation": "idle"
|
||||
}
|
||||
},
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"task": {
|
||||
"ref": "",
|
||||
"refKind": "display"
|
||||
},
|
||||
"targetRole": "subject",
|
||||
"linkKind": "lifecycle",
|
||||
"actorContext": {
|
||||
"relation": "idle"
|
||||
}
|
||||
}
|
||||
],
|
||||
"boardTaskToolActions": [
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"toolUseId": "tool-1",
|
||||
"canonicalToolName": "task_add_comment",
|
||||
"resultRefs": {
|
||||
"commentId": "comment-1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"canonicalToolName": "task_add_comment"
|
||||
}
|
||||
]
|
||||
}
|
||||
185
test/renderer/features/agent-graph/transientHandoffs.test.ts
Normal file
185
test/renderer/features/agent-graph/transientHandoffs.test.ts
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { GraphEdge, GraphNode, GraphParticle } from '@claude-teams/agent-graph';
|
||||
|
||||
import {
|
||||
createTransientHandoffState,
|
||||
selectRenderableTransientHandoffCards,
|
||||
updateTransientHandoffState,
|
||||
} from '../../../../packages/agent-graph/src/ui/transientHandoffs';
|
||||
|
||||
const leadNode: GraphNode = {
|
||||
id: 'lead:team-a',
|
||||
kind: 'lead',
|
||||
label: 'team-a',
|
||||
state: 'active',
|
||||
x: 0,
|
||||
y: 0,
|
||||
domainRef: { kind: 'lead', teamName: 'team-a', memberName: 'team-lead' },
|
||||
};
|
||||
|
||||
const aliceNode: GraphNode = {
|
||||
id: 'member:team-a:alice',
|
||||
kind: 'member',
|
||||
label: 'alice',
|
||||
state: 'active',
|
||||
x: 100,
|
||||
y: 0,
|
||||
domainRef: { kind: 'member', teamName: 'team-a', memberName: 'alice' },
|
||||
};
|
||||
|
||||
const taskNode: GraphNode = {
|
||||
id: 'task:team-a:42',
|
||||
kind: 'task',
|
||||
label: '#42',
|
||||
sublabel: 'Fix queue',
|
||||
state: 'active',
|
||||
x: 200,
|
||||
y: 100,
|
||||
domainRef: { kind: 'task', teamName: 'team-a', taskId: '42' },
|
||||
};
|
||||
|
||||
const nodeMap = new Map<string, GraphNode>([
|
||||
[leadNode.id, leadNode],
|
||||
[aliceNode.id, aliceNode],
|
||||
[taskNode.id, taskNode],
|
||||
]);
|
||||
|
||||
const edgeMap = new Map<string, GraphEdge>([
|
||||
[
|
||||
'edge:lead:alice',
|
||||
{
|
||||
id: 'edge:lead:alice',
|
||||
source: leadNode.id,
|
||||
target: aliceNode.id,
|
||||
type: 'parent-child',
|
||||
},
|
||||
],
|
||||
[
|
||||
'edge:alice:task',
|
||||
{
|
||||
id: 'edge:alice:task',
|
||||
source: aliceNode.id,
|
||||
target: taskNode.id,
|
||||
type: 'message',
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
function makeParticle(overrides?: Partial<GraphParticle>): GraphParticle {
|
||||
return {
|
||||
id: 'particle-1',
|
||||
edgeId: 'edge:lead:alice',
|
||||
progress: 0.7,
|
||||
kind: 'inbox_message',
|
||||
color: '#66ccff',
|
||||
label: '✉ Ship the patch after green CI',
|
||||
preview: 'Ship the patch after green CI and send the changelog',
|
||||
reverse: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('transient handoff cards', () => {
|
||||
it('creates one readable handoff card when a particle reaches the recipient zone', () => {
|
||||
const state = createTransientHandoffState();
|
||||
|
||||
updateTransientHandoffState(state, {
|
||||
particles: [makeParticle()],
|
||||
edgeMap,
|
||||
nodeMap,
|
||||
time: 10,
|
||||
});
|
||||
|
||||
const cards = selectRenderableTransientHandoffCards(state);
|
||||
expect(cards).toHaveLength(1);
|
||||
expect(cards[0]).toMatchObject({
|
||||
edgeId: 'edge:lead:alice',
|
||||
sourceNodeId: aliceNode.id,
|
||||
destinationNodeId: leadNode.id,
|
||||
kind: 'inbox_message',
|
||||
count: 1,
|
||||
preview: 'Ship the patch after green CI and send the changelog',
|
||||
});
|
||||
});
|
||||
|
||||
it('aggregates repeated sends on the same edge and keeps the latest preview', () => {
|
||||
const state = createTransientHandoffState();
|
||||
|
||||
updateTransientHandoffState(state, {
|
||||
particles: [makeParticle({ id: 'particle-1' })],
|
||||
edgeMap,
|
||||
nodeMap,
|
||||
time: 20,
|
||||
});
|
||||
|
||||
updateTransientHandoffState(state, {
|
||||
particles: [
|
||||
makeParticle({
|
||||
id: 'particle-2',
|
||||
label: '✉ Follow-up with the release note diff',
|
||||
preview: 'Follow-up with the release note diff and deployment checklist',
|
||||
}),
|
||||
],
|
||||
edgeMap,
|
||||
nodeMap,
|
||||
time: 21,
|
||||
});
|
||||
|
||||
const cards = selectRenderableTransientHandoffCards(state);
|
||||
expect(cards).toHaveLength(1);
|
||||
expect(cards[0]).toMatchObject({
|
||||
count: 2,
|
||||
preview: 'Follow-up with the release note diff and deployment checklist',
|
||||
});
|
||||
});
|
||||
|
||||
it('expires old cards and caps renderables per destination', () => {
|
||||
const state = createTransientHandoffState();
|
||||
|
||||
updateTransientHandoffState(state, {
|
||||
particles: [
|
||||
makeParticle({ id: 'comment-1', edgeId: 'edge:alice:task', kind: 'task_comment', reverse: false }),
|
||||
makeParticle({ id: 'comment-2', edgeId: 'edge:alice:task', kind: 'task_comment', reverse: false }),
|
||||
makeParticle({ id: 'comment-3', edgeId: 'edge:alice:task', kind: 'review_request', reverse: false }),
|
||||
],
|
||||
edgeMap,
|
||||
nodeMap,
|
||||
time: 30,
|
||||
});
|
||||
|
||||
const cards = selectRenderableTransientHandoffCards(state);
|
||||
expect(cards).toHaveLength(2);
|
||||
expect(new Set(cards.map((card) => card.kind))).toEqual(
|
||||
new Set(['task_comment', 'review_request'])
|
||||
);
|
||||
|
||||
updateTransientHandoffState(state, {
|
||||
particles: [],
|
||||
edgeMap,
|
||||
nodeMap,
|
||||
time: 34,
|
||||
});
|
||||
|
||||
expect(selectRenderableTransientHandoffCards(state)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not create a card for generic idle inbox noise', () => {
|
||||
const state = createTransientHandoffState();
|
||||
|
||||
updateTransientHandoffState(state, {
|
||||
particles: [
|
||||
makeParticle({
|
||||
id: 'idle-1',
|
||||
label: 'idle',
|
||||
preview: 'idle',
|
||||
}),
|
||||
],
|
||||
edgeMap,
|
||||
nodeMap,
|
||||
time: 40,
|
||||
});
|
||||
|
||||
expect(selectRenderableTransientHandoffCards(state)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Reference in a new issue