diff --git a/docs/research/electron-decoupling.md b/docs/research/electron-decoupling.md
new file mode 100644
index 00000000..30485dfa
--- /dev/null
+++ b/docs/research/electron-decoupling.md
@@ -0,0 +1,163 @@
+# Electron Decoupling Audit
+
+> Дата аудита: 2026-03-08
+
+## Executive Summary
+
+Кодовая база **на 68% независима от Electron**. Только 30 из 692 файлов имеют прямые Electron импорты. Миграция оценивается в 4-6 недель, в основном механический рефакторинг.
+
+## Структура кодовой базы
+
+| Категория | Файлов | % | Electron-зависимость |
+|-----------|:------:|:-:|---------------------|
+| Renderer (React) | 473 | 68% | Нет — pure React, работает в браузере |
+| Main (Services) | ~140 | 20% | Минимальная — pure Node.js |
+| Main (IPC handlers) | 22 | 3% | Да — `ipcMain.handle()` |
+| Main (Electron APIs) | 8 | 1% | Да — BrowserWindow, app, dialog, shell |
+| Preload | 2 | 0.3% | Да — contextBridge |
+| Shared (types/utils) | 45 | 6.5% | Нет — полностью agnostic |
+
+## Уже реализованная инфраструктура отвязки
+
+### 1. HTTP Server (Fastify)
+- **Файл**: `src/main/services/infrastructure/HttpServer.ts` (179 LOC)
+- Работает на `127.0.0.1:3456`
+- Раздаёт статику + API роуты
+- CORS настроен для standalone режима
+
+### 2. HTTP Routes (80% покрытие)
+- **Директория**: `src/main/http/`
+- 13 файлов роутов, дублируют IPC handlers:
+ - projects, sessions, search, subagents
+ - config, notifications, utility, validation
+ - ssh, updater, events, schedule
+
+### 3. HttpAPIClient
+- **Файл**: `src/renderer/api/httpClient.ts` (400+ LOC)
+- Полная имплементация `ElectronAPI` интерфейса
+- EventSource (SSE) для real-time событий
+- Fetch для request/response
+
+### 4. Unified API Proxy
+- **Файл**: `src/renderer/api/index.ts`
+- Автоматически переключается между:
+ - `window.electronAPI` (Electron mode)
+ - `HttpAPIClient` (browser mode)
+- Прозрачно для всех компонентов
+
+### 5. Standalone Entry Point
+- **Файл**: `src/main/standalone.ts`
+- Запускает HTTP сервер без Electron
+- Стабит UpdaterService, SshConnectionManager
+- Протестирован и работает
+
+## 30 файлов с Electron импортами
+
+### ipcMain (22 файла)
+```
+src/main/ipc/handlers.ts — оркестратор
+src/main/ipc/cliInstaller.ts
+src/main/ipc/config.ts — + dialog, BrowserWindow
+src/main/ipc/context.ts
+src/main/ipc/editor.ts — + BrowserWindow
+src/main/ipc/extensions.ts
+src/main/ipc/httpServer.ts
+src/main/ipc/notifications.ts
+src/main/ipc/projects.ts
+src/main/ipc/rendererLogs.ts
+src/main/ipc/review.ts
+src/main/ipc/schedule.ts
+src/main/ipc/search.ts
+src/main/ipc/sessions.ts
+src/main/ipc/ssh.ts
+src/main/ipc/subagents.ts
+src/main/ipc/teams.ts — + BrowserWindow, Notification
+src/main/ipc/terminal.ts
+src/main/ipc/updater.ts
+src/main/ipc/utility.ts — + shell
+src/main/ipc/validation.ts
+src/main/ipc/window.ts — + app, BrowserWindow
+```
+
+### Другие Electron API
+```
+src/main/index.ts — app, BrowserWindow, ipcMain
+src/main/utils/pathDecoder.ts — app.getPath('home')
+src/main/services/infrastructure/UpdaterService.ts — BrowserWindow, electron-updater
+src/main/services/infrastructure/NotificationManager.ts — Notification
+src/preload/index.ts — contextBridge, ipcRenderer
+```
+
+## Замена каждого Electron API
+
+| API | Файлов | Замена | Усилия |
+|-----|:------:|--------|:------:|
+| `ipcMain.handle()` | 22 | HTTP роуты (80% уже есть) | 3-4ч |
+| `BrowserWindow` | 6 | Убрать (браузер сам управляет) | 1-2ч |
+| `app` lifecycle | 2 | Прямой запуск Node.js сервера | 4-5ч |
+| `dialog.showOpenDialog()` | 1 | HTML `` / env var | 3ч |
+| `shell.openExternal/Path` | 1 | `window.open()` / убрать | 1ч |
+| `Notification` | 2 | Browser Notification API | 2ч |
+| `electron-updater` | 1 | GitHub releases redirect | 2ч |
+| `contextBridge` | 1 | Убрать полностью | 1ч |
+
+## Оценка миграции
+
+| Метрика | Значение |
+|---------|----------|
+| Сложность | 6/10 — механический рефакторинг |
+| Объём работы | 4-6 недель |
+| Вероятность успеха | 95% |
+| Уверенность в оценке | 9/10 |
+
+## Фазы миграции
+
+### Phase 1: Setup (2-3 дня)
+- Рефакторинг `src/main/index.ts` в pure HTTP server bootstrap
+- Удаление Electron app lifecycle
+- HTTP server как primary entry point
+
+### Phase 2: IPC → HTTP (3-4 дня)
+- Завершить оставшиеся HTTP роуты (~20%)
+- Удалить все `ipcMain.handle()` вызовы
+- SSE для event delivery
+
+### Phase 3: Desktop-Only Features (2-3 дня)
+- Убрать auto-updater → version check API
+- Убрать dialog → env var / HTML input
+- Убрать shell → client-side links
+- Browser Notification API
+
+### Phase 4: Build System (2-3 дня)
+- `electron-vite` → стандартный Vite
+- Убрать preload bundling
+- Docker build config
+
+### Phase 5: Testing (2-3 дня)
+- HTTP endpoint coverage
+- Browser compatibility
+- Docker deployment
+
+## Что нельзя заменить (Electron-only)
+- Auto-update бинарных патчей → GitHub releases
+- System tray → убрать
+- Native menu → web context menus
+- System hotkeys → browser keyboard events
+- Native file dialogs → HTML file input
+
+## Build изменения
+
+**Текущий:**
+```json
+"dev": "electron-vite dev",
+"build": "electron-vite build",
+"dist": "electron-builder --mac --win --linux"
+```
+
+**После миграции:**
+```json
+"dev": "tsx src/main/standalone.ts & vite",
+"build": "vite build && tsc --noEmit",
+"start": "node dist/main/index.cjs",
+"docker": "docker build -t claude-teams ."
+```
diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts
index 3678405f..d064cc25 100644
--- a/src/main/services/team/TeamProvisioningService.ts
+++ b/src/main/services/team/TeamProvisioningService.ts
@@ -2852,6 +2852,45 @@ export class TeamProvisioningService {
}
}
+ /**
+ * Create an InboxMessage from assistant text and push it into the live cache.
+ * Used for both pre-ready (provisioning) and post-ready assistant text.
+ * Emits a coalesced `lead-message` event for renderer refresh.
+ */
+ private pushLiveLeadTextMessage(run: ProvisioningRun, cleanText: string): void {
+ run.leadMsgSeq += 1;
+ const leadName =
+ run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead';
+ const messageId = `lead-turn-${run.runId}-${run.leadMsgSeq}`;
+ // Attach accumulated tool call details from preceding tool_use messages, then reset.
+ const toolCalls = run.pendingToolCalls.length > 0 ? [...run.pendingToolCalls] : undefined;
+ const toolSummary = toolCalls ? formatToolSummaryFromCalls(toolCalls) : undefined;
+ run.pendingToolCalls = [];
+ const leadMsg: InboxMessage = {
+ from: leadName,
+ text: cleanText,
+ timestamp: nowIso(),
+ read: true,
+ summary: cleanText.length > 60 ? cleanText.slice(0, 57) + '...' : cleanText,
+ messageId,
+ source: 'lead_process',
+ toolSummary,
+ toolCalls,
+ };
+ this.pushLiveLeadProcessMessage(run.teamName, leadMsg);
+
+ // Coalesced refresh: at most one event per LEAD_TEXT_EMIT_THROTTLE_MS per team.
+ const now = Date.now();
+ if (now - run.lastLeadTextEmitMs >= TeamProvisioningService.LEAD_TEXT_EMIT_THROTTLE_MS) {
+ run.lastLeadTextEmitMs = now;
+ this.teamChangeEmitter?.({
+ type: 'lead-message',
+ teamName: run.teamName,
+ detail: 'lead-text',
+ });
+ }
+ }
+
/**
* Stop the running process for a team. No-op if team is not running.
*/
@@ -2946,41 +2985,16 @@ export class TeamProvisioningService {
) {
const cleanText = stripAgentBlocks(text).trim();
if (cleanText.length > 0) {
- run.leadMsgSeq += 1;
- const leadName =
- run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name ||
- 'team-lead';
- const messageId = `lead-turn-${run.runId}-${run.leadMsgSeq}`;
- // Attach accumulated tool call details from preceding tool_use messages, then reset.
- const toolCalls =
- run.pendingToolCalls.length > 0 ? [...run.pendingToolCalls] : undefined;
- const toolSummary = toolCalls ? formatToolSummaryFromCalls(toolCalls) : undefined;
- run.pendingToolCalls = [];
- const leadMsg: InboxMessage = {
- from: leadName,
- text: cleanText,
- timestamp: nowIso(),
- read: true,
- summary: cleanText.length > 60 ? cleanText.slice(0, 57) + '...' : cleanText,
- messageId,
- source: 'lead_process',
- toolSummary,
- toolCalls,
- };
- this.pushLiveLeadProcessMessage(run.teamName, leadMsg);
-
- const now = Date.now();
- if (
- now - run.lastLeadTextEmitMs >=
- TeamProvisioningService.LEAD_TEXT_EMIT_THROTTLE_MS
- ) {
- run.lastLeadTextEmitMs = now;
- this.teamChangeEmitter?.({
- type: 'inbox',
- teamName: run.teamName,
- detail: 'lead-text',
- });
- }
+ this.pushLiveLeadTextMessage(run, cleanText);
+ }
+ }
+ } else {
+ // Pre-ready: also push to live cache so Messages shows early narration
+ // once team:getData becomes readable. The banner still uses provisioningOutputParts.
+ if (!run.silentUserDmForward && !hasSendMessageToUser) {
+ const cleanText = stripAgentBlocks(text).trim();
+ if (cleanText.length > 0) {
+ this.pushLiveLeadTextMessage(run, cleanText);
}
}
}
@@ -2988,19 +3002,18 @@ export class TeamProvisioningService {
// Accumulate tool_use details from tool-only messages (text + tool_use are separate in stream-json).
// These details will be attached to the next text message as toolCalls/toolSummary.
- if (run.provisioningComplete) {
- for (const block of content ?? []) {
- if (
- block?.type === 'tool_use' &&
- typeof block.name === 'string' &&
- block.name !== 'SendMessage'
- ) {
- const input = (block.input ?? {}) as Record;
- run.pendingToolCalls.push({
- name: block.name,
- preview: extractToolPreview(block.name, input),
- });
- }
+ // Works in both pre-ready and post-ready phases so early live messages get tool metadata.
+ for (const block of content ?? []) {
+ if (
+ block?.type === 'tool_use' &&
+ typeof block.name === 'string' &&
+ block.name !== 'SendMessage'
+ ) {
+ const input = (block.input ?? {}) as Record;
+ run.pendingToolCalls.push({
+ name: block.name,
+ preview: extractToolPreview(block.name, input),
+ });
}
}
@@ -3009,7 +3022,8 @@ export class TeamProvisioningService {
// (e.g., after session resume when teamContext is lost). We intercept the tool calls
// from stdout and persist them to sentMessages.json under the correct team name,
// ensuring the UI and notifications show the right team.
- if (run.provisioningComplete && !run.silentUserDmForward) {
+ // Works in both pre-ready and post-ready phases so provisioning-time user DMs are captured.
+ if (!run.silentUserDmForward) {
this.captureSendMessageToUser(run, content ?? []);
}
diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts
index 2de404d0..d1debcd3 100644
--- a/src/renderer/store/index.ts
+++ b/src/renderer/store/index.ts
@@ -132,7 +132,7 @@ export function initializeNotificationListeners(): () => void {
});
const pendingSessionRefreshTimers = new Map>();
const pendingProjectRefreshTimers = new Map>();
- let teamRefreshTimer: ReturnType | null = null;
+ let teamRefreshTimers = new Map>();
let teamListRefreshTimer: ReturnType | null = null;
let globalTasksRefreshTimer: ReturnType | null = null;
@@ -400,6 +400,24 @@ export function initializeNotificationListeners(): () => void {
return;
}
+ // Live lead-message events: only refresh the visible team detail, not team/task lists.
+ // This keeps the refresh lightweight and prevents one noisy team from starving another.
+ if (event.type === 'lead-message') {
+ if (!event?.teamName || !isTeamVisibleInAnyPane(event.teamName)) {
+ return;
+ }
+ if (teamRefreshTimers.has(event.teamName)) {
+ return;
+ }
+ const timer = setTimeout(() => {
+ teamRefreshTimers.delete(event.teamName);
+ const current = useStore.getState();
+ void current.refreshTeamData(event.teamName);
+ }, TEAM_REFRESH_THROTTLE_MS);
+ teamRefreshTimers.set(event.teamName, timer);
+ return;
+ }
+
// Throttled refresh of summary list (keeps TeamListView current without flooding).
if (!teamListRefreshTimer) {
teamListRefreshTimer = setTimeout(() => {
@@ -420,26 +438,25 @@ export function initializeNotificationListeners(): () => void {
return;
}
- // Throttle (not debounce): keep at most one pending detail refresh.
+ // Per-team throttle (not debounce): keep at most one pending detail refresh per team.
// Debounce would delay indefinitely while inbox messages keep arriving.
- if (teamRefreshTimer) {
+ if (teamRefreshTimers.has(event.teamName)) {
return;
}
- teamRefreshTimer = setTimeout(() => {
- teamRefreshTimer = null;
+ const timer = setTimeout(() => {
+ teamRefreshTimers.delete(event.teamName);
const current = useStore.getState();
void current.refreshTeamData(event.teamName);
}, TEAM_REFRESH_THROTTLE_MS);
+ teamRefreshTimers.set(event.teamName, timer);
});
if (typeof cleanup === 'function') {
cleanupFns.push(() => {
cleanup();
- if (teamRefreshTimer) {
- clearTimeout(teamRefreshTimer);
- teamRefreshTimer = null;
- }
+ for (const t of teamRefreshTimers.values()) clearTimeout(t);
+ teamRefreshTimers = new Map();
if (teamListRefreshTimer) {
clearTimeout(teamListRefreshTimer);
teamListRefreshTimer = null;
diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts
index 48409e57..681334e4 100644
--- a/src/renderer/store/slices/teamSlice.ts
+++ b/src/renderer/store/slices/teamSlice.ts
@@ -801,7 +801,24 @@ export const createTeamSlice: StateCreator = (set,
: error instanceof Error
? error.message
: 'Failed to refresh team data';
+
+ // During provisioning, team:getData may not be readable yet.
+ // Preserve existing data instead of showing a fatal error.
+ if (msg === 'TEAM_PROVISIONING' || msg.includes('TEAM_PROVISIONING')) {
+ logger.debug(`refreshTeamData(${teamName}) skipped: team is still provisioning`);
+ set({ selectedTeamError: null });
+ return;
+ }
+
logger.warn(`refreshTeamData(${teamName}) failed: ${msg}`);
+
+ // Non-destructive: if we already have data, keep it visible.
+ // Only set error when there's nothing to show.
+ if (get().selectedTeamData) {
+ logger.debug(`refreshTeamData(${teamName}) preserving existing data after transient error`);
+ set({ selectedTeamError: null });
+ return;
+ }
set({ selectedTeamError: msg });
}
},
diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts
index 98dbc85e..614b51de 100644
--- a/src/shared/types/team.ts
+++ b/src/shared/types/team.ts
@@ -347,7 +347,7 @@ export interface LeadContextUsage {
}
export interface TeamChangeEvent {
- type: 'config' | 'inbox' | 'task' | 'lead-activity' | 'lead-context' | 'process';
+ type: 'config' | 'inbox' | 'task' | 'lead-activity' | 'lead-context' | 'lead-message' | 'process';
teamName: string;
detail?: string;
}
diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts
index d39c516b..55dfe7fb 100644
--- a/test/main/ipc/teams.test.ts
+++ b/test/main/ipc/teams.test.ts
@@ -318,6 +318,53 @@ describe('ipc teams handlers', () => {
expect(sources.filter((s) => s === 'lead_session')).toHaveLength(1);
});
+ it('merges early live messages before durable lead_session backfill exists', async () => {
+ // Simulate: team just became readable but lead_session JSONL hasn't been written yet.
+ // Only live in-memory messages exist from the provisioning process.
+ service.getTeamData.mockResolvedValueOnce({
+ teamName: 'my-team',
+ config: { name: 'My Team' },
+ tasks: [],
+ members: [],
+ messages: [], // No durable messages yet
+ kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
+ processes: [],
+ });
+ provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([
+ {
+ from: 'team-lead',
+ text: 'Команда создана. Запускаю тиммейтов.',
+ timestamp: '2026-02-23T10:00:00.000Z',
+ read: true,
+ source: 'lead_process' as const,
+ messageId: 'lead-turn-run-1-1',
+ },
+ {
+ from: 'team-lead',
+ text: 'All teammates online!',
+ timestamp: '2026-02-23T10:00:01.000Z',
+ read: true,
+ source: 'lead_process' as const,
+ messageId: 'lead-turn-run-1-2',
+ to: 'user',
+ },
+ ]);
+
+ const getDataHandler = handlers.get(TEAM_GET_DATA)!;
+ const result = (await getDataHandler({} as never, 'my-team')) as {
+ success: boolean;
+ data: { messages: { source?: string; text: string }[] };
+ };
+ expect(result.success).toBe(true);
+ // Both live messages should appear since there's no durable backfill yet
+ // Sorted by timestamp descending (newest first)
+ expect(result.data.messages).toHaveLength(2);
+ expect(result.data.messages[0].source).toBe('lead_process');
+ expect(result.data.messages[0].text).toBe('All teammates online!');
+ expect(result.data.messages[1].source).toBe('lead_process');
+ expect(result.data.messages[1].text).toBe('Команда создана. Запускаю тиммейтов.');
+ });
+
it('keeps TEAM_GET_DATA read-only and never triggers reconcile side effects', async () => {
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
const result = (await getDataHandler({} as never, 'my-team')) as {
diff --git a/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts b/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts
new file mode 100644
index 00000000..514c914d
--- /dev/null
+++ b/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts
@@ -0,0 +1,298 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const hoisted = vi.hoisted(() => {
+ const files = new Map();
+
+ const norm = (p: string): string => p.replace(/\\/g, '/');
+
+ const stat = vi.fn(async (filePath: string) => {
+ const data = files.get(norm(filePath));
+ if (data === undefined) {
+ const error = new Error('ENOENT') as NodeJS.ErrnoException;
+ error.code = 'ENOENT';
+ throw error;
+ }
+ return { isFile: () => true, size: Buffer.byteLength(data, 'utf8') };
+ });
+
+ const readFile = vi.fn(async (filePath: string) => {
+ const data = files.get(norm(filePath));
+ if (data === undefined) {
+ const error = new Error('ENOENT') as NodeJS.ErrnoException;
+ error.code = 'ENOENT';
+ throw error;
+ }
+ return data;
+ });
+
+ const atomicWrite = vi.fn(async (filePath: string, data: string) => {
+ files.set(norm(filePath), data);
+ });
+
+ return {
+ files,
+ stat,
+ readFile,
+ atomicWrite,
+ appendSentMessage: vi.fn((teamName: string, message: Record) => {
+ const p = `/mock/teams/${teamName}/sentMessages.json`;
+ const current = files.get(p);
+ const rows = current ? (JSON.parse(current) as unknown[]) : [];
+ rows.push(message);
+ files.set(p, JSON.stringify(rows));
+ return message;
+ }),
+ };
+});
+
+vi.mock('fs', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ promises: { ...actual.promises, stat: hoisted.stat, readFile: hoisted.readFile },
+ };
+});
+
+vi.mock('../../../../src/main/services/team/atomicWrite', () => ({
+ atomicWriteAsync: hoisted.atomicWrite,
+}));
+
+vi.mock('../../../../src/main/utils/pathDecoder', async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, getTeamsBasePath: () => '/mock/teams' };
+});
+
+vi.mock('agent-teams-controller', () => ({
+ createController: ({ teamName }: { teamName: string }) => ({
+ messages: {
+ appendSentMessage: (message: Record) =>
+ hoisted.appendSentMessage(teamName, message),
+ },
+ }),
+}));
+
+import type { TeamChangeEvent } from '@shared/types/team';
+import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService';
+
+function seedConfig(teamName: string): void {
+ hoisted.files.set(
+ `/mock/teams/${teamName}/config.json`,
+ JSON.stringify({
+ name: 'My Team',
+ members: [{ name: 'team-lead', agentType: 'team-lead' }],
+ })
+ );
+}
+
+interface RunLike {
+ runId: string;
+ teamName: string;
+ provisioningComplete: boolean;
+ leadMsgSeq: number;
+ pendingToolCalls: { name: string; preview: string }[];
+ lastLeadTextEmitMs: number;
+ leadRelayCapture: null;
+ silentUserDmForward: null;
+ suppressPostCompactReminderOutput?: boolean;
+ child: Record | null;
+ processKilled: boolean;
+ cancelRequested: boolean;
+ provisioningOutputParts: string[];
+ request: { members: { name: string; role?: string }[] };
+}
+
+/**
+ * Attach a run to the service internals. `provisioningComplete` defaults to false
+ * (pre-ready) to test the early message pipeline.
+ */
+function attachRun(
+ service: TeamProvisioningService,
+ teamName: string,
+ opts?: { provisioningComplete?: boolean }
+): RunLike {
+ const runId = 'run-1';
+ const run: RunLike = {
+ runId,
+ teamName,
+ provisioningComplete: opts?.provisioningComplete ?? false,
+ leadMsgSeq: 0,
+ pendingToolCalls: [],
+ lastLeadTextEmitMs: 0,
+ leadRelayCapture: null,
+ silentUserDmForward: null,
+ child: { stdin: { writable: true, write: vi.fn(), end: vi.fn() } },
+ processKilled: false,
+ cancelRequested: false,
+ provisioningOutputParts: [],
+ request: { members: [{ name: 'team-lead', role: 'Team Lead' }] },
+ };
+
+ (service as unknown as { activeByTeam: Map }).activeByTeam.set(teamName, runId);
+ (service as unknown as { runs: Map }).runs.set(runId, run);
+
+ return run;
+}
+
+function callHandleStreamJsonMessage(
+ service: TeamProvisioningService,
+ run: RunLike,
+ msg: Record
+): void {
+ (service as unknown as { handleStreamJsonMessage: (r: unknown, m: unknown) => void })
+ .handleStreamJsonMessage(run, msg);
+}
+
+describe('TeamProvisioningService pre-ready live messages', () => {
+ beforeEach(() => {
+ hoisted.files.clear();
+ hoisted.appendSentMessage.mockClear();
+ });
+
+ it('pre-ready assistant text is added to liveLeadProcessMessages', () => {
+ const service = new TeamProvisioningService();
+ seedConfig('my-team');
+ const run = attachRun(service, 'my-team', { provisioningComplete: false });
+
+ callHandleStreamJsonMessage(service, run, {
+ type: 'assistant',
+ content: [{ type: 'text', text: 'Команда создана. Запускаю всех тиммейтов параллельно.' }],
+ });
+
+ const live = service.getLiveLeadProcessMessages('my-team');
+ expect(live).toHaveLength(1);
+ expect(live[0].text).toBe('Команда создана. Запускаю всех тиммейтов параллельно.');
+ expect(live[0].source).toBe('lead_process');
+ expect(live[0].messageId).toMatch(/^lead-turn-run-1-1$/);
+
+ // Also still in provisioningOutputParts for the banner
+ expect(run.provisioningOutputParts).toHaveLength(1);
+ });
+
+ it('emits lead-message event type (not inbox)', () => {
+ const service = new TeamProvisioningService();
+ seedConfig('my-team');
+ const emitter = vi.fn<(event: TeamChangeEvent) => void>();
+ service.setTeamChangeEmitter(emitter);
+ const run = attachRun(service, 'my-team', { provisioningComplete: false });
+
+ callHandleStreamJsonMessage(service, run, {
+ type: 'assistant',
+ content: [{ type: 'text', text: 'Launching teammates now.' }],
+ });
+
+ expect(emitter).toHaveBeenCalledTimes(1);
+ expect(emitter).toHaveBeenCalledWith(
+ expect.objectContaining({ type: 'lead-message', teamName: 'my-team' })
+ );
+ });
+
+ it('coalesces rapid emissions via LEAD_TEXT_EMIT_THROTTLE_MS', () => {
+ const service = new TeamProvisioningService();
+ seedConfig('my-team');
+ const emitter = vi.fn<(event: TeamChangeEvent) => void>();
+ service.setTeamChangeEmitter(emitter);
+ const run = attachRun(service, 'my-team', { provisioningComplete: false });
+
+ // First message: should emit
+ callHandleStreamJsonMessage(service, run, {
+ type: 'assistant',
+ content: [{ type: 'text', text: 'Message 1' }],
+ });
+ expect(emitter).toHaveBeenCalledTimes(1);
+
+ // Second message immediately after: should be coalesced (not emitted again)
+ callHandleStreamJsonMessage(service, run, {
+ type: 'assistant',
+ content: [{ type: 'text', text: 'Message 2' }],
+ });
+ expect(emitter).toHaveBeenCalledTimes(1); // Still 1
+
+ // Messages are still cached though
+ const live = service.getLiveLeadProcessMessages('my-team');
+ expect(live).toHaveLength(2);
+ });
+
+ it('early live messages carry toolCalls and toolSummary', () => {
+ const service = new TeamProvisioningService();
+ seedConfig('my-team');
+ const run = attachRun(service, 'my-team', { provisioningComplete: false });
+
+ // First: tool_use message (no text)
+ callHandleStreamJsonMessage(service, run, {
+ type: 'assistant',
+ content: [
+ {
+ type: 'tool_use',
+ name: 'TeamCreate',
+ input: { team_name: 'super-team', description: 'test' },
+ },
+ ],
+ });
+
+ // Then: text message — should pick up pending tool calls
+ callHandleStreamJsonMessage(service, run, {
+ type: 'assistant',
+ content: [{ type: 'text', text: 'Team created successfully.' }],
+ });
+
+ const live = service.getLiveLeadProcessMessages('my-team');
+ expect(live).toHaveLength(1);
+ expect(live[0].toolCalls).toBeDefined();
+ expect(live[0].toolCalls).toHaveLength(1);
+ expect(live[0].toolCalls![0].name).toBe('TeamCreate');
+ expect(live[0].toolSummary).toBeDefined();
+ });
+
+ it('provisioning-time SendMessage(to:user) is captured and persisted', () => {
+ const service = new TeamProvisioningService();
+ seedConfig('my-team');
+ const run = attachRun(service, 'my-team', { provisioningComplete: false });
+
+ callHandleStreamJsonMessage(service, run, {
+ type: 'assistant',
+ content: [
+ {
+ type: 'tool_use',
+ name: 'SendMessage',
+ input: {
+ type: 'message',
+ recipient: 'user',
+ content: 'All teammates online!',
+ summary: 'Team ready',
+ },
+ },
+ ],
+ });
+
+ const live = service.getLiveLeadProcessMessages('my-team');
+ expect(live).toHaveLength(1);
+ expect(live[0].to).toBe('user');
+ expect(live[0].text).toBe('All teammates online!');
+ expect(live[0].source).toBe('lead_process');
+
+ // Also persisted to sentMessages.json
+ expect(hoisted.appendSentMessage).toHaveBeenCalledTimes(1);
+ });
+
+ it('post-ready path also uses the unified helper', () => {
+ const service = new TeamProvisioningService();
+ seedConfig('my-team');
+ const emitter = vi.fn<(event: TeamChangeEvent) => void>();
+ service.setTeamChangeEmitter(emitter);
+ const run = attachRun(service, 'my-team', { provisioningComplete: true });
+
+ callHandleStreamJsonMessage(service, run, {
+ type: 'assistant',
+ content: [{ type: 'text', text: 'Assigning tasks now.' }],
+ });
+
+ const live = service.getLiveLeadProcessMessages('my-team');
+ expect(live).toHaveLength(1);
+ expect(live[0].source).toBe('lead_process');
+
+ // Post-ready also emits lead-message
+ expect(emitter).toHaveBeenCalledWith(
+ expect.objectContaining({ type: 'lead-message', teamName: 'my-team' })
+ );
+ });
+});
diff --git a/test/renderer/store/teamChangeThrottle.test.ts b/test/renderer/store/teamChangeThrottle.test.ts
index 1e2671a7..c48bfd34 100644
--- a/test/renderer/store/teamChangeThrottle.test.ts
+++ b/test/renderer/store/teamChangeThrottle.test.ts
@@ -1,7 +1,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const hoisted = vi.hoisted(() => ({
- onTeamChangeCb: null as ((event: unknown, data: { teamName: string }) => void) | null,
+ onTeamChangeCb: null as
+ | ((event: unknown, data: { type?: string; teamName: string }) => void)
+ | null,
onProvisioningProgressCb: null as
| ((event: unknown, data: { runId: string; teamName: string }) => void)
| null,
@@ -135,4 +137,71 @@ describe('team change throttling', () => {
await vi.advanceTimersByTimeAsync(800);
expect(refreshTeamDataSpy).toHaveBeenCalledTimes(2);
});
+
+ it('lead-message refreshes detail only, not team list or tasks', async () => {
+ const state = useStore.getState();
+ const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams');
+ const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
+
+ // Emit a lead-message event
+ hoisted.onTeamChangeCb?.({}, { type: 'lead-message', teamName: 'my-team' });
+
+ // Should NOT trigger fetchTeams
+ await vi.advanceTimersByTimeAsync(2100);
+ expect(fetchTeamsSpy).not.toHaveBeenCalled();
+
+ // Should trigger refreshTeamData at 800ms
+ expect(refreshTeamDataSpy).toHaveBeenCalledTimes(1);
+ expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team');
+ });
+
+ it('lead-message does not call fetchAllTasks', async () => {
+ const fetchAllTasksSpy = vi.fn(async () => undefined);
+ useStore.setState({ fetchAllTasks: fetchAllTasksSpy } as never);
+
+ hoisted.onTeamChangeCb?.({}, { type: 'lead-message', teamName: 'my-team' });
+
+ await vi.advanceTimersByTimeAsync(2100);
+ expect(fetchAllTasksSpy).not.toHaveBeenCalled();
+ });
+
+ it('per-team throttling: busy team does not block another visible team', async () => {
+ // Add a second visible team tab
+ useStore.setState({
+ paneLayout: {
+ focusedPaneId: 'p1',
+ panes: [
+ {
+ id: 'p1',
+ widthFraction: 0.5,
+ tabs: [{ id: 't1', type: 'team', teamName: 'my-team', label: 'my-team' }],
+ activeTabId: 't1',
+ },
+ {
+ id: 'p2',
+ widthFraction: 0.5,
+ tabs: [{ id: 't2', type: 'team', teamName: 'other-team', label: 'other-team' }],
+ activeTabId: 't2',
+ },
+ ],
+ },
+ } as never);
+
+ const state = useStore.getState();
+ const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
+
+ // Fire rapid events for my-team (throttled)
+ hoisted.onTeamChangeCb?.({}, { type: 'lead-message', teamName: 'my-team' });
+ hoisted.onTeamChangeCb?.({}, { type: 'lead-message', teamName: 'my-team' });
+
+ // Fire event for other-team — should NOT be blocked by my-team's throttle
+ hoisted.onTeamChangeCb?.({}, { type: 'lead-message', teamName: 'other-team' });
+
+ await vi.advanceTimersByTimeAsync(800);
+
+ // Both teams should get exactly 1 refresh each
+ expect(refreshTeamDataSpy).toHaveBeenCalledTimes(2);
+ expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team');
+ expect(refreshTeamDataSpy).toHaveBeenCalledWith('other-team');
+ });
});
diff --git a/test/renderer/store/teamSlice.test.ts b/test/renderer/store/teamSlice.test.ts
index daf850b3..58a91192 100644
--- a/test/renderer/store/teamSlice.test.ts
+++ b/test/renderer/store/teamSlice.test.ts
@@ -130,4 +130,126 @@ describe('teamSlice actions', () => {
'Failed to update task status (possible agent conflict).'
);
});
+
+ describe('refreshTeamData provisioning safety', () => {
+ it('does not set fatal error on TEAM_PROVISIONING', async () => {
+ const store = createSliceStore();
+ // First, select a team so selectedTeamName is set
+ store.setState({
+ selectedTeamName: 'my-team',
+ selectedTeamData: {
+ teamName: 'my-team',
+ config: { name: 'My Team' },
+ tasks: [],
+ members: [],
+ messages: [],
+ kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
+ },
+ selectedTeamError: null,
+ });
+
+ hoisted.getData.mockRejectedValue(new Error('TEAM_PROVISIONING'));
+
+ await store.getState().refreshTeamData('my-team');
+
+ // Should NOT set error — team is still provisioning
+ expect(store.getState().selectedTeamError).toBeNull();
+ // Should preserve existing data
+ expect(store.getState().selectedTeamData).not.toBeNull();
+ expect(store.getState().selectedTeamData?.teamName).toBe('my-team');
+ });
+
+ it('preserves existing data on transient refresh error', async () => {
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+ const store = createSliceStore();
+ const existingData = {
+ teamName: 'my-team',
+ config: { name: 'My Team' },
+ tasks: [],
+ members: [],
+ messages: [{ from: 'lead', text: 'Hello', timestamp: '2026-01-01T00:00:00Z' }],
+ kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
+ };
+ store.setState({
+ selectedTeamName: 'my-team',
+ selectedTeamData: existingData,
+ selectedTeamError: null,
+ });
+
+ hoisted.getData.mockRejectedValue(new Error('Network timeout'));
+
+ await store.getState().refreshTeamData('my-team');
+
+ // Should NOT replace data with error — preserve existing data
+ expect(store.getState().selectedTeamError).toBeNull();
+ expect(store.getState().selectedTeamData).toEqual(existingData);
+ });
+
+ it('clears stale selectedTeamError when TEAM_PROVISIONING with existing data', async () => {
+ const store = createSliceStore();
+ store.setState({
+ selectedTeamName: 'my-team',
+ selectedTeamData: {
+ teamName: 'my-team',
+ config: { name: 'My Team' },
+ tasks: [],
+ members: [],
+ messages: [],
+ kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
+ },
+ selectedTeamError: 'Previous failure',
+ });
+
+ hoisted.getData.mockRejectedValue(new Error('TEAM_PROVISIONING'));
+
+ await store.getState().refreshTeamData('my-team');
+
+ // Stale error should be cleared even though provisioning prevents new data
+ expect(store.getState().selectedTeamError).toBeNull();
+ expect(store.getState().selectedTeamData).not.toBeNull();
+ });
+
+ it('clears stale selectedTeamError on transient error when data exists', async () => {
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+ const store = createSliceStore();
+ const existingData = {
+ teamName: 'my-team',
+ config: { name: 'My Team' },
+ tasks: [],
+ members: [],
+ messages: [],
+ kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
+ };
+ store.setState({
+ selectedTeamName: 'my-team',
+ selectedTeamData: existingData,
+ selectedTeamError: 'Old stale error',
+ });
+
+ hoisted.getData.mockRejectedValue(new Error('Network timeout'));
+
+ await store.getState().refreshTeamData('my-team');
+
+ // Stale error should be cleared because we still have usable data
+ expect(store.getState().selectedTeamError).toBeNull();
+ expect(store.getState().selectedTeamData).toEqual(existingData);
+ });
+
+ it('sets error when no previous data exists', async () => {
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+ const store = createSliceStore();
+ store.setState({
+ selectedTeamName: 'my-team',
+ selectedTeamData: null,
+ selectedTeamError: null,
+ });
+
+ hoisted.getData.mockRejectedValue(new Error('Team not found'));
+
+ await store.getState().refreshTeamData('my-team');
+
+ // No previous data — error should be shown
+ expect(store.getState().selectedTeamError).toBe('Team not found');
+ });
+ });
});