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 kanban = require('./kanban.js');
|
||||||
const messages = require('./messages.js');
|
const messages = require('./messages.js');
|
||||||
|
const runtimeHelpers = require('./runtimeHelpers.js');
|
||||||
const tasks = require('./tasks.js');
|
const tasks = require('./tasks.js');
|
||||||
const { wrapAgentBlock } = require('./agentBlocks.js');
|
const { wrapAgentBlock } = require('./agentBlocks.js');
|
||||||
|
|
||||||
|
|
@ -17,19 +15,7 @@ function getReviewer(context, flags) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveLeadSessionId(context, flags) {
|
function resolveLeadSessionId(context, flags) {
|
||||||
if (typeof flags.leadSessionId === 'string' && flags.leadSessionId.trim()) {
|
return runtimeHelpers.resolveCanonicalLeadSessionId(context.paths, flags.leadSessionId);
|
||||||
return flags.leadSessionId.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const configPath = path.join(context.paths.teamDir, 'config.json');
|
|
||||||
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
||||||
return typeof parsed.leadSessionId === 'string' && parsed.leadSessionId.trim()
|
|
||||||
? parsed.leadSessionId.trim()
|
|
||||||
: undefined;
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCurrentReviewState(task) {
|
function getCurrentReviewState(task) {
|
||||||
|
|
|
||||||
|
|
@ -299,6 +299,24 @@ function resolveLeadSessionId(paths) {
|
||||||
: undefined;
|
: undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveCanonicalLeadSessionId(paths, candidate) {
|
||||||
|
const configured = resolveLeadSessionId(paths);
|
||||||
|
const explicit = typeof candidate === 'string' ? candidate.trim() : '';
|
||||||
|
|
||||||
|
if (!explicit) {
|
||||||
|
return configured;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The team config is the canonical source of the current lead runtime session.
|
||||||
|
// If a caller passes a placeholder like "team-lead" or any other mismatched value,
|
||||||
|
// prefer the configured session id instead of persisting dirty metadata into inbox rows.
|
||||||
|
if (configured) {
|
||||||
|
return explicit === configured ? explicit : configured;
|
||||||
|
}
|
||||||
|
|
||||||
|
return explicit;
|
||||||
|
}
|
||||||
|
|
||||||
function isProcessAlive(pid) {
|
function isProcessAlive(pid) {
|
||||||
try {
|
try {
|
||||||
process.kill(pid, 0);
|
process.kill(pid, 0);
|
||||||
|
|
@ -497,6 +515,7 @@ module.exports = {
|
||||||
readTeamConfig,
|
readTeamConfig,
|
||||||
resolveTeamMembers,
|
resolveTeamMembers,
|
||||||
getCurrentRuntimeMemberIdentity,
|
getCurrentRuntimeMemberIdentity,
|
||||||
|
resolveCanonicalLeadSessionId,
|
||||||
resolveLeadSessionId,
|
resolveLeadSessionId,
|
||||||
saveTaskAttachmentFile,
|
saveTaskAttachmentFile,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -530,6 +530,25 @@ describe('agent-teams-controller API', () => {
|
||||||
expect(inbox[0].leadSessionId).toBe('lead-session-1');
|
expect(inbox[0].leadSessionId).toBe('lead-session-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('ignores mismatched leadSessionId placeholders on review_request and uses canonical config session', () => {
|
||||||
|
const claudeDir = makeClaudeDir();
|
||||||
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
|
const task = controller.tasks.createTask({ subject: 'Review me', owner: 'bob' });
|
||||||
|
|
||||||
|
controller.kanban.addReviewer('alice');
|
||||||
|
controller.tasks.completeTask(task.id, 'bob');
|
||||||
|
controller.review.requestReview(task.id, {
|
||||||
|
from: 'team-lead',
|
||||||
|
leadSessionId: 'team-lead',
|
||||||
|
});
|
||||||
|
|
||||||
|
const reviewerInboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'alice.json');
|
||||||
|
const inbox = JSON.parse(fs.readFileSync(reviewerInboxPath, 'utf8'));
|
||||||
|
|
||||||
|
expect(inbox).toHaveLength(1);
|
||||||
|
expect(inbox[0].leadSessionId).toBe('lead-session-1');
|
||||||
|
});
|
||||||
|
|
||||||
it('starts review idempotently without requiring completed status', () => {
|
it('starts review idempotently without requiring completed status', () => {
|
||||||
const claudeDir = makeClaudeDir();
|
const claudeDir = makeClaudeDir();
|
||||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
|
|
@ -697,6 +716,47 @@ describe('agent-teams-controller API', () => {
|
||||||
expect(rows.at(-1).leadSessionId).toBe('lead-session-1');
|
expect(rows.at(-1).leadSessionId).toBe('lead-session-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('ignores mismatched leadSessionId placeholders on review_approve owner notifications', () => {
|
||||||
|
const claudeDir = makeClaudeDir();
|
||||||
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
|
const task = controller.tasks.createTask({ subject: 'Approve me', owner: 'bob' });
|
||||||
|
|
||||||
|
controller.kanban.addReviewer('alice');
|
||||||
|
controller.tasks.completeTask(task.id, 'bob');
|
||||||
|
controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' });
|
||||||
|
controller.review.approveReview(task.id, {
|
||||||
|
from: 'team-lead',
|
||||||
|
note: 'Looks good.',
|
||||||
|
'notify-owner': true,
|
||||||
|
leadSessionId: 'team-lead',
|
||||||
|
});
|
||||||
|
|
||||||
|
const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'bob.json');
|
||||||
|
const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
|
||||||
|
expect(rows.at(-1).summary).toContain('Approved');
|
||||||
|
expect(rows.at(-1).leadSessionId).toBe('lead-session-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores mismatched leadSessionId placeholders on review_request_changes owner notifications', () => {
|
||||||
|
const claudeDir = makeClaudeDir();
|
||||||
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
|
const task = controller.tasks.createTask({ subject: 'Needs revision', owner: 'bob' });
|
||||||
|
|
||||||
|
controller.kanban.addReviewer('alice');
|
||||||
|
controller.tasks.completeTask(task.id, 'bob');
|
||||||
|
controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' });
|
||||||
|
controller.review.requestChanges(task.id, {
|
||||||
|
from: 'alice',
|
||||||
|
comment: 'Please address review feedback.',
|
||||||
|
leadSessionId: 'team-lead',
|
||||||
|
});
|
||||||
|
|
||||||
|
const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'bob.json');
|
||||||
|
const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
|
||||||
|
expect(rows.at(-1).summary).toContain('Fix request');
|
||||||
|
expect(rows.at(-1).leadSessionId).toBe('lead-session-1');
|
||||||
|
});
|
||||||
|
|
||||||
it('limits approved briefing section to the latest 10 tasks by freshness', async () => {
|
it('limits approved briefing section to the latest 10 tasks by freshness', async () => {
|
||||||
const claudeDir = makeClaudeDir();
|
const claudeDir = makeClaudeDir();
|
||||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
# Итерация 03 — Kanban Board (click-to-move) + `kanban-state.json`
|
# Итерация 03 — Kanban Board (click-to-move) + `kanban-state.json`
|
||||||
|
|
||||||
|
> Historical note
|
||||||
|
> This document captures the planned Kanban scope at iteration time.
|
||||||
|
> It is not the source of truth for the current product contract.
|
||||||
|
> For the current review flow, see [../team-management/README.md](../team-management/README.md) and [../team-management/kanban-design.md](../team-management/kanban-design.md).
|
||||||
|
|
||||||
Эта итерация добавляет **kanban-доску команды** во вкладке Team и вводит **персистентное состояние** для колонок `REVIEW`/`APPROVED` через файл `~/.claude/teams/{teamName}/kanban-state.json`.
|
Эта итерация добавляет **kanban-доску команды** во вкладке Team и вводит **персистентное состояние** для колонок `REVIEW`/`APPROVED` через файл `~/.claude/teams/{teamName}/kanban-state.json`.
|
||||||
|
|
||||||
Основание:
|
Основание:
|
||||||
|
|
@ -263,4 +268,3 @@
|
||||||
- **EXDEV/rename нюансы**: в atomic write добавляем fallback copy+unlink.
|
- **EXDEV/rename нюансы**: в atomic write добавляем fallback copy+unlink.
|
||||||
- **Синхронизация UI**: после `updateKanban` делаем `refreshTeamData(teamName)` (и всё равно придёт watcher-событие; refresh должен быть идемпотентен).
|
- **Синхронизация UI**: после `updateKanban` делаем `refreshTeamData(teamName)` (и всё равно придёт watcher-событие; refresh должен быть идемпотентен).
|
||||||
- **Шум от fs.watch**: kanban-write может вызвать два refresh (ручной + watcher). Это ок, но store должен coalesce, а `refreshTeamData` — быть безопасным при частых вызовах.
|
- **Шум от fs.watch**: kanban-write может вызвать два refresh (ручной + watcher). Это ок, но store должен coalesce, а `refreshTeamData` — быть безопасным при частых вызовах.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
# Итерация 04 — Messaging + Review (Inbox + ReviewDialog)
|
# Итерация 04 — Messaging + Review (Inbox + ReviewDialog)
|
||||||
|
|
||||||
|
> Historical note
|
||||||
|
> This document captures the planned scope and assumptions at iteration time.
|
||||||
|
> It is not the source of truth for the current product contract.
|
||||||
|
> For the current review flow, see [../team-management/README.md](../team-management/README.md) and [../team-management/kanban-design.md](../team-management/kanban-design.md).
|
||||||
|
|
||||||
Эта итерация добавляет **панель активности (inbox messages)** и **отправку сообщений** тиммейтам, а также закрывает MVP review-flow: **Request Review → Approve / Request Changes**.
|
Эта итерация добавляет **панель активности (inbox messages)** и **отправку сообщений** тиммейтам, а также закрывает MVP review-flow: **Request Review → Approve / Request Changes**.
|
||||||
|
|
||||||
Основание:
|
Основание:
|
||||||
|
|
@ -293,4 +298,3 @@ Guards:
|
||||||
- **Race condition inbox**: атомарная запись не решает overwrite race, поэтому делаем `messageId verify` + retry/backoff, плюс in-process `withInboxLock`.
|
- **Race condition inbox**: атомарная запись не решает overwrite race, поэтому делаем `messageId verify` + retry/backoff, плюс in-process `withInboxLock`.
|
||||||
- **Конфликт при записи task.status**: после write делаем verify; если agent перезаписал — показываем warning в UI, не делаем silent fail.
|
- **Конфликт при записи task.status**: после write делаем verify; если agent перезаписал — показываем warning в UI, не делаем silent fail.
|
||||||
- **Большие inbox**: ограничиваем количество отображаемых сообщений (например 200) и добавляем “Show more” позже (итерация 05).
|
- **Большие inbox**: ограничиваем количество отображаемых сообщений (например 200) и добавляем “Show more” позже (итерация 05).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
# Итерация 05 — Testing + Polish (production-ready)
|
# Итерация 05 — Testing + Polish (production-ready)
|
||||||
|
|
||||||
|
> Historical note
|
||||||
|
> This document captures iteration-era test and polish assumptions.
|
||||||
|
> It is not the source of truth for the current product contract.
|
||||||
|
> For the current review flow, see [../team-management/README.md](../team-management/README.md) and [../team-management/kanban-design.md](../team-management/kanban-design.md).
|
||||||
|
|
||||||
Эта итерация закрывает **качество**: тесты на критические пути (read/write), фиксация edge cases, UX-polish (empty/error/loading), и небольшие оптимизации под реальные объёмы inbox/tasks.
|
Эта итерация закрывает **качество**: тесты на критические пути (read/write), фиксация edge cases, UX-polish (empty/error/loading), и небольшие оптимизации под реальные объёмы inbox/tasks.
|
||||||
|
|
||||||
Основание:
|
Основание:
|
||||||
|
|
@ -204,4 +209,3 @@ test/
|
||||||
4) Request Review → карточка в REVIEW + (если reviewer задан) сообщение ушло
|
4) Request Review → карточка в REVIEW + (если reviewer задан) сообщение ушло
|
||||||
5) Request Changes → task.status стал `in_progress` + owner получил сообщение
|
5) Request Changes → task.status стал `in_progress` + owner получил сообщение
|
||||||
6) Любое изменение файлов `~/.claude/teams/**` / `~/.claude/tasks/**` → UI обновился в пределах ~1с
|
6) Любое изменение файлов `~/.claude/teams/**` / `~/.claude/tasks/**` → UI обновился в пределах ~1с
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,9 @@
|
||||||
## Что делает
|
## Что делает
|
||||||
|
|
||||||
- Видеть состав команды и роли участников
|
- Видеть состав команды и роли участников
|
||||||
- Kanban-доска с 5 колонками (TODO → IN PROGRESS → DONE → REVIEW → APPROVED)
|
- Kanban-доска с 5 колонками: TODO, IN PROGRESS, REVIEW, DONE, APPROVED
|
||||||
- Отправка сообщений тиммейтам через inbox-файлы
|
- Отправка сообщений тиммейтам через inbox-файлы
|
||||||
- Review flow: автоматическое назначение ревьюверов или ручное ревью
|
- Review flow: запрос ревью, ручное ревью и прямое manual approval из DONE
|
||||||
- Live updates через file watcher
|
- Live updates через file watcher
|
||||||
|
|
||||||
## Документация
|
## Документация
|
||||||
|
|
@ -23,6 +23,8 @@
|
||||||
|
|
||||||
## Ключевые решения
|
## Ключевые решения
|
||||||
|
|
||||||
|
⚠️ `docs/iterations/*` - это исторические planning notes. Они полезны для контекста, но не являются source-of-truth для текущего поведения продукта. Актуальный контракт review flow описан в этом файле и в [kanban-design.md](./kanban-design.md).
|
||||||
|
|
||||||
### 1. Messaging: Inbox-файлы
|
### 1. Messaging: Inbox-файлы
|
||||||
Единственный способ общаться с **запущенными** тиммейтами. SDK и CLI создают новые сессии, а не подключаются к существующим. Подробности: [research-messaging.md](./research-messaging.md)
|
Единственный способ общаться с **запущенными** тиммейтами. SDK и CLI создают новые сессии, а не подключаются к существующим. Подробности: [research-messaging.md](./research-messaging.md)
|
||||||
|
|
||||||
|
|
@ -36,8 +38,9 @@ Kanban-позиция (REVIEW, APPROVED) хранится в `kanban-state.json`
|
||||||
|
|
||||||
### 3. Review Flow: Approve / Request Changes
|
### 3. Review Flow: Approve / Request Changes
|
||||||
- Есть ревьюверы в команде → автоматическое назначение через inbox
|
- Есть ревьюверы в команде → автоматическое назначение через inbox
|
||||||
|
- Юзер также может вручную одобрить задачу напрямую из `DONE` без отдельного захода в `REVIEW`
|
||||||
- Нет ревьюверов → ручное ревью юзером (Approve / Request Changes в UI)
|
- Нет ревьюверов → ручное ревью юзером (Approve / Request Changes в UI)
|
||||||
- При Request Changes → юзер описывает проблему (опционально) → задача к исходному owner
|
- При Request Changes → юзер описывает проблему (опционально) → задача возвращается owner'у в `pending` с `needsFix`
|
||||||
|
|
||||||
### 4. Atomic Write
|
### 4. Atomic Write
|
||||||
Все записи через tmp + rename для предотвращения corrupted JSON.
|
Все записи через tmp + rename для предотвращения corrupted JSON.
|
||||||
|
|
@ -62,6 +65,10 @@ Kanban-позиция (REVIEW, APPROVED) хранится в `kanban-state.json`
|
||||||
### Review Flow: Approve / Request Changes
|
### Review Flow: Approve / Request Changes
|
||||||
- Кнопки переименованы: **Approve** (вместо OK) и **Request Changes** (вместо Error)
|
- Кнопки переименованы: **Approve** (вместо OK) и **Request Changes** (вместо Error)
|
||||||
- Комментарий при Request Changes — опционален
|
- Комментарий при Request Changes — опционален
|
||||||
|
- Manual UI допускает два valid path:
|
||||||
|
- `DONE -> REVIEW -> APPROVED`
|
||||||
|
- `DONE -> APPROVED` как быстрый manual approval
|
||||||
|
- `Request Changes` снимает kanban-state запись и возвращает задачу в `pending` с `needsFix`
|
||||||
- `reviewHistory` и round-robin балансировка → Phase 2, не MVP
|
- `reviewHistory` и round-robin балансировка → Phase 2, не MVP
|
||||||
|
|
||||||
### Members: полный список через union
|
### Members: полный список через union
|
||||||
|
|
@ -75,8 +82,9 @@ Kanban-позиция (REVIEW, APPROVED) хранится в `kanban-state.json`
|
||||||
- `IDLE`: idle > 5 минут
|
- `IDLE`: idle > 5 минут
|
||||||
- `TERMINATED`: получен `shutdown_response` с `approve: true`
|
- `TERMINATED`: получен `shutdown_response` с `approve: true`
|
||||||
|
|
||||||
### @dnd-kit: click-to-move для MVP
|
### @dnd-kit and review transitions
|
||||||
- MVP: выбор колонки через select/dropdown (click-to-move) — проще и надёжнее
|
- Переходы между review-колонками делаются через card actions в UI
|
||||||
|
- `@dnd-kit` сейчас используется в первую очередь для перестановки задач внутри колонки
|
||||||
- Phase 2: полноценный D&D через `@dnd-kit`
|
- Phase 2: полноценный D&D через `@dnd-kit`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
# Implementation Plan (v7 — Production-Ready Architecture)
|
# Implementation Plan (v7 — Production-Ready Architecture)
|
||||||
|
|
||||||
|
> Historical note
|
||||||
|
> This is a planning and architecture document, not the source of truth for the current shipped product behavior.
|
||||||
|
> For the current review flow, see [README.md](./README.md) and [kanban-design.md](./kanban-design.md).
|
||||||
|
|
||||||
## Обзор
|
## Обзор
|
||||||
|
|
||||||
~34 новых файлов + 18 модификаций + 18 тестов. Vertical slices (не backend-first).
|
~34 новых файлов + 18 модификаций + 18 тестов. Vertical slices (не backend-first).
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,11 @@
|
||||||
## Flow
|
## Flow
|
||||||
|
|
||||||
```
|
```
|
||||||
TODO → IN PROGRESS → DONE → [юзер] → REVIEW → [юзер] → APPROVED
|
TODO → IN PROGRESS → DONE ───────────────→ APPROVED
|
||||||
↑ |
|
│ ↑
|
||||||
└── Fix (error) ←──┘
|
└→ REVIEW ───────────┘
|
||||||
|
│
|
||||||
|
└→ pending + needsFix
|
||||||
```
|
```
|
||||||
|
|
||||||
## Колонки
|
## Колонки
|
||||||
|
|
@ -15,8 +17,8 @@ TODO → IN PROGRESS → DONE → [юзер] → REVIEW → [юзер] → APPRO
|
||||||
| **TODO** | task.status = pending | Автоматически | Задачи ожидающие исполнителя |
|
| **TODO** | task.status = pending | Автоматически | Задачи ожидающие исполнителя |
|
||||||
| **IN PROGRESS** | task.status = in_progress | Автоматически | Агент работает |
|
| **IN PROGRESS** | task.status = in_progress | Автоматически | Агент работает |
|
||||||
| **DONE** | task.status = completed | Автоматически | Агент завершил |
|
| **DONE** | task.status = completed | Автоматически | Агент завершил |
|
||||||
| **REVIEW** | kanban-state.json | Юзер (drag-and-drop) | На проверке |
|
| **REVIEW** | kanban-state.json | Юзер/UI actions | На проверке |
|
||||||
| **APPROVED** | kanban-state.json | Юзер (drag-and-drop) | Одобрено |
|
| **APPROVED** | kanban-state.json | Юзер/UI actions | Одобрено |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -90,9 +92,20 @@ const tasks = await getAllTasks(teamName);
|
||||||
|
|
||||||
## Review Flow
|
## Review Flow
|
||||||
|
|
||||||
|
⚠️ Этот файл описывает текущий продуктовый contract review flow. Исторические iteration-доки могут расходиться с ним.
|
||||||
|
|
||||||
|
### Manual actions from DONE
|
||||||
|
|
||||||
|
Из `DONE` сейчас есть два валидных пользовательских сценария:
|
||||||
|
|
||||||
|
1. **Request Review** - отправить задачу в `REVIEW`
|
||||||
|
2. **Approve** - сразу перевести задачу в `APPROVED` как manual shortcut
|
||||||
|
|
||||||
|
`REVIEW` нужен, когда пользователь хочет отдельный шаг проверки на доске, включая reviewer-driven flow или ручную проверку через UI. Но `REVIEW` не является обязательным промежуточным шагом для каждого manual approval.
|
||||||
|
|
||||||
### Перемещение DONE → REVIEW
|
### Перемещение DONE → REVIEW
|
||||||
|
|
||||||
1. Юзер перетаскивает карточку из DONE в REVIEW
|
1. Юзер переводит карточку из DONE в REVIEW через UI action
|
||||||
2. Проверяем `kanbanState.reviewers[]`
|
2. Проверяем `kanbanState.reviewers[]`
|
||||||
3. **Есть ревьюверы**:
|
3. **Есть ревьюверы**:
|
||||||
- Берём первого свободного (round-robin с балансировкой по количеству активных ревью)
|
- Берём первого свободного (round-robin с балансировкой по количеству активных ревью)
|
||||||
|
|
@ -109,7 +122,14 @@ const tasks = await getAllTasks(teamName);
|
||||||
```
|
```
|
||||||
4. **Нет ревьюверов**:
|
4. **Нет ревьюверов**:
|
||||||
- Записываем в kanban-state: `{ column: "review", reviewStatus: "pending" }`
|
- Записываем в kanban-state: `{ column: "review", reviewStatus: "pending" }`
|
||||||
- Юзер сам ревьювит через UI (кнопки OK / Error)
|
- Юзер сам ревьювит через UI (кнопки Approve / Request Changes)
|
||||||
|
|
||||||
|
### Прямое DONE → APPROVED
|
||||||
|
|
||||||
|
Юзер может сразу нажать **Approve** на карточке в `DONE`:
|
||||||
|
- kanban-state: `{ column: "approved" }`
|
||||||
|
- отдельный заход в `REVIEW` не требуется
|
||||||
|
- это manual shortcut и текущее допустимое поведение UI
|
||||||
|
|
||||||
### Review Result
|
### Review Result
|
||||||
|
|
||||||
|
|
@ -129,8 +149,10 @@ const tasks = await getAllTasks(teamName);
|
||||||
2. Появляется ReviewDialog — textarea для описания проблемы (опционально)
|
2. Появляется ReviewDialog — textarea для описания проблемы (опционально)
|
||||||
3. Юзер нажимает "Отправить"
|
3. Юзер нажимает "Отправить"
|
||||||
4. Действия:
|
4. Действия:
|
||||||
- kanban-state: удаляем запись для этой задачи (вернётся в IN PROGRESS по status)
|
- kanban-state: удаляем запись для этой задачи
|
||||||
- task file: `status = "in_progress"` (atomic write)
|
- task file: `status = "pending"`
|
||||||
|
- reviewState становится `needsFix`
|
||||||
|
- в UI задача возвращается в TODO/backlog path с маркером Needs Fixes
|
||||||
- Inbox к исходному owner:
|
- Inbox к исходному owner:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|
@ -150,30 +172,31 @@ const tasks = await getAllTasks(teamName);
|
||||||
|
|
||||||
### MVP: Click-to-Move
|
### MVP: Click-to-Move
|
||||||
|
|
||||||
Для MVP вместо drag-and-drop используется **click-to-move**: каждая карточка имеет кнопку или select-dropdown для смены колонки. Это проще реализовать и достаточно для первой версии.
|
Для текущего UI переходы между review-колонками делаются через **card actions** на карточке. Отдельный DnD сейчас используется для перестановки задач внутри колонки, а не для review state transitions.
|
||||||
|
|
||||||
```
|
```
|
||||||
[Task Card]
|
[Task Card]
|
||||||
Subject: Rename package in pubspec.yaml
|
Subject: Rename package in pubspec.yaml
|
||||||
Owner: worker-1
|
Owner: worker-1
|
||||||
[Move to: REVIEW ▼] ← dropdown или кнопка
|
[Approve] [Request review]
|
||||||
```
|
```
|
||||||
|
|
||||||
Разрешённые переходы через click-to-move:
|
Разрешённые review-переходы через UI actions:
|
||||||
| Откуда → Куда | Действие |
|
| Откуда → Куда | Действие |
|
||||||
|----------------|----------|
|
|----------------|----------|
|
||||||
| DONE → REVIEW | kanban-state: review + reviewStatus: pending. Inbox ревьюверу если есть |
|
| DONE → REVIEW | kanban-state: review + reviewStatus: pending. Inbox ревьюверу если есть |
|
||||||
|
| DONE → APPROVED (Approve) | kanban-state: approved |
|
||||||
| REVIEW → APPROVED (Approve) | kanban-state: approved |
|
| REVIEW → APPROVED (Approve) | kanban-state: approved |
|
||||||
| REVIEW → DONE (Request Changes) | Dialog → task: in_progress, kanban: remove, inbox к owner |
|
| REVIEW → TODO/Needs Fixes (Request Changes) | Dialog → task: pending + needsFix, kanban: remove, inbox к owner |
|
||||||
| APPROVED → DONE | kanban-state: remove (возвращается в DONE по status) |
|
| APPROVED → DONE | kanban-state: remove (возвращается в DONE по status) |
|
||||||
|
|
||||||
Не разрешено:
|
Не разрешено:
|
||||||
- TODO → IN PROGRESS (агент берёт сам через TaskUpdate)
|
- TODO → IN PROGRESS (агент берёт сам через TaskUpdate)
|
||||||
- IN PROGRESS → DONE (агент завершает сам через TaskUpdate)
|
- IN PROGRESS → DONE (агент завершает сам через TaskUpdate)
|
||||||
|
|
||||||
### Phase 2: Полноценный D&D через @dnd-kit
|
### Phase 2: Полноценный D&D для state transitions
|
||||||
|
|
||||||
`@dnd-kit` уже есть в зависимостях проекта (используется для перетаскивания табов). В Phase 2 добавить drag-and-drop для всех разрешённых переходов.
|
`@dnd-kit` уже используется для ordering. В Phase 2 можно добавить drag-and-drop и для самих state transitions, если это понадобится по UX.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
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,
|
lifetime: 2.0,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export const HANDOFF_CARD = {
|
||||||
|
triggerProgress: 0.58,
|
||||||
|
lingerSeconds: 3.2,
|
||||||
|
fadeInSeconds: 0.14,
|
||||||
|
fadeOutSeconds: 0.35,
|
||||||
|
width: 196,
|
||||||
|
maxVisible: 6,
|
||||||
|
maxPerDestination: 2,
|
||||||
|
baseHeight: 42,
|
||||||
|
previewLineHeight: 10,
|
||||||
|
previewMaxLines: 2,
|
||||||
|
previewMaxWidth: 176,
|
||||||
|
badgeGap: 8,
|
||||||
|
stackGap: 10,
|
||||||
|
viewportPadding: 12,
|
||||||
|
anchorGap: 14,
|
||||||
|
} as const;
|
||||||
|
|
||||||
// ─── Hit detection ──────────────────────────────────────────────────────────
|
// ─── Hit detection ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const HIT_DETECTION = {
|
export const HIT_DETECTION = {
|
||||||
|
|
|
||||||
|
|
@ -177,6 +177,8 @@ export interface GraphParticle {
|
||||||
size?: number;
|
size?: number;
|
||||||
/** Short label near particle */
|
/** Short label near particle */
|
||||||
label?: string;
|
label?: string;
|
||||||
|
/** Longer preview text for transient handoff cards */
|
||||||
|
preview?: string;
|
||||||
/** If true, particle travels from target → source (reverse direction) */
|
/** If true, particle travels from target → source (reverse direction) */
|
||||||
reverse?: boolean;
|
reverse?: boolean;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { useRef, useEffect, useImperativeHandle, forwardRef } from 'react';
|
||||||
import type { GraphNode, GraphEdge, GraphParticle } from '../ports/types';
|
import type { GraphNode, GraphEdge, GraphParticle } from '../ports/types';
|
||||||
import { drawBackground, createDepthParticles, updateDepthParticles, type DepthParticle } from '../canvas/background-layer';
|
import { drawBackground, createDepthParticles, updateDepthParticles, type DepthParticle } from '../canvas/background-layer';
|
||||||
import { drawEdges } from '../canvas/draw-edges';
|
import { drawEdges } from '../canvas/draw-edges';
|
||||||
|
import { drawHandoffCards } from '../canvas/draw-handoff-cards';
|
||||||
import { drawParticles } from '../canvas/draw-particles';
|
import { drawParticles } from '../canvas/draw-particles';
|
||||||
import { drawAgents, drawCrossTeamNodes } from '../canvas/draw-agents';
|
import { drawAgents, drawCrossTeamNodes } from '../canvas/draw-agents';
|
||||||
import { drawTasks, drawColumnHeaders } from '../canvas/draw-tasks';
|
import { drawTasks, drawColumnHeaders } from '../canvas/draw-tasks';
|
||||||
|
|
@ -18,11 +19,17 @@ import { drawEffects, type VisualEffect } from '../canvas/draw-effects';
|
||||||
import { BloomRenderer } from '../canvas/bloom-renderer';
|
import { BloomRenderer } from '../canvas/bloom-renderer';
|
||||||
import { KanbanLayoutEngine } from '../layout/kanbanLayout';
|
import { KanbanLayoutEngine } from '../layout/kanbanLayout';
|
||||||
import { computeAdaptiveParticleBudget, selectRenderableParticles } from './selectRenderableParticles';
|
import { computeAdaptiveParticleBudget, selectRenderableParticles } from './selectRenderableParticles';
|
||||||
|
import {
|
||||||
|
createTransientHandoffState,
|
||||||
|
selectRenderableTransientHandoffCards,
|
||||||
|
updateTransientHandoffState,
|
||||||
|
} from './transientHandoffs';
|
||||||
import type { CameraTransform } from '../hooks/useGraphCamera';
|
import type { CameraTransform } from '../hooks/useGraphCamera';
|
||||||
|
|
||||||
// ─── Draw State (passed by ref, not by props — no React re-renders) ─────────
|
// ─── Draw State (passed by ref, not by props — no React re-renders) ─────────
|
||||||
|
|
||||||
export interface GraphDrawState {
|
export interface GraphDrawState {
|
||||||
|
teamName: string;
|
||||||
nodes: GraphNode[];
|
nodes: GraphNode[];
|
||||||
edges: GraphEdge[];
|
edges: GraphEdge[];
|
||||||
particles: GraphParticle[];
|
particles: GraphParticle[];
|
||||||
|
|
@ -123,6 +130,8 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(funct
|
||||||
const visibleNodeIdsCache = useRef(new Set<string>());
|
const visibleNodeIdsCache = useRef(new Set<string>());
|
||||||
const visibleEdgeIdsCache = useRef(new Set<string>());
|
const visibleEdgeIdsCache = useRef(new Set<string>());
|
||||||
const activeParticleEdgesCache = useRef(new Set<string>());
|
const activeParticleEdgesCache = useRef(new Set<string>());
|
||||||
|
const handoffStateRef = useRef(createTransientHandoffState());
|
||||||
|
const lastTeamNameRef = useRef<string | null>(null);
|
||||||
|
|
||||||
// Imperative draw function — called from RAF, NOT from React render
|
// Imperative draw function — called from RAF, NOT from React render
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
|
|
@ -139,6 +148,10 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(funct
|
||||||
if (w === 0 || h === 0) return;
|
if (w === 0 || h === 0) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (lastTeamNameRef.current !== state.teamName) {
|
||||||
|
handoffStateRef.current = createTransientHandoffState();
|
||||||
|
lastTeamNameRef.current = state.teamName;
|
||||||
|
}
|
||||||
|
|
||||||
const cam = state.camera;
|
const cam = state.camera;
|
||||||
const zoom = cam.zoom;
|
const zoom = cam.zoom;
|
||||||
|
|
@ -234,6 +247,19 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(funct
|
||||||
focusEdgeIds: prioritizedEdgeIds,
|
focusEdgeIds: prioritizedEdgeIds,
|
||||||
budget: particleBudget,
|
budget: particleBudget,
|
||||||
});
|
});
|
||||||
|
updateTransientHandoffState(handoffStateRef.current, {
|
||||||
|
particles: state.particles,
|
||||||
|
edgeMap,
|
||||||
|
nodeMap,
|
||||||
|
time: state.time,
|
||||||
|
});
|
||||||
|
const renderableHandoffCards = selectRenderableTransientHandoffCards(
|
||||||
|
handoffStateRef.current,
|
||||||
|
{
|
||||||
|
focusNodeIds: state.focusNodeIds,
|
||||||
|
focusEdgeIds: prioritizedEdgeIds ?? state.focusEdgeIds,
|
||||||
|
}
|
||||||
|
);
|
||||||
drawParticles(ctx, renderableParticles, edgeMap, nodeMap, state.time, prioritizedEdgeIds);
|
drawParticles(ctx, renderableParticles, edgeMap, nodeMap, state.time, prioritizedEdgeIds);
|
||||||
|
|
||||||
// 2c. Visible nodes only (back to front: process → task → member/lead)
|
// 2c. Visible nodes only (back to front: process → task → member/lead)
|
||||||
|
|
@ -282,6 +308,19 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(funct
|
||||||
bloomRef.current.apply(canvas, ctx);
|
bloomRef.current.apply(canvas, ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (renderableHandoffCards.length > 0) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
drawHandoffCards(ctx, {
|
||||||
|
cards: renderableHandoffCards,
|
||||||
|
nodeMap,
|
||||||
|
time: state.time,
|
||||||
|
camera: cam,
|
||||||
|
viewport: { width: w, height: h },
|
||||||
|
});
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
// 4. Performance overlay (enabled via ?perf in URL)
|
// 4. Performance overlay (enabled via ?perf in URL)
|
||||||
const perf = perfRef.current;
|
const perf = perfRef.current;
|
||||||
const frameMs = performance.now() - frameStart;
|
const frameMs = performance.now() - frameStart;
|
||||||
|
|
|
||||||
|
|
@ -200,6 +200,7 @@ export function GraphView({
|
||||||
|
|
||||||
// 4. Draw canvas imperatively (NO React re-render)
|
// 4. Draw canvas imperatively (NO React re-render)
|
||||||
canvasHandle.current?.draw({
|
canvasHandle.current?.draw({
|
||||||
|
teamName: data.teamName,
|
||||||
nodes: state.nodes,
|
nodes: state.nodes,
|
||||||
edges: state.edges,
|
edges: state.edges,
|
||||||
particles: state.particles,
|
particles: state.particles,
|
||||||
|
|
|
||||||
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 { setReviewMainWindow } from './ipc/review';
|
||||||
import {
|
import {
|
||||||
ApiKeyService,
|
ApiKeyService,
|
||||||
RUNTIME_MANAGED_API_KEY_ENV_VARS,
|
|
||||||
ExtensionFacadeService,
|
ExtensionFacadeService,
|
||||||
GlamaMcpEnrichmentService,
|
GlamaMcpEnrichmentService,
|
||||||
McpCatalogAggregator,
|
McpCatalogAggregator,
|
||||||
|
|
@ -74,6 +73,7 @@ import {
|
||||||
PluginCatalogService,
|
PluginCatalogService,
|
||||||
PluginInstallationStateService,
|
PluginInstallationStateService,
|
||||||
PluginInstallService,
|
PluginInstallService,
|
||||||
|
RUNTIME_MANAGED_API_KEY_ENV_VARS,
|
||||||
SkillsCatalogService,
|
SkillsCatalogService,
|
||||||
SkillsMutationService,
|
SkillsMutationService,
|
||||||
SkillsWatcherService,
|
SkillsWatcherService,
|
||||||
|
|
@ -103,6 +103,11 @@ import {
|
||||||
import { syncTelemetryFlag } from './sentry';
|
import { syncTelemetryFlag } from './sentry';
|
||||||
import {
|
import {
|
||||||
BranchStatusService,
|
BranchStatusService,
|
||||||
|
BoardTaskActivityRecordSource,
|
||||||
|
BoardTaskActivityService,
|
||||||
|
BoardTaskExactLogDetailService,
|
||||||
|
BoardTaskExactLogsService,
|
||||||
|
BoardTaskLogStreamService,
|
||||||
CliInstallerService,
|
CliInstallerService,
|
||||||
configManager,
|
configManager,
|
||||||
LocalFileSystemProvider,
|
LocalFileSystemProvider,
|
||||||
|
|
@ -779,6 +784,13 @@ async function initializeServices(): Promise<void> {
|
||||||
cliInstallerService = new CliInstallerService();
|
cliInstallerService = new CliInstallerService();
|
||||||
ptyTerminalService = new PtyTerminalService();
|
ptyTerminalService = new PtyTerminalService();
|
||||||
const teamMemberLogsFinder = new TeamMemberLogsFinder();
|
const teamMemberLogsFinder = new TeamMemberLogsFinder();
|
||||||
|
const boardTaskActivityRecordSource = new BoardTaskActivityRecordSource();
|
||||||
|
const boardTaskActivityService = new BoardTaskActivityService(boardTaskActivityRecordSource);
|
||||||
|
const boardTaskExactLogsService = new BoardTaskExactLogsService(boardTaskActivityRecordSource);
|
||||||
|
const boardTaskExactLogDetailService = new BoardTaskExactLogDetailService(
|
||||||
|
boardTaskActivityRecordSource
|
||||||
|
);
|
||||||
|
const boardTaskLogStreamService = new BoardTaskLogStreamService(boardTaskActivityRecordSource);
|
||||||
const teamMemberRuntimeAdvisoryService = new TeamMemberRuntimeAdvisoryService(
|
const teamMemberRuntimeAdvisoryService = new TeamMemberRuntimeAdvisoryService(
|
||||||
teamMemberLogsFinder
|
teamMemberLogsFinder
|
||||||
);
|
);
|
||||||
|
|
@ -924,6 +936,10 @@ async function initializeServices(): Promise<void> {
|
||||||
teamProvisioningService,
|
teamProvisioningService,
|
||||||
teamMemberLogsFinder,
|
teamMemberLogsFinder,
|
||||||
memberStatsComputer,
|
memberStatsComputer,
|
||||||
|
boardTaskActivityService,
|
||||||
|
boardTaskLogStreamService,
|
||||||
|
boardTaskExactLogsService,
|
||||||
|
boardTaskExactLogDetailService,
|
||||||
teammateToolTracker ?? undefined,
|
teammateToolTracker ?? undefined,
|
||||||
branchStatusService ?? undefined,
|
branchStatusService ?? undefined,
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CLI_INSTALLER_GET_STATUS,
|
|
||||||
CLI_INSTALLER_GET_PROVIDER_STATUS,
|
CLI_INSTALLER_GET_PROVIDER_STATUS,
|
||||||
|
CLI_INSTALLER_GET_STATUS,
|
||||||
CLI_INSTALLER_INSTALL,
|
CLI_INSTALLER_INSTALL,
|
||||||
CLI_INSTALLER_INVALIDATE_STATUS,
|
CLI_INSTALLER_INVALIDATE_STATUS,
|
||||||
// eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload
|
// eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload
|
||||||
|
|
@ -17,8 +17,9 @@ import {
|
||||||
import { getErrorMessage } from '@shared/utils/errorHandling';
|
import { getErrorMessage } from '@shared/utils/errorHandling';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
||||||
import type { CliInstallerService } from '../services';
|
|
||||||
import { ClaudeBinaryResolver } from '../services/team/ClaudeBinaryResolver';
|
import { ClaudeBinaryResolver } from '../services/team/ClaudeBinaryResolver';
|
||||||
|
|
||||||
|
import type { CliInstallerService } from '../services';
|
||||||
import type {
|
import type {
|
||||||
CliInstallationStatus,
|
CliInstallationStatus,
|
||||||
CliProviderId,
|
CliProviderId,
|
||||||
|
|
|
||||||
|
|
@ -28,12 +28,12 @@ import {
|
||||||
} from '@preload/constants/ipcChannels';
|
} from '@preload/constants/ipcChannels';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
||||||
|
import {
|
||||||
|
type ApiKeyService,
|
||||||
|
RUNTIME_MANAGED_API_KEY_ENV_VARS,
|
||||||
|
} from '../services/extensions/apikeys/ApiKeyService';
|
||||||
import { GitHubStarsService } from '../services/extensions/catalog/GitHubStarsService';
|
import { GitHubStarsService } from '../services/extensions/catalog/GitHubStarsService';
|
||||||
|
|
||||||
import {
|
|
||||||
RUNTIME_MANAGED_API_KEY_ENV_VARS,
|
|
||||||
type ApiKeyService,
|
|
||||||
} from '../services/extensions/apikeys/ApiKeyService';
|
|
||||||
import type { ExtensionFacadeService } from '../services/extensions/ExtensionFacadeService';
|
import type { ExtensionFacadeService } from '../services/extensions/ExtensionFacadeService';
|
||||||
import type { McpInstallService } from '../services/extensions/install/McpInstallService';
|
import type { McpInstallService } from '../services/extensions/install/McpInstallService';
|
||||||
import type { PluginInstallService } from '../services/extensions/install/PluginInstallService';
|
import type { PluginInstallService } from '../services/extensions/install/PluginInstallService';
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { getErrorMessage } from '@shared/utils/errorHandling';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { execFile } from 'child_process';
|
import { execFile } from 'child_process';
|
||||||
|
|
||||||
import type { TmuxPlatform, TmuxStatus, IpcResult } from '@shared/types';
|
import type { IpcResult, TmuxPlatform, TmuxStatus } from '@shared/types';
|
||||||
import type { IpcMain, IpcMainInvokeEvent } from 'electron';
|
import type { IpcMain, IpcMainInvokeEvent } from 'electron';
|
||||||
|
|
||||||
const logger = createLogger('IPC:tmux');
|
const logger = createLogger('IPC:tmux');
|
||||||
|
|
|
||||||
|
|
@ -20,14 +20,15 @@ const { autoUpdater } = electronUpdater;
|
||||||
|
|
||||||
import { app, net } from 'electron';
|
import { app, net } from 'electron';
|
||||||
|
|
||||||
import type { UpdaterStatus } from '@shared/types';
|
|
||||||
import type { BrowserWindow } from 'electron';
|
|
||||||
import {
|
import {
|
||||||
getExpectedReleaseAssetUrl,
|
getExpectedReleaseAssetUrl,
|
||||||
getLatestMacMetadataUrl,
|
getLatestMacMetadataUrl,
|
||||||
isLatestMacMetadataCompatible,
|
isLatestMacMetadataCompatible,
|
||||||
} from './updaterReleaseMetadata';
|
} from './updaterReleaseMetadata';
|
||||||
|
|
||||||
|
import type { UpdaterStatus } from '@shared/types';
|
||||||
|
import type { BrowserWindow } from 'electron';
|
||||||
|
|
||||||
const logger = createLogger('UpdaterService');
|
const logger = createLogger('UpdaterService');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ export function parseReleaseMetadataAssetNames(metadataText: string): Set<string
|
||||||
|
|
||||||
for (const rawLine of metadataText.split(/\r?\n/u)) {
|
for (const rawLine of metadataText.split(/\r?\n/u)) {
|
||||||
const line = rawLine.trim();
|
const line = rawLine.trim();
|
||||||
const match = line.match(/^(?:-\s+)?(url|path):\s+(.+)$/u);
|
const match = /^(?:-\s+)?(url|path):\s+(.+)$/u.exec(line);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,20 @@
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
export type GeminiGlobalConfig = {
|
export interface GeminiGlobalConfig {
|
||||||
geminiBackendPreference?: 'auto' | 'api' | 'cli' | 'cli-sdk';
|
geminiBackendPreference?: 'auto' | 'api' | 'cli' | 'cli-sdk';
|
||||||
geminiResolvedBackend?: 'api' | 'cli' | 'cli-sdk';
|
geminiResolvedBackend?: 'api' | 'cli' | 'cli-sdk';
|
||||||
geminiLastAuthMethod?: string;
|
geminiLastAuthMethod?: string;
|
||||||
geminiProjectId?: string;
|
geminiProjectId?: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
export type GeminiRuntimeAuthState = {
|
export interface GeminiRuntimeAuthState {
|
||||||
authenticated: boolean;
|
authenticated: boolean;
|
||||||
authMethod: string | null;
|
authMethod: string | null;
|
||||||
resolvedBackend: 'auto' | 'api' | 'cli-sdk';
|
resolvedBackend: 'auto' | 'api' | 'cli-sdk';
|
||||||
projectId: string | null;
|
projectId: string | null;
|
||||||
statusMessage: string | null;
|
statusMessage: string | null;
|
||||||
};
|
}
|
||||||
|
|
||||||
function normalizeGeminiBackend(
|
function normalizeGeminiBackend(
|
||||||
value: string | null | undefined
|
value: string | null | undefined
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { TeamProviderId } from '@shared/types';
|
|
||||||
|
|
||||||
import { ConfigManager } from '../infrastructure/ConfigManager';
|
import { ConfigManager } from '../infrastructure/ConfigManager';
|
||||||
|
|
||||||
|
import type { TeamProviderId } from '@shared/types';
|
||||||
|
|
||||||
const PROVIDER_ROUTING_ENV_KEYS = [
|
const PROVIDER_ROUTING_ENV_KEYS = [
|
||||||
'CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST',
|
'CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST',
|
||||||
'CLAUDE_CODE_ENTRY_PROVIDER',
|
'CLAUDE_CODE_ENTRY_PROVIDER',
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,7 @@ export class BranchStatusService {
|
||||||
try {
|
try {
|
||||||
const branch = await this.resolver.getBranch(tracked.actualPath, { forceRefresh });
|
const branch = await this.resolver.getBranch(tracked.actualPath, { forceRefresh });
|
||||||
const latestTracked = this.trackedPaths.get(normalizedPath);
|
const latestTracked = this.trackedPaths.get(normalizedPath);
|
||||||
if (!latestTracked || latestTracked.token !== expectedToken) return;
|
if (latestTracked?.token !== expectedToken) return;
|
||||||
|
|
||||||
const previous = this.lastEmittedBranchByPath.get(normalizedPath) ?? UNSET_BRANCH;
|
const previous = this.lastEmittedBranchByPath.get(normalizedPath) ?? UNSET_BRANCH;
|
||||||
if (previous !== UNSET_BRANCH && previous === branch) {
|
if (previous !== UNSET_BRANCH && previous === branch) {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { getTeamsBasePath } from '@main/utils/pathDecoder';
|
import { getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||||
import { createPersistedLaunchSnapshot } from './TeamLaunchStateEvaluator';
|
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import { createPersistedLaunchSnapshot } from './TeamLaunchStateEvaluator';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
PersistedTeamLaunchMemberState,
|
PersistedTeamLaunchMemberState,
|
||||||
PersistedTeamLaunchSnapshot,
|
PersistedTeamLaunchSnapshot,
|
||||||
|
|
@ -19,15 +20,15 @@ const MAX_BOOTSTRAP_JOURNAL_BYTES = 256 * 1024;
|
||||||
const MAX_BOOTSTRAP_LOCK_METADATA_BYTES = 64 * 1024;
|
const MAX_BOOTSTRAP_LOCK_METADATA_BYTES = 64 * 1024;
|
||||||
const ACTIVE_BOOTSTRAP_STUCK_CLASSIFICATION_MS = 3 * 60 * 1000;
|
const ACTIVE_BOOTSTRAP_STUCK_CLASSIFICATION_MS = 3 * 60 * 1000;
|
||||||
|
|
||||||
type RawBootstrapMemberState = {
|
interface RawBootstrapMemberState {
|
||||||
name?: unknown;
|
name?: unknown;
|
||||||
status?: unknown;
|
status?: unknown;
|
||||||
lastAttemptAt?: unknown;
|
lastAttemptAt?: unknown;
|
||||||
lastObservedAt?: unknown;
|
lastObservedAt?: unknown;
|
||||||
failureReason?: unknown;
|
failureReason?: unknown;
|
||||||
};
|
}
|
||||||
|
|
||||||
type RawBootstrapState = {
|
interface RawBootstrapState {
|
||||||
version?: unknown;
|
version?: unknown;
|
||||||
runId?: unknown;
|
runId?: unknown;
|
||||||
teamName?: unknown;
|
teamName?: unknown;
|
||||||
|
|
@ -38,7 +39,7 @@ type RawBootstrapState = {
|
||||||
realTaskSubmissionState?: unknown;
|
realTaskSubmissionState?: unknown;
|
||||||
members?: unknown;
|
members?: unknown;
|
||||||
terminal?: unknown;
|
terminal?: unknown;
|
||||||
};
|
}
|
||||||
|
|
||||||
type RawBootstrapJournalRecord =
|
type RawBootstrapJournalRecord =
|
||||||
| { ts?: unknown; type?: 'phase'; phase?: unknown }
|
| { ts?: unknown; type?: 'phase'; phase?: unknown }
|
||||||
|
|
@ -47,31 +48,31 @@ type RawBootstrapJournalRecord =
|
||||||
| { ts?: unknown; type?: 'terminal'; status?: unknown; reason?: unknown }
|
| { ts?: unknown; type?: 'terminal'; status?: unknown; reason?: unknown }
|
||||||
| { ts?: unknown; type?: 'real_task'; state?: unknown; detail?: unknown };
|
| { ts?: unknown; type?: 'real_task'; state?: unknown; detail?: unknown };
|
||||||
|
|
||||||
type RawBootstrapLockMetadata = {
|
interface RawBootstrapLockMetadata {
|
||||||
pid?: unknown;
|
pid?: unknown;
|
||||||
runId?: unknown;
|
runId?: unknown;
|
||||||
requestHash?: unknown;
|
requestHash?: unknown;
|
||||||
ownerStartedAt?: unknown;
|
ownerStartedAt?: unknown;
|
||||||
createdAt?: unknown;
|
createdAt?: unknown;
|
||||||
nonce?: unknown;
|
nonce?: unknown;
|
||||||
};
|
}
|
||||||
|
|
||||||
type BootstrapStateInspection = {
|
interface BootstrapStateInspection {
|
||||||
raw: RawBootstrapState | null;
|
raw: RawBootstrapState | null;
|
||||||
issue?: string;
|
issue?: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
type BootstrapJournalInspection = {
|
interface BootstrapJournalInspection {
|
||||||
warnings?: string[];
|
warnings?: string[];
|
||||||
issue?: string;
|
issue?: string;
|
||||||
lastPhase?: BootstrapRuntimePhase;
|
lastPhase?: BootstrapRuntimePhase;
|
||||||
};
|
}
|
||||||
|
|
||||||
type BootstrapLockMetadata = {
|
interface BootstrapLockMetadata {
|
||||||
pid: number;
|
pid: number;
|
||||||
runId: string;
|
runId: string;
|
||||||
ownerStartedAt?: number;
|
ownerStartedAt?: number;
|
||||||
};
|
}
|
||||||
|
|
||||||
type BootstrapRuntimePhase =
|
type BootstrapRuntimePhase =
|
||||||
| 'validating_spec'
|
| 'validating_spec'
|
||||||
|
|
@ -84,13 +85,13 @@ type BootstrapRuntimePhase =
|
||||||
| 'failed'
|
| 'failed'
|
||||||
| 'canceled';
|
| 'canceled';
|
||||||
|
|
||||||
type ComparableStat = {
|
interface ComparableStat {
|
||||||
dev?: number;
|
dev?: number;
|
||||||
ino?: number;
|
ino?: number;
|
||||||
size: number;
|
size: number;
|
||||||
mode?: number;
|
mode?: number;
|
||||||
mtimeMs?: number;
|
mtimeMs?: number;
|
||||||
};
|
}
|
||||||
|
|
||||||
function isFiniteNumber(value: unknown): value is number {
|
function isFiniteNumber(value: unknown): value is number {
|
||||||
return typeof value === 'number' && Number.isFinite(value);
|
return typeof value === 'number' && Number.isFinite(value);
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,8 @@ import { Worker } from 'node:worker_threads';
|
||||||
|
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
||||||
import type { MemberLogSummary, TeamData } from '@shared/types';
|
|
||||||
import type { TeamDataWorkerRequest, TeamDataWorkerResponse } from './teamDataWorkerTypes';
|
import type { TeamDataWorkerRequest, TeamDataWorkerResponse } from './teamDataWorkerTypes';
|
||||||
|
import type { MemberLogSummary, TeamData } from '@shared/types';
|
||||||
|
|
||||||
const logger = createLogger('Service:TeamDataWorkerClient');
|
const logger = createLogger('Service:TeamDataWorkerClient');
|
||||||
const WORKER_CALL_TIMEOUT_MS = 30_000;
|
const WORKER_CALL_TIMEOUT_MS = 30_000;
|
||||||
|
|
@ -49,10 +49,10 @@ function resolveWorkerPath(): string | null {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type PendingEntry = {
|
interface PendingEntry {
|
||||||
resolve: (v: unknown) => void;
|
resolve: (v: unknown) => void;
|
||||||
reject: (e: Error) => void;
|
reject: (e: Error) => void;
|
||||||
};
|
}
|
||||||
|
|
||||||
export class TeamDataWorkerClient {
|
export class TeamDataWorkerClient {
|
||||||
private worker: Worker | null = null;
|
private worker: Worker | null = null;
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@ import { createLogger } from '@shared/utils/logger';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
import { normalizePersistedLaunchSnapshot } from './TeamLaunchStateEvaluator';
|
|
||||||
import { atomicWriteAsync } from './atomicWrite';
|
import { atomicWriteAsync } from './atomicWrite';
|
||||||
|
import { normalizePersistedLaunchSnapshot } from './TeamLaunchStateEvaluator';
|
||||||
|
|
||||||
import type { PersistedTeamLaunchSnapshot } from '@shared/types';
|
import type { PersistedTeamLaunchSnapshot } from '@shared/types';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
|
|
||||||
import type { MemberRuntimeAdvisory, ResolvedTeamMember } from '@shared/types';
|
|
||||||
import { createLogger } from '@shared/utils/logger';
|
|
||||||
|
|
||||||
import { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
|
import { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
|
||||||
|
|
||||||
|
import type { MemberRuntimeAdvisory, ResolvedTeamMember } from '@shared/types';
|
||||||
|
|
||||||
const LOOKBACK_MS = 10 * 60 * 1000;
|
const LOOKBACK_MS = 10 * 60 * 1000;
|
||||||
const CACHE_TTL_MS = 5_000;
|
const CACHE_TTL_MS = 5_000;
|
||||||
const TAIL_BYTES = 64 * 1024;
|
const TAIL_BYTES = 64 * 1024;
|
||||||
|
|
@ -102,11 +102,7 @@ export class TeamMemberRuntimeAdvisoryService {
|
||||||
const membersSignature = this.buildMembersSignature(activeMembers);
|
const membersSignature = this.buildMembersSignature(activeMembers);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const cachedBatch = this.teamBatchCacheByTeam.get(teamKey);
|
const cachedBatch = this.teamBatchCacheByTeam.get(teamKey);
|
||||||
if (
|
if (cachedBatch?.membersSignature === membersSignature && cachedBatch.expiresAt > now) {
|
||||||
cachedBatch &&
|
|
||||||
cachedBatch.membersSignature === membersSignature &&
|
|
||||||
cachedBatch.expiresAt > now
|
|
||||||
) {
|
|
||||||
return this.materializeBatchAdvisories(activeMembers, cachedBatch.value);
|
return this.materializeBatchAdvisories(activeMembers, cachedBatch.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead';
|
import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead';
|
||||||
import { getTeamsBasePath } from '@main/utils/pathDecoder';
|
import { getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||||
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
|
||||||
import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName';
|
import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName';
|
||||||
|
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,12 @@ import {
|
||||||
|
|
||||||
export type MainProcessIdleHandling = 'silent_noise' | 'passive_activity' | 'visible_actionable';
|
export type MainProcessIdleHandling = 'silent_noise' | 'passive_activity' | 'visible_actionable';
|
||||||
|
|
||||||
export type ClassifiedMainProcessIdle = {
|
export interface ClassifiedMainProcessIdle {
|
||||||
primaryKind: MainProcessIdlePrimaryKind;
|
primaryKind: MainProcessIdlePrimaryKind;
|
||||||
hasPeerSummary: boolean;
|
hasPeerSummary: boolean;
|
||||||
peerSummary: string | null;
|
peerSummary: string | null;
|
||||||
handling: MainProcessIdleHandling;
|
handling: MainProcessIdleHandling;
|
||||||
};
|
}
|
||||||
|
|
||||||
export function classifyIdleNotificationForMainProcess(
|
export function classifyIdleNotificationForMainProcess(
|
||||||
text: string
|
text: string
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
|
|
||||||
type InboxIdentityLike = {
|
interface InboxIdentityLike {
|
||||||
messageId?: unknown;
|
messageId?: unknown;
|
||||||
from?: unknown;
|
from?: unknown;
|
||||||
timestamp?: unknown;
|
timestamp?: unknown;
|
||||||
text?: unknown;
|
text?: unknown;
|
||||||
};
|
}
|
||||||
|
|
||||||
export function buildLegacyInboxMessageId(from: string, timestamp: string, text: string): string {
|
export function buildLegacyInboxMessageId(from: string, timestamp: string, text: string): string {
|
||||||
return `inbox-${createHash('sha256').update(`${from}\n${timestamp}\n${text}`).digest('hex').slice(0, 16)}`;
|
return `inbox-${createHash('sha256').update(`${from}\n${timestamp}\n${text}`).digest('hex').slice(0, 16)}`;
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,26 @@
|
||||||
export type MemberDiffInput = {
|
export interface MemberDiffInput {
|
||||||
name: string;
|
name: string;
|
||||||
role?: string;
|
role?: string;
|
||||||
workflow?: string;
|
workflow?: string;
|
||||||
providerId?: 'anthropic' | 'codex' | 'gemini';
|
providerId?: 'anthropic' | 'codex' | 'gemini';
|
||||||
model?: string;
|
model?: string;
|
||||||
removedAt?: number | string | null;
|
removedAt?: number | string | null;
|
||||||
};
|
}
|
||||||
|
|
||||||
export type ReplaceMembersDiff = {
|
export interface ReplaceMembersDiff {
|
||||||
added: Array<{
|
added: {
|
||||||
name: string;
|
name: string;
|
||||||
role?: string;
|
role?: string;
|
||||||
workflow?: string;
|
workflow?: string;
|
||||||
providerId?: 'anthropic' | 'codex' | 'gemini';
|
providerId?: 'anthropic' | 'codex' | 'gemini';
|
||||||
model?: string;
|
model?: string;
|
||||||
}>;
|
}[];
|
||||||
removed: string[];
|
removed: string[];
|
||||||
updated: Array<{
|
updated: {
|
||||||
name: string;
|
name: string;
|
||||||
changes: string[];
|
changes: string[];
|
||||||
}>;
|
}[];
|
||||||
};
|
}
|
||||||
|
|
||||||
function normalizeOptionalText(value: string | undefined): string | undefined {
|
function normalizeOptionalText(value: string | undefined): string | undefined {
|
||||||
const normalized = value?.trim();
|
const normalized = value?.trim();
|
||||||
|
|
@ -61,13 +61,13 @@ function describeWorkflowChange(
|
||||||
|
|
||||||
export function buildReplaceMembersDiff(
|
export function buildReplaceMembersDiff(
|
||||||
previousMembers: MemberDiffInput[],
|
previousMembers: MemberDiffInput[],
|
||||||
nextMembers: Array<{
|
nextMembers: {
|
||||||
name: string;
|
name: string;
|
||||||
role?: string;
|
role?: string;
|
||||||
workflow?: string;
|
workflow?: string;
|
||||||
providerId?: 'anthropic' | 'codex' | 'gemini';
|
providerId?: 'anthropic' | 'codex' | 'gemini';
|
||||||
model?: string;
|
model?: string;
|
||||||
}>
|
}[]
|
||||||
): ReplaceMembersDiff {
|
): ReplaceMembersDiff {
|
||||||
const previousByName = new Map(
|
const previousByName = new Map(
|
||||||
previousMembers
|
previousMembers
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
import { execFile } from 'child_process';
|
|
||||||
|
|
||||||
import { parseCliArgs } from '@shared/utils/cliArgsParser';
|
import { parseCliArgs } from '@shared/utils/cliArgsParser';
|
||||||
|
import { execFile } from 'child_process';
|
||||||
|
|
||||||
const TMUX_AVAILABILITY_CACHE_TTL_MS = 10_000;
|
const TMUX_AVAILABILITY_CACHE_TTL_MS = 10_000;
|
||||||
|
|
||||||
type DesktopTeammateModeDecision = {
|
interface DesktopTeammateModeDecision {
|
||||||
injectedTeammateMode: 'tmux' | null;
|
injectedTeammateMode: 'tmux' | null;
|
||||||
forceProcessTeammates: boolean;
|
forceProcessTeammates: boolean;
|
||||||
};
|
}
|
||||||
|
|
||||||
let tmuxAvailabilityCache: { value: boolean; at: number } | null = null;
|
let tmuxAvailabilityCache: { value: boolean; at: number } | null = null;
|
||||||
let tmuxAvailablePromise: Promise<boolean> | null = null;
|
let tmuxAvailablePromise: Promise<boolean> | null = null;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
|
import { deriveTaskSince } from '@shared/utils/taskChangeSince';
|
||||||
import {
|
import {
|
||||||
getTaskChangeStateBucket,
|
getTaskChangeStateBucket,
|
||||||
type TaskChangeStateBucket,
|
type TaskChangeStateBucket,
|
||||||
} from '@shared/utils/taskChangeState';
|
} from '@shared/utils/taskChangeState';
|
||||||
import { deriveTaskSince } from '@shared/utils/taskChangeSince';
|
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
|
|
||||||
export interface TaskChangePresenceInterval {
|
export interface TaskChangePresenceInterval {
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,12 @@
|
||||||
|
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
||||||
|
import { LocalFileSystemProvider } from './services/infrastructure/LocalFileSystemProvider';
|
||||||
import {
|
import {
|
||||||
getProjectsBasePath,
|
getProjectsBasePath,
|
||||||
getTodosBasePath,
|
getTodosBasePath,
|
||||||
setClaudeBasePathOverride,
|
setClaudeBasePathOverride,
|
||||||
} from './utils/pathDecoder';
|
} from './utils/pathDecoder';
|
||||||
import { LocalFileSystemProvider } from './services/infrastructure/LocalFileSystemProvider';
|
|
||||||
|
|
||||||
import type { HttpServices } from './http';
|
import type { HttpServices } from './http';
|
||||||
import type { HttpServer } from './services/infrastructure/HttpServer';
|
import type { HttpServer } from './services/infrastructure/HttpServer';
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,12 @@ import { parentPort } from 'node:worker_threads';
|
||||||
import { TeamDataService } from '@main/services/team/TeamDataService';
|
import { TeamDataService } from '@main/services/team/TeamDataService';
|
||||||
import { TeamMemberLogsFinder } from '@main/services/team/TeamMemberLogsFinder';
|
import { TeamMemberLogsFinder } from '@main/services/team/TeamMemberLogsFinder';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import type { MemberLogSummary } from '@shared/types';
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
TeamDataWorkerRequest,
|
TeamDataWorkerRequest,
|
||||||
TeamDataWorkerResponse,
|
TeamDataWorkerResponse,
|
||||||
} from '@main/services/team/teamDataWorkerTypes';
|
} from '@main/services/team/teamDataWorkerTypes';
|
||||||
|
import type { MemberLogSummary } from '@shared/types';
|
||||||
|
|
||||||
const logger = createLogger('Worker:TeamData');
|
const logger = createLogger('Worker:TeamData');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@ import * as fs from 'node:fs';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import { parentPort } from 'node:worker_threads';
|
import { parentPort } from 'node:worker_threads';
|
||||||
|
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
|
||||||
import { normalizePersistedLaunchSnapshot } from '@main/services/team/TeamLaunchStateEvaluator';
|
import { normalizePersistedLaunchSnapshot } from '@main/services/team/TeamLaunchStateEvaluator';
|
||||||
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
|
|
||||||
interface ListTeamsPayload {
|
interface ListTeamsPayload {
|
||||||
teamsDir: string;
|
teamsDir: string;
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import remarkGfm from 'remark-gfm';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { CopyButton } from '../common/CopyButton';
|
import { CopyButton } from '../common/CopyButton';
|
||||||
|
|
||||||
import { extractTextFromReactNode } from './markdownCopyUtils';
|
import { extractTextFromReactNode } from './markdownCopyUtils';
|
||||||
import {
|
import {
|
||||||
createSearchContext,
|
createSearchContext,
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ import {
|
||||||
import { useTabUI } from '@renderer/hooks/useTabUI';
|
import { useTabUI } from '@renderer/hooks/useTabUI';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
|
||||||
import { buildDisplayItemsFromMessages, buildSummary } from '@renderer/utils/aiGroupEnhancer';
|
import { buildDisplayItemsFromMessages, buildSummary } from '@renderer/utils/aiGroupEnhancer';
|
||||||
import { computeSubagentPhaseBreakdown } from '@renderer/utils/aiGroupHelpers';
|
import { computeSubagentPhaseBreakdown } from '@renderer/utils/aiGroupHelpers';
|
||||||
import { formatDuration, formatTokensCompact } from '@renderer/utils/formatters';
|
import { formatDuration, formatTokensCompact } from '@renderer/utils/formatters';
|
||||||
|
|
@ -37,6 +36,7 @@ import {
|
||||||
Sigma,
|
Sigma,
|
||||||
Terminal,
|
Terminal,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { ExecutionTrace } from './ExecutionTrace';
|
import { ExecutionTrace } from './ExecutionTrace';
|
||||||
import { MetricsPill } from './MetricsPill';
|
import { MetricsPill } from './MetricsPill';
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import {
|
||||||
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
|
||||||
import { detectOperationalNoise } from '@renderer/utils/agentMessageFormatting';
|
import { detectOperationalNoise } from '@renderer/utils/agentMessageFormatting';
|
||||||
import { formatTokensCompact } from '@renderer/utils/formatters';
|
import { formatTokensCompact } from '@renderer/utils/formatters';
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
|
|
@ -19,6 +18,7 @@ import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||||
import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch';
|
import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { ChevronRight, CornerDownLeft, MessageSquare, RefreshCw } from 'lucide-react';
|
import { ChevronRight, CornerDownLeft, MessageSquare, RefreshCw } from 'lucide-react';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { MarkdownViewer } from '../viewers/MarkdownViewer';
|
import { MarkdownViewer } from '../viewers/MarkdownViewer';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import React from 'react';
|
||||||
import { type ItemStatus } from '../BaseItem';
|
import { type ItemStatus } from '../BaseItem';
|
||||||
|
|
||||||
import { CollapsibleOutputSection } from './CollapsibleOutputSection';
|
import { CollapsibleOutputSection } from './CollapsibleOutputSection';
|
||||||
import { renderInput, renderOutput } from './renderHelpers';
|
import { extractOutputText, renderInput, renderOutput } from './renderHelpers';
|
||||||
|
|
||||||
import type { LinkedToolItem } from '@renderer/types/groups';
|
import type { LinkedToolItem } from '@renderer/types/groups';
|
||||||
|
|
||||||
|
|
@ -19,6 +19,13 @@ interface DefaultToolViewerProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DefaultToolViewer: React.FC<DefaultToolViewerProps> = ({ linkedTool, status }) => {
|
export const DefaultToolViewer: React.FC<DefaultToolViewerProps> = ({ linkedTool, status }) => {
|
||||||
|
const hasMeaningfulOutput =
|
||||||
|
linkedTool.result &&
|
||||||
|
(() => {
|
||||||
|
const text = extractOutputText(linkedTool.result.content).trim();
|
||||||
|
return text.length > 0 && text !== '[]' && text !== '{}';
|
||||||
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Input Section */}
|
{/* Input Section */}
|
||||||
|
|
@ -39,7 +46,7 @@ export const DefaultToolViewer: React.FC<DefaultToolViewerProps> = ({ linkedTool
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Output Section — Collapsed by default */}
|
{/* Output Section — Collapsed by default */}
|
||||||
{!linkedTool.isOrphaned && linkedTool.result && (
|
{!linkedTool.isOrphaned && linkedTool.result && hasMeaningfulOutput && (
|
||||||
<CollapsibleOutputSection status={status}>
|
<CollapsibleOutputSection status={status}>
|
||||||
{renderOutput(linkedTool.result.content)}
|
{renderOutput(linkedTool.result.content)}
|
||||||
</CollapsibleOutputSection>
|
</CollapsibleOutputSection>
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@ import React from 'react';
|
||||||
import { CopyButton } from '@renderer/components/common/CopyButton';
|
import { CopyButton } from '@renderer/components/common/CopyButton';
|
||||||
import { PROSE_BODY } from '@renderer/constants/cssVariables';
|
import { PROSE_BODY } from '@renderer/constants/cssVariables';
|
||||||
|
|
||||||
|
import { FileLink, isRelativeUrl } from './viewers/FileLink';
|
||||||
import { extractTextFromReactNode } from './markdownCopyUtils';
|
import { extractTextFromReactNode } from './markdownCopyUtils';
|
||||||
import { highlightSearchInChildren, type SearchContext } from './searchHighlightUtils';
|
import { highlightSearchInChildren, type SearchContext } from './searchHighlightUtils';
|
||||||
import { FileLink, isRelativeUrl } from './viewers/FileLink';
|
|
||||||
|
|
||||||
import type { Components } from 'react-markdown';
|
import type { Components } from 'react-markdown';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ import {
|
||||||
type SearchContext,
|
type SearchContext,
|
||||||
} from '../searchHighlightUtils';
|
} from '../searchHighlightUtils';
|
||||||
import { highlightLine } from '../viewers/syntaxHighlighter';
|
import { highlightLine } from '../viewers/syntaxHighlighter';
|
||||||
|
|
||||||
import { FileLink, isRelativeUrl } from './FileLink';
|
import { FileLink, isRelativeUrl } from './FileLink';
|
||||||
import { MermaidDiagram } from './MermaidDiagram';
|
import { MermaidDiagram } from './MermaidDiagram';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ type BannerState =
|
||||||
|
|
||||||
const INITIAL_STATE: BannerState = { loading: true, status: null, error: null };
|
const INITIAL_STATE: BannerState = { loading: true, status: null, error: null };
|
||||||
|
|
||||||
function PlatformInstallMatrix(): React.JSX.Element {
|
const PlatformInstallMatrix = (): React.JSX.Element => {
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 grid gap-2 lg:grid-cols-3">
|
<div className="mt-3 grid gap-2 lg:grid-cols-3">
|
||||||
<div
|
<div
|
||||||
|
|
@ -74,7 +74,7 @@ function PlatformInstallMatrix(): React.JSX.Element {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
function getPrimaryDetail(status: TmuxStatus): string {
|
function getPrimaryDetail(status: TmuxStatus): string {
|
||||||
if (status.platform === 'darwin') {
|
if (status.platform === 'darwin') {
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@ import { useEffect, useState } from 'react';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
|
||||||
import { AlertTriangle, Info, Key, Plus } from 'lucide-react';
|
import { AlertTriangle, Info, Key, Plus } from 'lucide-react';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { ApiKeyCard } from './ApiKeyCard';
|
import { ApiKeyCard } from './ApiKeyCard';
|
||||||
import { ApiKeyFormDialog } from './ApiKeyFormDialog';
|
import { ApiKeyFormDialog } from './ApiKeyFormDialog';
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,8 @@ import {
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@renderer/components/ui/tooltip';
|
} from '@renderer/components/ui/tooltip';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
|
||||||
import { Check, Loader2, Trash2 } from 'lucide-react';
|
import { Check, Loader2, Trash2 } from 'lucide-react';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import type { ExtensionOperationState } from '@shared/types/extensions';
|
import type { ExtensionOperationState } from '@shared/types/extensions';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,11 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@renderer/components/ui/select';
|
} from '@renderer/components/ui/select';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
|
||||||
import { formatRelativeTime } from '@renderer/utils/formatters';
|
import { formatRelativeTime } from '@renderer/utils/formatters';
|
||||||
import { CLI_NOT_FOUND_MARKER } from '@shared/constants/cli';
|
import { CLI_NOT_FOUND_MARKER } from '@shared/constants/cli';
|
||||||
import { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers';
|
import { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers';
|
||||||
import { AlertTriangle, RefreshCw, Search, Server } from 'lucide-react';
|
import { AlertTriangle, RefreshCw, Search, Server } from 'lucide-react';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { SearchInput } from '../common/SearchInput';
|
import { SearchInput } from '../common/SearchInput';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,13 +24,13 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@renderer/components/ui/select';
|
} from '@renderer/components/ui/select';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
|
||||||
import {
|
import {
|
||||||
getCapabilityLabel,
|
getCapabilityLabel,
|
||||||
inferCapabilities,
|
inferCapabilities,
|
||||||
normalizeCategory,
|
normalizeCategory,
|
||||||
} from '@shared/utils/extensionNormalizers';
|
} from '@shared/utils/extensionNormalizers';
|
||||||
import { ExternalLink, Loader2, Mail } from 'lucide-react';
|
import { ExternalLink, Loader2, Mail } from 'lucide-react';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { InstallButton } from '../common/InstallButton';
|
import { InstallButton } from '../common/InstallButton';
|
||||||
import { InstallCountBadge } from '../common/InstallCountBadge';
|
import { InstallCountBadge } from '../common/InstallCountBadge';
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,9 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@renderer/components/ui/select';
|
} from '@renderer/components/ui/select';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
|
||||||
import { inferCapabilities, normalizeCategory } from '@shared/utils/extensionNormalizers';
|
import { inferCapabilities, normalizeCategory } from '@shared/utils/extensionNormalizers';
|
||||||
import { ArrowUpDown, Filter, Puzzle, Search } from 'lucide-react';
|
import { ArrowUpDown, Filter, Puzzle, Search } from 'lucide-react';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { SearchInput } from '../common/SearchInput';
|
import { SearchInput } from '../common/SearchInput';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,8 @@ import {
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@renderer/components/ui/dialog';
|
} from '@renderer/components/ui/dialog';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
|
||||||
import { AlertTriangle, ExternalLink, FolderOpen, Pencil, Trash2 } from 'lucide-react';
|
import { AlertTriangle, ExternalLink, FolderOpen, Pencil, Trash2 } from 'lucide-react';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
interface SkillDetailDialogProps {
|
interface SkillDetailDialogProps {
|
||||||
skillId: string | null;
|
skillId: string | null;
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import { Button } from '@renderer/components/ui/button';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ArrowUpAZ,
|
ArrowUpAZ,
|
||||||
|
|
@ -19,6 +18,7 @@ import {
|
||||||
Plus,
|
Plus,
|
||||||
Search,
|
Search,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { SearchInput } from '../common/SearchInput';
|
import { SearchInput } from '../common/SearchInput';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
|
||||||
import { computeTakeaways } from '@renderer/utils/reportAssessments';
|
import { computeTakeaways } from '@renderer/utils/reportAssessments';
|
||||||
import { analyzeSession } from '@renderer/utils/sessionAnalyzer';
|
import { analyzeSession } from '@renderer/utils/sessionAnalyzer';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { CostSection } from './sections/CostSection';
|
import { CostSection } from './sections/CostSection';
|
||||||
import { ErrorSection } from './sections/ErrorSection';
|
import { ErrorSection } from './sections/ErrorSection';
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import type { CliProviderStatus } from '@shared/types';
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
|
|
@ -13,11 +12,13 @@ import {
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@renderer/components/ui/tooltip';
|
} from '@renderer/components/ui/tooltip';
|
||||||
|
|
||||||
type Props = {
|
import type { CliProviderStatus } from '@shared/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
provider: CliProviderStatus;
|
provider: CliProviderStatus;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
onSelect: (providerId: CliProviderStatus['providerId'], backendId: string) => void;
|
onSelect: (providerId: CliProviderStatus['providerId'], backendId: string) => void;
|
||||||
};
|
}
|
||||||
|
|
||||||
export function getOptionDisplayLabel(
|
export function getOptionDisplayLabel(
|
||||||
option: NonNullable<CliProviderStatus['availableBackends']>[number],
|
option: NonNullable<CliProviderStatus['availableBackends']>[number],
|
||||||
|
|
@ -47,11 +48,11 @@ export function getProviderRuntimeBackendSummary(provider: CliProviderStatus): s
|
||||||
return getOptionDisplayLabel(selectedOption, resolvedOption);
|
return getOptionDisplayLabel(selectedOption, resolvedOption);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProviderRuntimeBackendSelector({
|
export const ProviderRuntimeBackendSelector = ({
|
||||||
provider,
|
provider,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
onSelect,
|
onSelect,
|
||||||
}: Props): React.JSX.Element | null {
|
}: Props): React.JSX.Element | null => {
|
||||||
const options = provider.availableBackends ?? [];
|
const options = provider.availableBackends ?? [];
|
||||||
if (options.length === 0) {
|
if (options.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -191,4 +192,4 @@ export function ProviderRuntimeBackendSelector({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
|
||||||
import { nameColorSet } from '@renderer/utils/projectColor';
|
import { nameColorSet } from '@renderer/utils/projectColor';
|
||||||
import { formatNextRun, getCronDescription } from '@renderer/utils/scheduleFormatters';
|
import { formatNextRun, getCronDescription } from '@renderer/utils/scheduleFormatters';
|
||||||
import {
|
import {
|
||||||
|
|
@ -23,6 +22,7 @@ import {
|
||||||
Trash2,
|
Trash2,
|
||||||
Zap,
|
Zap,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { LaunchTeamDialog } from '../team/dialogs/LaunchTeamDialog';
|
import { LaunchTeamDialog } from '../team/dialogs/LaunchTeamDialog';
|
||||||
import { ScheduleRunLogDialog } from '../team/schedule/ScheduleRunLogDialog';
|
import { ScheduleRunLogDialog } from '../team/schedule/ScheduleRunLogDialog';
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
|
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
|
||||||
import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers';
|
||||||
import { nameColorSet } from '@renderer/utils/projectColor';
|
import { nameColorSet } from '@renderer/utils/projectColor';
|
||||||
import { projectColor } from '@renderer/utils/projectColor';
|
import { projectColor } from '@renderer/utils/projectColor';
|
||||||
|
|
@ -13,6 +12,7 @@ import { projectLabelFromPath } from '@renderer/utils/taskGrouping';
|
||||||
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
||||||
import { format, isThisYear, isToday, isYesterday } from 'date-fns';
|
import { format, isThisYear, isToday, isYesterday } from 'date-fns';
|
||||||
import { CheckCircle2, Circle, Eye, Loader2, ShieldCheck, Trash2 } from 'lucide-react';
|
import { CheckCircle2, Circle, Eye, Loader2, ShieldCheck, Trash2 } from 'lucide-react';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import type { GlobalTask, TeamTaskStatus } from '@shared/types';
|
import type { GlobalTask, TeamTaskStatus } from '@shared/types';
|
||||||
import type { LucideIcon } from 'lucide-react';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
|
|
||||||
import { formatDistanceToNowStrict } from 'date-fns';
|
import { formatDistanceToNowStrict } from 'date-fns';
|
||||||
import { ExternalLink, Square, Terminal } from 'lucide-react';
|
import { ExternalLink, Square, Terminal } from 'lucide-react';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -207,7 +207,7 @@ export const ProvisioningProgressBlock = ({
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
surface === 'flat'
|
surface === 'flat'
|
||||||
? 'rounded-none border-0 bg-transparent px-0 py-0'
|
? 'rounded-none border-0 bg-transparent p-0'
|
||||||
: 'rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-3 py-2',
|
: 'rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-3 py-2',
|
||||||
isError && 'border-red-500/40 bg-red-500/10',
|
isError && 'border-red-500/40 bg-red-500/10',
|
||||||
className
|
className
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,11 @@ import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer
|
||||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
|
||||||
import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers';
|
||||||
import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils';
|
import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils';
|
||||||
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
||||||
import { formatTaskDisplayLabel, taskMatchesRef } from '@shared/utils/taskIdentity';
|
import { formatTaskDisplayLabel, taskMatchesRef } from '@shared/utils/taskIdentity';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import type { TeamTaskWithKanban } from '@shared/types';
|
import type { TeamTaskWithKanban } from '@shared/types';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -824,9 +824,11 @@ export const TeamDetailView = ({
|
||||||
const handler = (e: Event) => {
|
const handler = (e: Event) => {
|
||||||
const detail = (e as CustomEvent).detail;
|
const detail = (e as CustomEvent).detail;
|
||||||
if (detail?.teamName === teamName) {
|
if (detail?.teamName === teamName) {
|
||||||
|
const state = useStore.getState();
|
||||||
|
const displayName = state.teamByName[teamName]?.displayName ?? teamName;
|
||||||
useStore.getState().openTab({
|
useStore.getState().openTab({
|
||||||
type: 'graph',
|
type: 'graph',
|
||||||
label: `${teamName} Graph`,
|
label: `${displayName} Graph`,
|
||||||
teamName,
|
teamName,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
|
|
||||||
type TeamEmptyStateProps = {
|
interface TeamEmptyStateProps {
|
||||||
canCreate: boolean;
|
canCreate: boolean;
|
||||||
onCreateTeam: () => void;
|
onCreateTeam: () => void;
|
||||||
};
|
}
|
||||||
|
|
||||||
export const TeamEmptyState = ({
|
export const TeamEmptyState = ({
|
||||||
canCreate,
|
canCreate,
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,10 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
|
||||||
import { shortenDisplayPath } from '@renderer/utils/pathDisplay';
|
import { shortenDisplayPath } from '@renderer/utils/pathDisplay';
|
||||||
import { highlightLines } from '@renderer/utils/syntaxHighlighter';
|
import { highlightLines } from '@renderer/utils/syntaxHighlighter';
|
||||||
import { AlertTriangle, FileText, MessageCircleQuestion, Search, Terminal } from 'lucide-react';
|
import { AlertTriangle, FileText, MessageCircleQuestion, Search, Terminal } from 'lucide-react';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ToolApprovalSettingsContent,
|
ToolApprovalSettingsContent,
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ import {
|
||||||
import { getTeamColorSet, getThemedBorder } from '@renderer/constants/teamColors';
|
import { getTeamColorSet, getThemedBorder } from '@renderer/constants/teamColors';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
|
||||||
import {
|
import {
|
||||||
getMessageTypeLabel,
|
getMessageTypeLabel,
|
||||||
getStructuredMessageSummary,
|
getStructuredMessageSummary,
|
||||||
|
|
@ -70,6 +69,7 @@ import {
|
||||||
Reply,
|
Reply,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { ReplyQuoteBlock } from './ReplyQuoteBlock';
|
import { ReplyQuoteBlock } from './ReplyQuoteBlock';
|
||||||
|
|
||||||
|
|
@ -111,7 +111,7 @@ function getCommandOutputSummary(text: string): string {
|
||||||
|
|
||||||
function parseIdlePeerSummaryRoute(summary: string): { recipient: string | null; body: string } {
|
function parseIdlePeerSummaryRoute(summary: string): { recipient: string | null; body: string } {
|
||||||
const trimmed = summary.trim();
|
const trimmed = summary.trim();
|
||||||
const match = trimmed.match(/^\[to\s+([^\]]+)\]\s*(.*)$/i);
|
const match = /^\[to\s+([^\]]+)\]\s*(.*)$/i.exec(trimmed);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
return { recipient: null, body: trimmed };
|
return { recipient: null, body: trimmed };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { CARD_BG, CARD_BORDER_STYLE, CARD_ICON_MUTED } from '@renderer/constants
|
||||||
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
import {
|
import {
|
||||||
agentAvatarUrl,
|
agentAvatarUrl,
|
||||||
|
|
@ -14,6 +13,7 @@ import {
|
||||||
import { nameColorSet } from '@renderer/utils/projectColor';
|
import { nameColorSet } from '@renderer/utils/projectColor';
|
||||||
import { formatDistanceToNowStrict } from 'date-fns';
|
import { formatDistanceToNowStrict } from 'date-fns';
|
||||||
import { Loader2, ShieldQuestion, Users } from 'lucide-react';
|
import { Loader2, ShieldQuestion, Users } from 'lucide-react';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import type { ResolvedTeamMember } from '@shared/types';
|
import type { ResolvedTeamMember } from '@shared/types';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,23 +41,24 @@ import {
|
||||||
normalizeCreateLaunchProviderForUi,
|
normalizeCreateLaunchProviderForUi,
|
||||||
} from '@renderer/utils/geminiUiFreeze';
|
} from '@renderer/utils/geminiUiFreeze';
|
||||||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||||
|
import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog';
|
||||||
import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability';
|
import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability';
|
||||||
import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||||
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
|
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
|
||||||
|
|
||||||
import { AdvancedCliSection } from './AdvancedCliSection';
|
import { AdvancedCliSection } from './AdvancedCliSection';
|
||||||
import { OptionalSettingsSection } from './OptionalSettingsSection';
|
import { OptionalSettingsSection } from './OptionalSettingsSection';
|
||||||
|
import { ProjectPathSelector } from './ProjectPathSelector';
|
||||||
import {
|
import {
|
||||||
createInitialProviderChecks,
|
createInitialProviderChecks,
|
||||||
failIncompleteProviderChecks,
|
failIncompleteProviderChecks,
|
||||||
getProvisioningProviderBackendSummary,
|
|
||||||
getProvisioningFailureHint,
|
getProvisioningFailureHint,
|
||||||
|
getProvisioningProviderBackendSummary,
|
||||||
|
type ProvisioningProviderCheck,
|
||||||
ProvisioningProviderStatusList,
|
ProvisioningProviderStatusList,
|
||||||
shouldHideProvisioningProviderStatusList,
|
shouldHideProvisioningProviderStatusList,
|
||||||
updateProviderCheck,
|
updateProviderCheck,
|
||||||
type ProvisioningProviderCheck,
|
|
||||||
} from './ProvisioningProviderStatusList';
|
} from './ProvisioningProviderStatusList';
|
||||||
import { ProjectPathSelector } from './ProjectPathSelector';
|
|
||||||
import { SkipPermissionsCheckbox } from './SkipPermissionsCheckbox';
|
import { SkipPermissionsCheckbox } from './SkipPermissionsCheckbox';
|
||||||
import { computeEffectiveTeamModel } from './TeamModelSelector';
|
import { computeEffectiveTeamModel } from './TeamModelSelector';
|
||||||
import { getNextSuggestedTeamName } from './teamNameSets';
|
import { getNextSuggestedTeamName } from './teamNameSets';
|
||||||
|
|
@ -111,15 +112,7 @@ function isEphemeralRenderedProjectPath(projectPath: string | null | undefined):
|
||||||
}
|
}
|
||||||
|
|
||||||
function getProviderLabel(providerId: TeamProviderId): string {
|
function getProviderLabel(providerId: TeamProviderId): string {
|
||||||
switch (providerId) {
|
return getCatalogTeamProviderLabel(providerId) ?? 'Anthropic';
|
||||||
case 'codex':
|
|
||||||
return 'Codex';
|
|
||||||
case 'gemini':
|
|
||||||
return 'Gemini';
|
|
||||||
case 'anthropic':
|
|
||||||
default:
|
|
||||||
return 'Anthropic';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TeamCopyData {
|
export interface TeamCopyData {
|
||||||
|
|
@ -484,9 +477,7 @@ export const CreateTeamDialog = ({
|
||||||
}, [members, multimodelEnabled, selectedProviderId, soloTeam, syncModelsWithLead]);
|
}, [members, multimodelEnabled, selectedProviderId, soloTeam, syncModelsWithLead]);
|
||||||
|
|
||||||
const runtimeBackendSummaryByProvider = useMemo(() => {
|
const runtimeBackendSummaryByProvider = useMemo(() => {
|
||||||
const entries: Array<readonly [TeamProviderId, string | null]> = (
|
const entries: (readonly [TeamProviderId, string | null])[] = (cliStatus?.providers ?? []).map(
|
||||||
cliStatus?.providers ?? []
|
|
||||||
).map(
|
|
||||||
(provider) =>
|
(provider) =>
|
||||||
[
|
[
|
||||||
provider.providerId as TeamProviderId,
|
provider.providerId as TeamProviderId,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
|
import { SkipPermissionsCheckbox } from '@renderer/components/team/dialogs/SkipPermissionsCheckbox';
|
||||||
import {
|
import {
|
||||||
buildMemberDraftColorMap,
|
buildMemberDraftColorMap,
|
||||||
buildMemberDraftSuggestions,
|
buildMemberDraftSuggestions,
|
||||||
|
|
@ -13,7 +14,6 @@ import {
|
||||||
validateMemberNameInline,
|
validateMemberNameInline,
|
||||||
} from '@renderer/components/team/members/MembersEditorSection';
|
} from '@renderer/components/team/members/MembersEditorSection';
|
||||||
import { TeamRosterEditorSection } from '@renderer/components/team/members/TeamRosterEditorSection';
|
import { TeamRosterEditorSection } from '@renderer/components/team/members/TeamRosterEditorSection';
|
||||||
import { SkipPermissionsCheckbox } from '@renderer/components/team/dialogs/SkipPermissionsCheckbox';
|
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { Checkbox } from '@renderer/components/ui/checkbox';
|
import { Checkbox } from '@renderer/components/ui/checkbox';
|
||||||
import { Combobox } from '@renderer/components/ui/combobox';
|
import { Combobox } from '@renderer/components/ui/combobox';
|
||||||
|
|
@ -36,16 +36,16 @@ import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
|
||||||
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
|
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice';
|
||||||
import {
|
import {
|
||||||
isGeminiUiFrozen,
|
isGeminiUiFrozen,
|
||||||
normalizeCreateLaunchProviderForUi,
|
normalizeCreateLaunchProviderForUi,
|
||||||
} from '@renderer/utils/geminiUiFreeze';
|
} from '@renderer/utils/geminiUiFreeze';
|
||||||
import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice';
|
|
||||||
import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability';
|
|
||||||
import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
|
||||||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||||
import { nameColorSet } from '@renderer/utils/projectColor';
|
import { nameColorSet } from '@renderer/utils/projectColor';
|
||||||
|
import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog';
|
||||||
|
import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability';
|
||||||
|
import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Check,
|
Check,
|
||||||
|
|
@ -56,24 +56,25 @@ import {
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { CronScheduleInput } from '../schedule/CronScheduleInput';
|
import { CronScheduleInput } from '../schedule/CronScheduleInput';
|
||||||
|
|
||||||
import { AdvancedCliSection } from './AdvancedCliSection';
|
import { AdvancedCliSection } from './AdvancedCliSection';
|
||||||
import { EffortLevelSelector } from './EffortLevelSelector';
|
import { EffortLevelSelector } from './EffortLevelSelector';
|
||||||
|
import { resolveLaunchDialogPrefill } from './launchDialogPrefill';
|
||||||
import { OptionalSettingsSection } from './OptionalSettingsSection';
|
import { OptionalSettingsSection } from './OptionalSettingsSection';
|
||||||
|
import { ProjectPathSelector } from './ProjectPathSelector';
|
||||||
import {
|
import {
|
||||||
createInitialProviderChecks,
|
createInitialProviderChecks,
|
||||||
failIncompleteProviderChecks,
|
failIncompleteProviderChecks,
|
||||||
getProvisioningProviderBackendSummary,
|
|
||||||
getProvisioningFailureHint,
|
getProvisioningFailureHint,
|
||||||
|
getProvisioningProviderBackendSummary,
|
||||||
|
type ProvisioningProviderCheck,
|
||||||
ProvisioningProviderStatusList,
|
ProvisioningProviderStatusList,
|
||||||
shouldHideProvisioningProviderStatusList,
|
shouldHideProvisioningProviderStatusList,
|
||||||
updateProviderCheck,
|
updateProviderCheck,
|
||||||
type ProvisioningProviderCheck,
|
|
||||||
} from './ProvisioningProviderStatusList';
|
} from './ProvisioningProviderStatusList';
|
||||||
import { ProjectPathSelector } from './ProjectPathSelector';
|
|
||||||
import { resolveLaunchDialogPrefill } from './launchDialogPrefill';
|
|
||||||
import {
|
import {
|
||||||
computeEffectiveTeamModel,
|
computeEffectiveTeamModel,
|
||||||
formatTeamModelSummary,
|
formatTeamModelSummary,
|
||||||
|
|
@ -160,15 +161,7 @@ function getStoredTeamModel(providerId: TeamProviderId): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
function getProviderLabel(providerId: TeamProviderId): string {
|
function getProviderLabel(providerId: TeamProviderId): string {
|
||||||
switch (providerId) {
|
return getCatalogTeamProviderLabel(providerId) ?? 'Anthropic';
|
||||||
case 'codex':
|
|
||||||
return 'Codex';
|
|
||||||
case 'gemini':
|
|
||||||
return 'Gemini';
|
|
||||||
case 'anthropic':
|
|
||||||
default:
|
|
||||||
return 'Anthropic';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveMemberDraftRuntime(
|
function resolveMemberDraftRuntime(
|
||||||
|
|
@ -339,9 +332,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
);
|
);
|
||||||
|
|
||||||
const runtimeBackendSummaryByProvider = useMemo(() => {
|
const runtimeBackendSummaryByProvider = useMemo(() => {
|
||||||
const entries: Array<readonly [TeamProviderId, string | null]> = (
|
const entries: (readonly [TeamProviderId, string | null])[] = (cliStatus?.providers ?? []).map(
|
||||||
cliStatus?.providers ?? []
|
|
||||||
).map(
|
|
||||||
(provider) =>
|
(provider) =>
|
||||||
[
|
[
|
||||||
provider.providerId as TeamProviderId,
|
provider.providerId as TeamProviderId,
|
||||||
|
|
@ -643,10 +634,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
|
|
||||||
const runtimeChangeNotes = useMemo(() => {
|
const runtimeChangeNotes = useMemo(() => {
|
||||||
if (!isLaunch) {
|
if (!isLaunch) {
|
||||||
return [] as Array<{ key: string; memberName: string; message: string }>;
|
return [] as { key: string; memberName: string; message: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const notes: Array<{ key: string; memberName: string; message: string }> = [];
|
const notes: { key: string; memberName: string; message: string }[] = [];
|
||||||
const previousLeadModel = previousLaunchParams?.model?.trim() || '';
|
const previousLeadModel = previousLaunchParams?.model?.trim() || '';
|
||||||
const previousLeadEffort = previousLaunchParams?.effort;
|
const previousLeadEffort = previousLaunchParams?.effort;
|
||||||
const currentLeadDisplayModel = selectedModel.trim() || effectiveLeadRuntimeModel;
|
const currentLeadDisplayModel = selectedModel.trim() || effectiveLeadRuntimeModel;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
|
import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog';
|
||||||
|
import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
import type { TeamProviderId } from '@shared/types';
|
import type { TeamProviderId } from '@shared/types';
|
||||||
import type { CliProviderStatus } from '@shared/types';
|
import type { CliProviderStatus } from '@shared/types';
|
||||||
import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react';
|
|
||||||
|
|
||||||
export type ProvisioningProviderCheckStatus = 'pending' | 'checking' | 'ready' | 'notes' | 'failed';
|
export type ProvisioningProviderCheckStatus = 'pending' | 'checking' | 'ready' | 'notes' | 'failed';
|
||||||
|
|
||||||
|
|
@ -14,15 +16,7 @@ export interface ProvisioningProviderCheck {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getProvisioningProviderLabel(providerId: TeamProviderId): string {
|
export function getProvisioningProviderLabel(providerId: TeamProviderId): string {
|
||||||
switch (providerId) {
|
return getCatalogTeamProviderLabel(providerId) ?? 'Anthropic';
|
||||||
case 'codex':
|
|
||||||
return 'Codex';
|
|
||||||
case 'gemini':
|
|
||||||
return 'Gemini';
|
|
||||||
case 'anthropic':
|
|
||||||
default:
|
|
||||||
return 'Anthropic';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createInitialProviderChecks(
|
export function createInitialProviderChecks(
|
||||||
|
|
@ -150,7 +144,7 @@ function summarizeDetail(detail: string, status: ProvisioningProviderCheckStatus
|
||||||
|
|
||||||
function getDisplayStatusText(check: ProvisioningProviderCheck): string {
|
function getDisplayStatusText(check: ProvisioningProviderCheck): string {
|
||||||
const summary = check.details.find(Boolean)
|
const summary = check.details.find(Boolean)
|
||||||
? summarizeDetail(check.details[0]!, check.status)
|
? summarizeDetail(check.details[0], check.status)
|
||||||
: null;
|
: null;
|
||||||
return summary ?? getStatusLabel(check.status);
|
return summary ?? getStatusLabel(check.status);
|
||||||
}
|
}
|
||||||
|
|
@ -194,7 +188,7 @@ function getStatusColor(status: ProvisioningProviderCheckStatus): string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusIcon({ status }: { status: ProvisioningProviderCheckStatus }): React.JSX.Element {
|
const StatusIcon = ({ status }: { status: ProvisioningProviderCheckStatus }): React.JSX.Element => {
|
||||||
if (status === 'checking') {
|
if (status === 'checking') {
|
||||||
return <Loader2 className="size-3 animate-spin" />;
|
return <Loader2 className="size-3 animate-spin" />;
|
||||||
}
|
}
|
||||||
|
|
@ -205,9 +199,9 @@ function StatusIcon({ status }: { status: ProvisioningProviderCheckStatus }): Re
|
||||||
return <AlertTriangle className="size-3" />;
|
return <AlertTriangle className="size-3" />;
|
||||||
}
|
}
|
||||||
return <span className="inline-block size-1.5 rounded-full bg-current opacity-60" />;
|
return <span className="inline-block size-1.5 rounded-full bg-current opacity-60" />;
|
||||||
}
|
};
|
||||||
|
|
||||||
export function ProvisioningProviderStatusList({
|
export const ProvisioningProviderStatusList = ({
|
||||||
checks,
|
checks,
|
||||||
className = '',
|
className = '',
|
||||||
suppressDetailsMatching,
|
suppressDetailsMatching,
|
||||||
|
|
@ -215,7 +209,7 @@ export function ProvisioningProviderStatusList({
|
||||||
checks: ProvisioningProviderCheck[];
|
checks: ProvisioningProviderCheck[];
|
||||||
className?: string;
|
className?: string;
|
||||||
suppressDetailsMatching?: string | null;
|
suppressDetailsMatching?: string | null;
|
||||||
}): React.JSX.Element | null {
|
}): React.JSX.Element | null => {
|
||||||
if (checks.length === 0) {
|
if (checks.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -253,7 +247,7 @@ export function ProvisioningProviderStatusList({
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export function getProvisioningFailureHint(
|
export function getProvisioningFailureHint(
|
||||||
message: string | null | undefined,
|
message: string | null | undefined,
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,8 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@renderer/components/ui/select';
|
} from '@renderer/components/ui/select';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
|
||||||
import { ChevronDown, ChevronRight, Settings } from 'lucide-react';
|
import { ChevronDown, ChevronRight, Settings } from 'lucide-react';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import type { ToolApprovalSettings, ToolApprovalTimeoutAction } from '@shared/types';
|
import type { ToolApprovalSettings, ToolApprovalTimeoutAction } from '@shared/types';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog';
|
import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,10 @@ import {
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import { ExternalLink } from 'lucide-react';
|
import { ExternalLink } from 'lucide-react';
|
||||||
|
|
||||||
import { CurrentTaskIndicator } from './CurrentTaskIndicator';
|
|
||||||
import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from '../provisioningSteps';
|
import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from '../provisioningSteps';
|
||||||
|
|
||||||
|
import { CurrentTaskIndicator } from './CurrentTaskIndicator';
|
||||||
|
|
||||||
import type { LeadActivityState, TeamTaskWithKanban } from '@shared/types';
|
import type { LeadActivityState, TeamTaskWithKanban } from '@shared/types';
|
||||||
|
|
||||||
interface MemberHoverCardProps {
|
interface MemberHoverCardProps {
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,12 @@ import {
|
||||||
getTeamModelLabel,
|
getTeamModelLabel,
|
||||||
getTeamProviderLabel,
|
getTeamProviderLabel,
|
||||||
} from '@renderer/components/team/dialogs/TeamModelSelector';
|
} from '@renderer/components/team/dialogs/TeamModelSelector';
|
||||||
import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice';
|
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
|
||||||
|
|
||||||
import { MemberCard } from './MemberCard';
|
import { MemberCard } from './MemberCard';
|
||||||
|
|
||||||
|
import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice';
|
||||||
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
|
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
|
||||||
import type {
|
import type {
|
||||||
LeadActivityState,
|
LeadActivityState,
|
||||||
|
|
@ -86,8 +86,7 @@ function areTaskStatusCountsMapsEquivalent(
|
||||||
for (const [key, leftCounts] of left) {
|
for (const [key, leftCounts] of left) {
|
||||||
const rightCounts = right.get(key);
|
const rightCounts = right.get(key);
|
||||||
if (
|
if (
|
||||||
!rightCounts ||
|
leftCounts.pending !== rightCounts?.pending ||
|
||||||
leftCounts.pending !== rightCounts.pending ||
|
|
||||||
leftCounts.inProgress !== rightCounts.inProgress ||
|
leftCounts.inProgress !== rightCounts.inProgress ||
|
||||||
leftCounts.completed !== rightCounts.completed
|
leftCounts.completed !== rightCounts.completed
|
||||||
) {
|
) {
|
||||||
|
|
@ -107,8 +106,7 @@ function areMemberTaskMapsEquivalent(
|
||||||
for (const [key, leftTask] of left) {
|
for (const [key, leftTask] of left) {
|
||||||
const rightTask = right.get(key);
|
const rightTask = right.get(key);
|
||||||
if (
|
if (
|
||||||
!rightTask ||
|
leftTask.id !== rightTask?.id ||
|
||||||
leftTask.id !== rightTask.id ||
|
|
||||||
leftTask.displayId !== rightTask.displayId ||
|
leftTask.displayId !== rightTask.displayId ||
|
||||||
leftTask.subject !== rightTask.subject ||
|
leftTask.subject !== rightTask.subject ||
|
||||||
leftTask.owner !== rightTask.owner ||
|
leftTask.owner !== rightTask.owner ||
|
||||||
|
|
@ -150,8 +148,7 @@ function areMemberSpawnStatusesEquivalent(
|
||||||
for (const [key, leftEntry] of left) {
|
for (const [key, leftEntry] of left) {
|
||||||
const rightEntry = right.get(key);
|
const rightEntry = right.get(key);
|
||||||
if (
|
if (
|
||||||
!rightEntry ||
|
leftEntry.status !== rightEntry?.status ||
|
||||||
leftEntry.status !== rightEntry.status ||
|
|
||||||
leftEntry.launchState !== rightEntry.launchState ||
|
leftEntry.launchState !== rightEntry.launchState ||
|
||||||
leftEntry.error !== rightEntry.error ||
|
leftEntry.error !== rightEntry.error ||
|
||||||
leftEntry.livenessSource !== rightEntry.livenessSource ||
|
leftEntry.livenessSource !== rightEntry.livenessSource ||
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { useStore } from '@renderer/store';
|
|
||||||
import { useTabIdOptional } from '@renderer/contexts/useTabUIContext';
|
|
||||||
|
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog';
|
import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog';
|
||||||
import {
|
import {
|
||||||
|
|
@ -11,6 +8,8 @@ import {
|
||||||
} from '@renderer/components/team/members/SubagentRecentMessagesPreview';
|
} from '@renderer/components/team/members/SubagentRecentMessagesPreview';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
|
import { useTabIdOptional } from '@renderer/contexts/useTabUIContext';
|
||||||
|
import { useStore } from '@renderer/store';
|
||||||
import { asEnhancedChunkArray } from '@renderer/types/data';
|
import { asEnhancedChunkArray } from '@renderer/types/data';
|
||||||
import { enhanceAIGroup } from '@renderer/utils/aiGroupEnhancer';
|
import { enhanceAIGroup } from '@renderer/utils/aiGroupEnhancer';
|
||||||
import { formatDuration } from '@renderer/utils/formatters';
|
import { formatDuration } from '@renderer/utils/formatters';
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { MembersEditorSection } from './MembersEditorSection';
|
|
||||||
import { LeadModelRow } from './LeadModelRow';
|
import { LeadModelRow } from './LeadModelRow';
|
||||||
|
import { MembersEditorSection } from './MembersEditorSection';
|
||||||
|
|
||||||
import type { MemberDraft } from './membersEditorTypes';
|
import type { MemberDraft } from './membersEditorTypes';
|
||||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
|
import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
|
||||||
import { normalizeCreateLaunchProviderForUi } from '@renderer/utils/geminiUiFreeze';
|
|
||||||
import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability';
|
|
||||||
import { serializeChipsWithText } from '@renderer/types/inlineChip';
|
import { serializeChipsWithText } from '@renderer/types/inlineChip';
|
||||||
|
import { normalizeCreateLaunchProviderForUi } from '@renderer/utils/geminiUiFreeze';
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
|
import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability';
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||||
|
|
||||||
import type { MemberDraft } from './membersEditorTypes';
|
import type { MemberDraft } from './membersEditorTypes';
|
||||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||||
import type { EffortLevel, TeamProvisioningMemberInput, TeamProviderId } from '@shared/types';
|
import type { EffortLevel, TeamProviderId, TeamProvisioningMemberInput } from '@shared/types';
|
||||||
|
|
||||||
function isValidMemberName(name: string): boolean {
|
function isValidMemberName(name: string): boolean {
|
||||||
if (name.length < 1 || name.length > 128) return false;
|
if (name.length < 1 || name.length > 128) return false;
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
|
||||||
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
|
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
|
||||||
import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice';
|
import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice';
|
||||||
import { serializeChipsWithText } from '@renderer/types/inlineChip';
|
import { serializeChipsWithText } from '@renderer/types/inlineChip';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
|
|
@ -28,6 +27,7 @@ import { MAX_TEXT_LENGTH } from '@shared/constants';
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import { KNOWN_SLASH_COMMANDS, parseStandaloneSlashCommand } from '@shared/utils/slashCommands';
|
import { KNOWN_SLASH_COMMANDS, parseStandaloneSlashCommand } from '@shared/utils/slashCommands';
|
||||||
import { AlertCircle, Check, ChevronDown, Mic, Paperclip, Search, Send } from 'lucide-react';
|
import { AlertCircle, Check, ChevronDown, Mic, Paperclip, Search, Send } from 'lucide-react';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import type { ActionMode } from '@renderer/components/team/messages/ActionModeSelector';
|
import type { ActionMode } from '@renderer/components/team/messages/ActionModeSelector';
|
||||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,9 @@ import { Checkbox } from '@renderer/components/ui/checkbox';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
import { Filter } from 'lucide-react';
|
import { Filter } from 'lucide-react';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import type { InboxMessage, ResolvedTeamMember } from '@shared/types';
|
import type { InboxMessage, ResolvedTeamMember } from '@shared/types';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,10 @@ import { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded
|
||||||
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
|
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages';
|
import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
|
||||||
import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
|
import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
|
||||||
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
|
||||||
import { shouldExcludeInboxTextFromReplyCandidates } from '@shared/utils/idleNotificationSemantics';
|
import { shouldExcludeInboxTextFromReplyCandidates } from '@shared/utils/idleNotificationSemantics';
|
||||||
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import {
|
import {
|
||||||
CheckCheck,
|
CheckCheck,
|
||||||
ChevronsDownUp,
|
ChevronsDownUp,
|
||||||
|
|
@ -24,6 +23,7 @@ import {
|
||||||
Search,
|
Search,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { ActivityTimeline } from '../activity/ActivityTimeline';
|
import { ActivityTimeline } from '../activity/ActivityTimeline';
|
||||||
import { getThoughtGroupKey, groupTimelineItems } from '../activity/LeadThoughtsGroup';
|
import { getThoughtGroupKey, groupTimelineItems } from '../activity/LeadThoughtsGroup';
|
||||||
|
|
|
||||||
|
|
@ -17,13 +17,13 @@ export const DISPLAY_STEPS = [
|
||||||
|
|
||||||
export const DISPLAY_COMPLETE_STEP_INDEX = DISPLAY_STEPS.length;
|
export const DISPLAY_COMPLETE_STEP_INDEX = DISPLAY_STEPS.length;
|
||||||
|
|
||||||
export type LaunchJoinMilestones = {
|
export interface LaunchJoinMilestones {
|
||||||
expectedTeammateCount: number;
|
expectedTeammateCount: number;
|
||||||
heartbeatConfirmedCount: number;
|
heartbeatConfirmedCount: number;
|
||||||
processOnlyAliveCount: number;
|
processOnlyAliveCount: number;
|
||||||
pendingSpawnCount: number;
|
pendingSpawnCount: number;
|
||||||
failedSpawnCount: number;
|
failedSpawnCount: number;
|
||||||
};
|
}
|
||||||
|
|
||||||
type DisplayStepMilestones = LaunchJoinMilestones & {
|
type DisplayStepMilestones = LaunchJoinMilestones & {
|
||||||
progress: Pick<TeamProvisioningProgress, 'configReady' | 'pid' | 'state'>;
|
progress: Pick<TeamProvisioningProgress, 'configReady' | 'pid' | 'state'>;
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@ import {
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@renderer/components/ui/dialog';
|
} from '@renderer/components/ui/dialog';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
|
||||||
import { AlertTriangle, Clock, Loader2, Terminal } from 'lucide-react';
|
import { AlertTriangle, Clock, Loader2, Terminal } from 'lucide-react';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { CliLogsRichView } from '../CliLogsRichView';
|
import { CliLogsRichView } from '../CliLogsRichView';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { Button } from '@renderer/components/ui/button';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
|
||||||
import { formatNextRun, getCronDescription } from '@renderer/utils/scheduleFormatters';
|
import { formatNextRun, getCronDescription } from '@renderer/utils/scheduleFormatters';
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
|
@ -17,6 +16,7 @@ import {
|
||||||
Trash2,
|
Trash2,
|
||||||
Zap,
|
Zap,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { LaunchTeamDialog } from '../dialogs/LaunchTeamDialog';
|
import { LaunchTeamDialog } from '../dialogs/LaunchTeamDialog';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { memo, useState } from 'react';
|
||||||
|
|
||||||
import { ClaudeLogsSection } from '../ClaudeLogsSection';
|
import { ClaudeLogsSection } from '../ClaudeLogsSection';
|
||||||
import { MessagesPanel } from '../messages/MessagesPanel';
|
import { MessagesPanel } from '../messages/MessagesPanel';
|
||||||
|
|
||||||
import type { MouseEventHandler } from 'react';
|
import type { MouseEventHandler } from 'react';
|
||||||
import type { ComponentProps } from 'react';
|
import type { ComponentProps } from 'react';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -211,23 +211,22 @@ export function getThemedBorder(colorSet: TeamColorSet, isLight: boolean): strin
|
||||||
|
|
||||||
export function scaleColorAlpha(color: string, factor: number): string {
|
export function scaleColorAlpha(color: string, factor: number): string {
|
||||||
const safeFactor = Math.max(0, factor);
|
const safeFactor = Math.max(0, factor);
|
||||||
const rgbaMatch = color.match(
|
const rgbaMatch = /^rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*([0-9]*\.?[0-9]+)\s*\)$/i.exec(
|
||||||
/^rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*([0-9]*\.?[0-9]+)\s*\)$/i
|
color
|
||||||
);
|
);
|
||||||
if (rgbaMatch) {
|
if (rgbaMatch) {
|
||||||
const [, r, g, b, alpha] = rgbaMatch;
|
const [, r, g, b, alpha] = rgbaMatch;
|
||||||
return `rgba(${r}, ${g}, ${b}, ${Number(alpha) * safeFactor})`;
|
return `rgba(${r}, ${g}, ${b}, ${Number(alpha) * safeFactor})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hslaMatch = color.match(
|
const hslaMatch =
|
||||||
/^hsla\(\s*([^,]+)\s*,\s*([^,]+)\s*,\s*([^,]+)\s*,\s*([0-9]*\.?[0-9]+)\s*\)$/i
|
/^hsla\(\s*([^,]+)\s*,\s*([^,]+)\s*,\s*([^,]+)\s*,\s*([0-9]*\.?[0-9]+)\s*\)$/i.exec(color);
|
||||||
);
|
|
||||||
if (hslaMatch) {
|
if (hslaMatch) {
|
||||||
const [, hue, saturation, lightness, alpha] = hslaMatch;
|
const [, hue, saturation, lightness, alpha] = hslaMatch;
|
||||||
return `hsla(${hue}, ${saturation}, ${lightness}, ${Number(alpha) * safeFactor})`;
|
return `hsla(${hue}, ${saturation}, ${lightness}, ${Number(alpha) * safeFactor})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hexAlphaMatch = color.match(/^#([\da-f]{6})([\da-f]{2})$/i);
|
const hexAlphaMatch = /^#([\da-f]{6})([\da-f]{2})$/i.exec(color);
|
||||||
if (hexAlphaMatch) {
|
if (hexAlphaMatch) {
|
||||||
const [, hex, alphaHex] = hexAlphaMatch;
|
const [, hex, alphaHex] = hexAlphaMatch;
|
||||||
const alpha = parseInt(alphaHex, 16) / 255;
|
const alpha = parseInt(alphaHex, 16) / 255;
|
||||||
|
|
|
||||||
|
|
@ -661,6 +661,9 @@ export class TeamGraphAdapter {
|
||||||
kind: 'inbox_message',
|
kind: 'inbox_message',
|
||||||
color: '#cc88ff',
|
color: '#cc88ff',
|
||||||
label,
|
label,
|
||||||
|
preview:
|
||||||
|
getIdleGraphLabel(msg.text ?? '') ??
|
||||||
|
TeamGraphAdapter.#buildParticlePreview(msg.summary ?? cleanText),
|
||||||
reverse: !isIncoming, // ghost→lead edge: incoming = forward, sent = reverse
|
reverse: !isIncoming, // ghost→lead edge: incoming = forward, sent = reverse
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -690,6 +693,9 @@ export class TeamGraphAdapter {
|
||||||
kind: 'inbox_message',
|
kind: 'inbox_message',
|
||||||
color: msg.color ?? '#66ccff',
|
color: msg.color ?? '#66ccff',
|
||||||
label: particleLabel,
|
label: particleLabel,
|
||||||
|
preview:
|
||||||
|
getIdleGraphLabel(msgText) ??
|
||||||
|
TeamGraphAdapter.#buildParticlePreview(msg.summary ?? msg.text),
|
||||||
reverse: isFromTeammate,
|
reverse: isFromTeammate,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -783,6 +789,7 @@ export class TeamGraphAdapter {
|
||||||
kind: 'task_comment',
|
kind: 'task_comment',
|
||||||
color: memberColors.get(newComment.author) ?? '#cc88ff',
|
color: memberColors.get(newComment.author) ?? '#cc88ff',
|
||||||
label: TeamGraphAdapter.#buildParticleLabel(newComment.text, 'comment'),
|
label: TeamGraphAdapter.#buildParticleLabel(newComment.text, 'comment'),
|
||||||
|
preview: TeamGraphAdapter.#buildParticlePreview(newComment.text),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -978,13 +985,7 @@ export class TeamGraphAdapter {
|
||||||
kind: 'inbox' | 'comment',
|
kind: 'inbox' | 'comment',
|
||||||
max = 52
|
max = 52
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
let normalized = text?.replace(/\s+/g, ' ').trim();
|
const normalized = TeamGraphAdapter.#normalizeParticleText(text);
|
||||||
// Clean up raw task ID hashes like "#363e78de done|sent to review" → "done | sent to review"
|
|
||||||
if (normalized) {
|
|
||||||
normalized = normalized.replace(/#[a-f0-9]{6,}\s*/gi, '').trim();
|
|
||||||
// Clean pipe separators
|
|
||||||
normalized = normalized.replace(/\|/g, ' - ');
|
|
||||||
}
|
|
||||||
const prefix = kind === 'comment' ? '\u{1F4AC}' : '\u{2709}';
|
const prefix = kind === 'comment' ? '\u{1F4AC}' : '\u{2709}';
|
||||||
if (!normalized) return prefix;
|
if (!normalized) return prefix;
|
||||||
const clipped =
|
const clipped =
|
||||||
|
|
@ -994,6 +995,22 @@ export class TeamGraphAdapter {
|
||||||
return `${prefix} ${clipped}`;
|
return `${prefix} ${clipped}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static #buildParticlePreview(text: string | undefined, max = 180): string | undefined {
|
||||||
|
const normalized = TeamGraphAdapter.#normalizeParticleText(text);
|
||||||
|
if (!normalized) return undefined;
|
||||||
|
return normalized.length > max
|
||||||
|
? `${normalized.slice(0, Math.max(0, max - 1)).trimEnd()}\u2026`
|
||||||
|
: normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
static #normalizeParticleText(text: string | undefined): string | undefined {
|
||||||
|
let normalized = text?.replace(/\s+/g, ' ').trim();
|
||||||
|
if (!normalized) return normalized;
|
||||||
|
normalized = normalized.replace(/#[a-f0-9]{6,}\s*/gi, '').trim();
|
||||||
|
normalized = normalized.replace(/\|/g, ' - ');
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
static #getMessageParticleKey(msg: InboxMessage): string {
|
static #getMessageParticleKey(msg: InboxMessage): string {
|
||||||
if (msg.messageId && msg.messageId.trim().length > 0) {
|
if (msg.messageId && msg.messageId.trim().length > 0) {
|
||||||
return msg.messageId;
|
return msg.messageId;
|
||||||
|
|
|
||||||
|
|
@ -13,21 +13,21 @@ const DEFAULT_MAX_WIDTH = 500;
|
||||||
const DEFAULT_MIN_HEIGHT = 120;
|
const DEFAULT_MIN_HEIGHT = 120;
|
||||||
const DEFAULT_MAX_HEIGHT = 520;
|
const DEFAULT_MAX_HEIGHT = 520;
|
||||||
|
|
||||||
type HorizontalResizeOptions = {
|
interface HorizontalResizeOptions {
|
||||||
width: number;
|
width: number;
|
||||||
onWidthChange: (width: number) => void;
|
onWidthChange: (width: number) => void;
|
||||||
minWidth?: number;
|
minWidth?: number;
|
||||||
maxWidth?: number;
|
maxWidth?: number;
|
||||||
side: 'left' | 'right';
|
side: 'left' | 'right';
|
||||||
};
|
}
|
||||||
|
|
||||||
type VerticalResizeOptions = {
|
interface VerticalResizeOptions {
|
||||||
height: number;
|
height: number;
|
||||||
onHeightChange: (height: number) => void;
|
onHeightChange: (height: number) => void;
|
||||||
minHeight?: number;
|
minHeight?: number;
|
||||||
maxHeight?: number;
|
maxHeight?: number;
|
||||||
side: 'top' | 'bottom';
|
side: 'top' | 'bottom';
|
||||||
};
|
}
|
||||||
|
|
||||||
type UseResizablePanelOptions = HorizontalResizeOptions | VerticalResizeOptions;
|
type UseResizablePanelOptions = HorizontalResizeOptions | VerticalResizeOptions;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
|
||||||
import { createEncodedTaskReference } from '@renderer/utils/taskReferenceUtils';
|
import { createEncodedTaskReference } from '@renderer/utils/taskReferenceUtils';
|
||||||
import { getTaskDisplayId } from '@shared/utils/taskIdentity';
|
import { getTaskDisplayId } from '@shared/utils/taskIdentity';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||||
import type { GlobalTask, TeamTaskWithKanban } from '@shared/types';
|
import type { GlobalTask, TeamTaskWithKanban } from '@shared/types';
|
||||||
|
|
|
||||||
|
|
@ -1903,9 +1903,14 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
// Sync tab label with the team's display name from config
|
// Sync tab label with the team's display name from config
|
||||||
const displayName = data.config.name || teamName;
|
const displayName = data.config.name || teamName;
|
||||||
const allTabs = get().getAllPaneTabs();
|
const allTabs = get().getAllPaneTabs();
|
||||||
const teamTab = allTabs.find((tab) => tab.type === 'team' && tab.teamName === teamName);
|
const relatedTabs = allTabs.filter(
|
||||||
if (teamTab && teamTab.label !== displayName) {
|
(tab) => (tab.type === 'team' || tab.type === 'graph') && tab.teamName === teamName
|
||||||
get().updateTabLabel(teamTab.id, displayName);
|
);
|
||||||
|
for (const tab of relatedTabs) {
|
||||||
|
const nextLabel = tab.type === 'graph' ? `${displayName} Graph` : displayName;
|
||||||
|
if (tab.label !== nextLabel) {
|
||||||
|
get().updateTabLabel(tab.id, nextLabel);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts?.skipProjectAutoSelect) {
|
if (opts?.skipProjectAutoSelect) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { CliProviderId } from '@shared/types/cliInstaller';
|
|
||||||
import type { TeamProviderId } from '@shared/types';
|
import type { TeamProviderId } from '@shared/types';
|
||||||
|
import type { CliProviderId } from '@shared/types/cliInstaller';
|
||||||
|
|
||||||
export const GEMINI_UI_FROZEN = true;
|
export const GEMINI_UI_FROZEN = true;
|
||||||
export const GEMINI_UI_DISABLED_REASON = 'Gemini in development';
|
export const GEMINI_UI_DISABLED_REASON = 'Gemini in development';
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import type {
|
||||||
IdleNotificationPrimaryKind,
|
IdleNotificationPrimaryKind,
|
||||||
} from '@shared/utils/idleNotificationSemantics';
|
} from '@shared/utils/idleNotificationSemantics';
|
||||||
|
|
||||||
export type ClassifiedIdleNotification = {
|
export interface ClassifiedIdleNotification {
|
||||||
payload: IdleNotificationPayload;
|
payload: IdleNotificationPayload;
|
||||||
primaryKind: IdleNotificationPrimaryKind;
|
primaryKind: IdleNotificationPrimaryKind;
|
||||||
hasPeerSummary: boolean;
|
hasPeerSummary: boolean;
|
||||||
|
|
@ -15,7 +15,7 @@ export type ClassifiedIdleNotification = {
|
||||||
countsAsBootstrapConfirmation: boolean;
|
countsAsBootstrapConfirmation: boolean;
|
||||||
liveDelivery: 'silent_finalize' | 'passive_activity' | 'visible_actionable';
|
liveDelivery: 'silent_finalize' | 'passive_activity' | 'visible_actionable';
|
||||||
uiPresentation: 'heartbeat' | 'peer_summary' | 'interrupted' | 'task_terminal' | 'failure';
|
uiPresentation: 'heartbeat' | 'peer_summary' | 'interrupted' | 'task_terminal' | 'failure';
|
||||||
};
|
}
|
||||||
|
|
||||||
export function classifyIdleNotification(
|
export function classifyIdleNotification(
|
||||||
value: string | Pick<InboxMessage, 'text'> | Record<string, unknown> | IdleNotificationPayload
|
value: string | Pick<InboxMessage, 'text'> | Record<string, unknown> | IdleNotificationPayload
|
||||||
|
|
@ -44,7 +44,7 @@ export function classifyIdleNotification(
|
||||||
: shared.primaryKind;
|
: shared.primaryKind;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...(shared as SharedClassifiedIdleNotification),
|
...shared,
|
||||||
liveDelivery,
|
liveDelivery,
|
||||||
uiPresentation,
|
uiPresentation,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
export {
|
export {
|
||||||
|
getTeamModelUiDisabledReason,
|
||||||
GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL,
|
GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL,
|
||||||
GPT_5_1_CODEX_MINI_UI_DISABLED_REASON,
|
GPT_5_1_CODEX_MINI_UI_DISABLED_REASON,
|
||||||
GPT_5_3_CODEX_SPARK_UI_DISABLED_MODEL,
|
GPT_5_3_CODEX_SPARK_UI_DISABLED_MODEL,
|
||||||
GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON,
|
GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON,
|
||||||
TEAM_MODEL_UI_DISABLED_BADGE_LABEL,
|
|
||||||
getTeamModelUiDisabledReason,
|
|
||||||
isTeamModelUiDisabled,
|
isTeamModelUiDisabled,
|
||||||
normalizeTeamModelForUi,
|
normalizeTeamModelForUi,
|
||||||
|
TEAM_MODEL_UI_DISABLED_BADGE_LABEL,
|
||||||
} from './teamModelCatalog';
|
} from './teamModelCatalog';
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import type { TeamProviderId } from '@shared/types';
|
|
||||||
import {
|
import {
|
||||||
doesTeamModelCarryProviderBrand,
|
doesTeamModelCarryProviderBrand,
|
||||||
getTeamModelLabel,
|
getTeamModelLabel,
|
||||||
getTeamProviderLabel,
|
getTeamProviderLabel,
|
||||||
} from './teamModelCatalog';
|
} from './teamModelCatalog';
|
||||||
|
|
||||||
|
import type { TeamProviderId } from '@shared/types';
|
||||||
|
|
||||||
export function getTeamRuntimeModelLabel(model: string | undefined): string | undefined {
|
export function getTeamRuntimeModelLabel(model: string | undefined): string | undefined {
|
||||||
return getTeamModelLabel(model);
|
return getTeamModelLabel(model);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { isInboxNoiseMessage, parseInboxJson } from './inboxNoise';
|
import { isInboxNoiseMessage, parseInboxJson } from './inboxNoise';
|
||||||
|
|
||||||
export type IdleNotificationPayload = {
|
export interface IdleNotificationPayload {
|
||||||
type: 'idle_notification';
|
type: 'idle_notification';
|
||||||
from?: string;
|
from?: string;
|
||||||
timestamp?: string;
|
timestamp?: string;
|
||||||
|
|
@ -9,17 +9,17 @@ export type IdleNotificationPayload = {
|
||||||
completedTaskId?: string;
|
completedTaskId?: string;
|
||||||
completedStatus?: 'resolved' | 'blocked' | 'failed';
|
completedStatus?: 'resolved' | 'blocked' | 'failed';
|
||||||
failureReason?: string;
|
failureReason?: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
export type IdleNotificationPrimaryKind = 'heartbeat' | 'interrupted' | 'task_terminal' | 'failure';
|
export type IdleNotificationPrimaryKind = 'heartbeat' | 'interrupted' | 'task_terminal' | 'failure';
|
||||||
|
|
||||||
export type ClassifiedIdleNotification = {
|
export interface ClassifiedIdleNotification {
|
||||||
payload: IdleNotificationPayload;
|
payload: IdleNotificationPayload;
|
||||||
primaryKind: IdleNotificationPrimaryKind;
|
primaryKind: IdleNotificationPrimaryKind;
|
||||||
hasPeerSummary: boolean;
|
hasPeerSummary: boolean;
|
||||||
peerSummary: string | null;
|
peerSummary: string | null;
|
||||||
countsAsBootstrapConfirmation: boolean;
|
countsAsBootstrapConfirmation: boolean;
|
||||||
};
|
}
|
||||||
|
|
||||||
function getTrimmedOptionalString(value: unknown): string | null {
|
function getTrimmedOptionalString(value: unknown): string | null {
|
||||||
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ export interface ParsedPermissionRequest {
|
||||||
*/
|
*/
|
||||||
export function parsePermissionRequest(text: string): ParsedPermissionRequest | null {
|
export function parsePermissionRequest(text: string): ParsedPermissionRequest | null {
|
||||||
const parsed = parseInboxJson(text);
|
const parsed = parseInboxJson(text);
|
||||||
if (!parsed || parsed.type !== 'permission_request') return null;
|
if (parsed?.type !== 'permission_request') return null;
|
||||||
|
|
||||||
const requestId = typeof parsed.request_id === 'string' ? parsed.request_id : null;
|
const requestId = typeof parsed.request_id === 'string' ? parsed.request_id : null;
|
||||||
const agentId = typeof parsed.agent_id === 'string' ? parsed.agent_id : null;
|
const agentId = typeof parsed.agent_id === 'string' ? parsed.agent_id : null;
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
const TASK_SINCE_GRACE_MS = 2 * 60 * 1000;
|
const TASK_SINCE_GRACE_MS = 2 * 60 * 1000;
|
||||||
|
|
||||||
type TaskChangeIntervalLike = {
|
interface TaskChangeIntervalLike {
|
||||||
startedAt?: string | null;
|
startedAt?: string | null;
|
||||||
};
|
}
|
||||||
|
|
||||||
type TaskChangeHistoryEventLike = {
|
interface TaskChangeHistoryEventLike {
|
||||||
timestamp?: string | null;
|
timestamp?: string | null;
|
||||||
};
|
}
|
||||||
|
|
||||||
export interface TaskChangeSinceLike<
|
export interface TaskChangeSinceLike<
|
||||||
TInterval extends TaskChangeIntervalLike = TaskChangeIntervalLike,
|
TInterval extends TaskChangeIntervalLike = TaskChangeIntervalLike,
|
||||||
|
|
|
||||||
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(),
|
openTab: vi.fn(),
|
||||||
setActiveTab: vi.fn(),
|
setActiveTab: vi.fn(),
|
||||||
|
updateTabLabel: vi.fn(),
|
||||||
getAllPaneTabs: vi.fn(() => []),
|
getAllPaneTabs: vi.fn(() => []),
|
||||||
warmTaskChangeSummaries: vi.fn(async () => undefined),
|
warmTaskChangeSummaries: vi.fn(async () => undefined),
|
||||||
invalidateTaskChangePresence: vi.fn(),
|
invalidateTaskChangePresence: vi.fn(),
|
||||||
|
|
@ -220,6 +221,35 @@ describe('teamSlice actions', () => {
|
||||||
expect(store.getState().warmTaskChangeSummaries).not.toHaveBeenCalled();
|
expect(store.getState().warmTaskChangeSummaries).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('syncs both team and graph tab labels when the team display name changes', async () => {
|
||||||
|
const store = createSliceStore();
|
||||||
|
const getAllPaneTabs = vi.fn(() => [
|
||||||
|
{ id: 'team-tab', type: 'team', teamName: 'my-team', label: 'my-team' },
|
||||||
|
{ id: 'graph-tab', type: 'graph', teamName: 'my-team', label: 'my-team Graph' },
|
||||||
|
]);
|
||||||
|
const updateTabLabel = vi.fn();
|
||||||
|
|
||||||
|
store.setState({
|
||||||
|
getAllPaneTabs,
|
||||||
|
updateTabLabel,
|
||||||
|
});
|
||||||
|
|
||||||
|
hoisted.getData.mockResolvedValue({
|
||||||
|
teamName: 'my-team',
|
||||||
|
config: { name: 'Northstar', members: [], projectPath: '/repo' },
|
||||||
|
tasks: [],
|
||||||
|
members: [],
|
||||||
|
messages: [],
|
||||||
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||||
|
processes: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
await store.getState().selectTeam('my-team');
|
||||||
|
|
||||||
|
expect(updateTabLabel).toHaveBeenCalledWith('team-tab', 'Northstar');
|
||||||
|
expect(updateTabLabel).toHaveBeenCalledWith('graph-tab', 'Northstar Graph');
|
||||||
|
});
|
||||||
|
|
||||||
it('removes non-selected team cache entries on permanent delete', async () => {
|
it('removes non-selected team cache entries on permanent delete', async () => {
|
||||||
const store = createSliceStore();
|
const store = createSliceStore();
|
||||||
store.setState({
|
store.setState({
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue