From db8942ab7af594fa402e49f41894556f9171f894 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D0=B8=D1=8F?= Date: Tue, 31 Mar 2026 17:07:15 +0300 Subject: [PATCH 01/22] Update Discord link in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f1e04336..57a6c17d 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@

Latest Release  CI Status  - Discord + Discord

From 7ff9317b6f0492ae861e9697be73e1c9fbc25d46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D0=B8=D1=8F?= Date: Wed, 1 Apr 2026 12:27:55 +0300 Subject: [PATCH 02/22] Update image in README with new demo image --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 57a6c17d..cfbd35c1 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ 100% free, open source. No API keys. No configuration. Runs entirely locally. Not just coding agents.

-image +demo From 21513bb6f89519b3afbdf5c0e1260c389f1cc239 Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 1 Apr 2026 16:49:07 +0300 Subject: [PATCH 03/22] Improve Extensions CLI preflight and plugin install diagnostics Refs: https://github.com/777genius/claude_agent_teams_ui/issues/37 --- .../extensions/ExtensionStoreView.tsx | 73 +++++++++++++++++++ .../extensions/common/InstallButton.tsx | 47 ++++++++---- .../extensions/plugins/PluginCard.tsx | 2 +- src/renderer/store/slices/extensionsSlice.ts | 33 +++++++++ 4 files changed, 140 insertions(+), 15 deletions(-) diff --git a/src/renderer/components/extensions/ExtensionStoreView.tsx b/src/renderer/components/extensions/ExtensionStoreView.tsx index 8f0c7130..fbcf7d5e 100644 --- a/src/renderer/components/extensions/ExtensionStoreView.tsx +++ b/src/renderer/components/extensions/ExtensionStoreView.tsx @@ -30,6 +30,7 @@ import { ExtensionsSubTabTrigger } from './ExtensionsSubTabTrigger'; export const ExtensionStoreView = (): React.JSX.Element => { const tabId = useTabIdOptional(); const fetchPluginCatalog = useStore((s) => s.fetchPluginCatalog); + const fetchCliStatus = useStore((s) => s.fetchCliStatus); const fetchApiKeys = useStore((s) => s.fetchApiKeys); const fetchSkillsCatalog = useStore((s) => s.fetchSkillsCatalog); const mcpBrowse = useStore((s) => s.mcpBrowse); @@ -38,7 +39,9 @@ export const ExtensionStoreView = (): React.JSX.Element => { const mcpBrowseLoading = useStore((s) => s.mcpBrowseLoading); const skillsLoading = useStore((s) => s.skillsLoading); const cliStatus = useStore((s) => s.cliStatus); + const cliStatusLoading = useStore((s) => s.cliStatusLoading); const cliInstalled = cliStatus?.installed ?? true; // assume installed until checked + const openDashboard = useStore((s) => s.openDashboard); const hasOngoingSessions = useStore((s) => s.sessions.some((sess) => sess.isOngoing)); const projects = useStore((s) => s.projects); const extensionsTabProjectId = useStore((s) => @@ -97,6 +100,10 @@ export const ExtensionStoreView = (): React.JSX.Element => { void fetchPluginCatalog(projectPath ?? undefined); }, [fetchPluginCatalog, projectPath]); + useEffect(() => { + void fetchCliStatus(); + }, [fetchCliStatus]); + // Fetch MCP installed state on mount useEffect(() => { void mcpFetchInstalled(projectPath ?? undefined); @@ -121,6 +128,71 @@ export const ExtensionStoreView = (): React.JSX.Element => { }, [fetchPluginCatalog, fetchSkillsCatalog, mcpBrowse, mcpFetchInstalled, projectPath]); const isRefreshing = pluginCatalogLoading || mcpBrowseLoading || skillsLoading; + const cliStatusBanner = useMemo(() => { + if (cliStatusLoading || cliStatus === null) { + return ( +
+ +
+

Checking Claude CLI availability

+

+ Extensions need Claude CLI to install plugins, run MCP servers, and validate auth. +

+
+
+ ); + } + + if (!cliStatus.installed) { + return ( +
+ +
+

Claude CLI is not available

+

+ Plugin installs are disabled until Claude CLI is installed. Open the Dashboard to + install it and retry. +

+
+ +
+ ); + } + + if (!cliStatus.authLoggedIn) { + return ( +
+ +
+

Claude CLI needs sign-in

+

+ Claude CLI was found + {cliStatus.installedVersion ? ` (${cliStatus.installedVersion})` : ''}, but plugin + installs are disabled until you sign in from the Dashboard. +

+
+ +
+ ); + } + + return ( +
+ +
+

Claude CLI is ready

+

+ Plugins can be installed from this page + {cliStatus.installedVersion ? ` using Claude CLI ${cliStatus.installedVersion}` : ''}. +

+
+
+ ); + }, [cliStatus, cliStatusLoading, openDashboard]); // Browser mode guard if (!api.plugins && !api.mcpRegistry && !api.skills) { @@ -138,6 +210,7 @@ export const ExtensionStoreView = (): React.JSX.Element => { return (
+ {cliStatusBanner}
{/* Header */}
diff --git a/src/renderer/components/extensions/common/InstallButton.tsx b/src/renderer/components/extensions/common/InstallButton.tsx index 5d45a257..ea609745 100644 --- a/src/renderer/components/extensions/common/InstallButton.tsx +++ b/src/renderer/components/extensions/common/InstallButton.tsx @@ -37,8 +37,20 @@ export const InstallButton = ({ errorMessage, }: InstallButtonProps) => { const cliStatus = useStore((s) => s.cliStatus); - const cliMissing = cliStatus !== null && !cliStatus.installed; - const isDisabled = disabled || cliMissing; + const cliStatusLoading = useStore((s) => s.cliStatusLoading); + const cliUnknown = cliStatus === null; + const cliMissing = cliStatus?.installed === false; + const authMissing = cliStatus?.installed === true && !cliStatus.authLoggedIn; + const disableReason = cliStatusLoading + ? 'Checking Claude CLI status...' + : cliUnknown + ? 'Checking Claude CLI availability...' + : cliMissing + ? 'Claude CLI required. Install it from the Dashboard.' + : authMissing + ? 'Claude CLI is installed but not signed in. Open the Dashboard to sign in.' + : null; + const isDisabled = disabled || Boolean(disableReason); const [lastAction, setLastAction] = useState<'install' | 'uninstall' | null>(null); useEffect(() => { @@ -91,23 +103,30 @@ export const InstallButton = ({ ); - if (errorMessage) { + const tooltipMessage = disableReason ?? errorMessage; + + if (tooltipMessage) { return ( - - - - {retryButton} - - {errorMessage} - - +
+ + + + {retryButton} + + {tooltipMessage} + + + {errorMessage && !disableReason ? ( +

{errorMessage}

+ ) : null} +
); } return retryButton; } - // idle — wrap in tooltip when CLI missing + // idle — wrap in tooltip when install is unavailable const button = isInstalled ? ( +
+ )} + {hasMore && ( +
+ +
+ )} Promise; cancelProvisioning: (runId: string) => Promise; sendMessage: (teamName: string, request: SendMessageRequest) => Promise; + getMessagesPage: ( + teamName: string, + options?: { beforeTimestamp?: string; limit?: number } + ) => Promise; createTask: (teamName: string, request: CreateTaskRequest) => Promise; requestReview: (teamName: string, taskId: string) => Promise; updateKanban: (teamName: string, taskId: string, patch: UpdateKanbanPatch) => Promise; diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index b8f4e817..aad4d036 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -386,6 +386,14 @@ export interface InboxMessage { commandOutput?: CommandOutputMeta; } +/** Cursor-based paginated messages response. */ +export interface MessagesPage { + messages: InboxMessage[]; + /** ISO timestamp cursor for fetching older messages. Null when no more pages. */ + nextCursor: string | null; + hasMore: boolean; +} + export type AgentActionMode = 'do' | 'ask' | 'delegate'; export interface SendMessageRequest { From 7d2282c35cf177f8db07af8f9ffb6033f0871930 Mon Sep 17 00:00:00 2001 From: Artem Rootman <4586640+artemrootman@users.noreply.github.com> Date: Sun, 5 Apr 2026 17:45:44 +0000 Subject: [PATCH 14/22] fix: keep 50 messages in getTeamData for backward compatibility Returning messages: [] broke the slash command annotation test and any code relying on getTeamData.messages (notifications, dedup). Keep a small batch (50 newest) in getTeamData for compatibility. Full message history is available via getMessagesPage() API. --- src/main/services/team/TeamDataService.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 7f7953e7..d50390a8 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -723,15 +723,19 @@ export class TeamDataService { this.processHealthTeams.delete(teamName); } - // Messages are now served separately via getMessagesPage() for pagination. - // Sending an empty array here keeps the TeamData shape unchanged while - // eliminating the ~1MB messages payload from every getTeamData refresh. + // Cap messages to keep IPC payloads small. Full history is available + // via the paginated getMessagesPage() API. We still include a small + // batch here for backward compatibility (notifications, dedup, etc.). + const MAX_RETURN_MESSAGES = 50; + const cappedMessages = + messages.length > MAX_RETURN_MESSAGES ? messages.slice(0, MAX_RETURN_MESSAGES) : messages; + return { teamName, config, tasks: tasksWithKanban, members, - messages: [], + messages: cappedMessages, kanbanState, processes, warnings: warnings.length > 0 ? warnings : undefined, From 54198e65988d2ad22b86a27506e246f10adebca6 Mon Sep 17 00:00:00 2001 From: Artem Rootman <4586640+artemrootman@users.noreply.github.com> Date: Sun, 5 Apr 2026 18:06:31 +0000 Subject: [PATCH 15/22] fix: remove capTimerMap that silently drops pending refreshes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit capTimerMap was cancelling oldest pending throttle/debounce timers when the Map exceeded a size limit. Since the schedulers don't replay dropped keys, this silently lost refresh callbacks — leaving session/team state stale after high-volume file change events. These Maps are self-cleaning (entries delete themselves when their callback fires) and hold ~100 bytes per entry. Even 200 entries is negligible memory. Removing the cap fixes the data freshness issue without any memory concern. --- src/renderer/store/index.ts | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index f6f2786f..b9a6db70 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -175,19 +175,6 @@ export function initializeNotificationListeners(): () => void { const TEAM_PRESENCE_REFRESH_THROTTLE_MS = 400; const TEAM_LIST_REFRESH_THROTTLE_MS = 2000; const GLOBAL_TASKS_REFRESH_THROTTLE_MS = 500; - /** Cap a Map at maxSize by clearing oldest entries (FIFO via insertion order). */ - const capTimerMap = (map: Map>, maxSize: number): void => { - if (map.size <= maxSize) return; - const excess = map.size - maxSize; - let cleared = 0; - for (const [key, value] of map) { - if (cleared >= excess) break; - clearTimeout(value); - map.delete(key); - cleared++; - } - }; - const buildToolActivityTimerKey = ( teamName: string, memberName: string, @@ -222,7 +209,6 @@ export function initializeNotificationListeners(): () => void { cb(); }, delayMs); toolActivityTimers.set(key, timer); - capTimerMap(toolActivityTimers, 200); }; const clearToolActivityTimersForTeam = (teamName: string): void => { for (const [key, timer] of toolActivityTimers.entries()) { @@ -400,7 +386,6 @@ export function initializeNotificationListeners(): () => void { void state.refreshSessionInPlace(projectId, sessionId); }, SESSION_REFRESH_DEBOUNCE_MS); pendingSessionRefreshTimers.set(key, timer); - capTimerMap(pendingSessionRefreshTimers, 50); }; const scheduleProjectRefresh = (projectId: string): void => { @@ -414,7 +399,6 @@ export function initializeNotificationListeners(): () => void { void state.refreshSessionsInPlace(projectId); }, PROJECT_REFRESH_DEBOUNCE_MS); pendingProjectRefreshTimers.set(projectId, timer); - capTimerMap(pendingProjectRefreshTimers, 20); }; // Listen for new notifications from main process @@ -959,7 +943,6 @@ export function initializeNotificationListeners(): () => void { void current.refreshTeamData(event.teamName); }, TEAM_REFRESH_THROTTLE_MS); teamRefreshTimers.set(event.teamName, timer); - capTimerMap(teamRefreshTimers, 20); return; } @@ -976,7 +959,6 @@ export function initializeNotificationListeners(): () => void { void current.refreshSelectedTeamChangePresence(event.teamName); }, TEAM_PRESENCE_REFRESH_THROTTLE_MS); teamPresenceRefreshTimers.set(event.teamName, timer); - capTimerMap(teamPresenceRefreshTimers, 20); return; } @@ -1012,7 +994,6 @@ export function initializeNotificationListeners(): () => void { void current.refreshTeamData(event.teamName); }, TEAM_REFRESH_THROTTLE_MS); teamRefreshTimers.set(event.teamName, timer); - capTimerMap(teamRefreshTimers, 20); }); if (typeof cleanup === 'function') { From bc7981f6b96cb942bc45e5c4475b8f03381f5cc2 Mon Sep 17 00:00:00 2001 From: Artem Rootman <4586640+artemrootman@users.noreply.github.com> Date: Sun, 5 Apr 2026 18:09:59 +0000 Subject: [PATCH 16/22] fix: use full intervals content in worker cache key, not just length Two different interval sets with the same length would produce the same cache key, returning stale results. Serialize each interval's startedAt~completedAt into the key. --- src/main/workers/team-data-worker.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/workers/team-data-worker.ts b/src/main/workers/team-data-worker.ts index 4b91fdf1..06fa12c2 100644 --- a/src/main/workers/team-data-worker.ts +++ b/src/main/workers/team-data-worker.ts @@ -44,7 +44,10 @@ parentPort?.on('message', async (msg: TeamDataWorkerRequest) => { } case 'findLogsForTask': { const { teamName, taskId, options } = msg.payload; - const cacheKey = `${teamName}:${taskId}:${options?.owner ?? ''}:${options?.status ?? ''}:${options?.since ?? ''}:${options?.intervals?.length ?? 0}`; + const intervalsKey = options?.intervals + ? options.intervals.map((i) => `${i.startedAt}~${i.completedAt ?? ''}`).join(',') + : ''; + const cacheKey = `${teamName}:${taskId}:${options?.owner ?? ''}:${options?.status ?? ''}:${options?.since ?? ''}:${intervalsKey}`; // Check result cache const cached = logsResultCache.get(cacheKey); From 5efc3dd63fbc6186f096f9db3d36af7e4b7e9e5c Mon Sep 17 00:00:00 2001 From: Artem Rootman <4586640+artemrootman@users.noreply.github.com> Date: Sun, 5 Apr 2026 18:18:03 +0000 Subject: [PATCH 17/22] fix: pagination correctness and message enrichment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1: Poller no longer overwrites nextCursor/hasMore — those belong to the "Load older" flow. Both poller and loadOlder now dedup messages by messageId or timestamp+from fingerprint. P1: Cursor is now compound (timestamp|messageId) with stable tie-breaking sort. Messages sharing the same timestamp at page boundaries are no longer lost. P2: getMessagesPage now applies the same enrichment as getTeamData: leadSessionId propagation and slash-command-result annotation. P3: Added 3 tests for getMessagesPage covering pagination, cursor stability with same-timestamp messages, and slash command annotation. --- src/main/services/team/TeamDataService.ts | 63 +++++++++++-- .../team/messages/MessagesPanel.tsx | 25 ++--- .../services/team/TeamDataService.test.ts | 93 +++++++++++++++++++ 3 files changed, 164 insertions(+), 17 deletions(-) diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index d50390a8..4624d1be 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -782,19 +782,70 @@ export class TeamDataService { }); } - // Sort newest-first - messages.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp)); + // Enrich: propagate leadSessionId to messages missing it (same as getTeamData) + if (config.leadSessionId || messages.some((m) => m.leadSessionId)) { + messages.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)); + const anchors: { time: number; sessionId: string }[] = []; + for (const msg of messages) { + if (msg.leadSessionId) { + anchors.push({ time: Date.parse(msg.timestamp), sessionId: msg.leadSessionId }); + } + } + if (anchors.length > 0) { + for (const msg of messages) { + if (msg.leadSessionId) continue; + const msgTime = Date.parse(msg.timestamp); + let best = anchors[0]; + let bestDist = Math.abs(msgTime - best.time); + for (const a of anchors) { + const dist = Math.abs(msgTime - a.time); + if (dist < bestDist) { + bestDist = dist; + best = a; + } else if (dist > bestDist && a.time > msgTime) { + break; + } + } + msg.leadSessionId = best.sessionId; + } + } else if (config.leadSessionId) { + for (const msg of messages) { + msg.leadSessionId = config.leadSessionId; + } + } + } - // Apply cursor filter + // Enrich: annotate slash command responses + this.annotateSlashCommandResponses(messages); + + // Sort newest-first, with stable tie-breaker by messageId + messages.sort((a, b) => { + const diff = Date.parse(b.timestamp) - Date.parse(a.timestamp); + if (diff !== 0) return diff; + return (a.messageId ?? '').localeCompare(b.messageId ?? ''); + }); + + // Apply cursor filter. Cursor format: "timestamp|messageId" (compound) + // to handle multiple messages sharing the same timestamp. if (options.beforeTimestamp) { - const cursorMs = Date.parse(options.beforeTimestamp); - messages = messages.filter((m) => Date.parse(m.timestamp) < cursorMs); + const [cursorTs, cursorId] = options.beforeTimestamp.split('|'); + const cursorMs = Date.parse(cursorTs); + messages = messages.filter((m) => { + const ms = Date.parse(m.timestamp); + if (ms < cursorMs) return true; + if (ms > cursorMs) return false; + // Same timestamp — use messageId tie-breaker + if (!cursorId) return false; + return (m.messageId ?? '').localeCompare(cursorId) > 0; + }); } // Paginate const hasMore = messages.length > options.limit; const page = messages.slice(0, options.limit); - const nextCursor = hasMore && page.length > 0 ? page[page.length - 1].timestamp : null; + const lastMsg = page[page.length - 1]; + const nextCursor = + hasMore && lastMsg ? `${lastMsg.timestamp}|${lastMsg.messageId ?? ''}` : null; return { messages: page, nextCursor, hasMore }; } diff --git a/src/renderer/components/team/messages/MessagesPanel.tsx b/src/renderer/components/team/messages/MessagesPanel.tsx index 7ce5a006..9294d9b5 100644 --- a/src/renderer/components/team/messages/MessagesPanel.tsx +++ b/src/renderer/components/team/messages/MessagesPanel.tsx @@ -159,24 +159,20 @@ export const MessagesPanel = memo(function MessagesPanel({ })(); }, [teamName]); // eslint-disable-line react-hooks/exhaustive-deps -- intentionally only on teamName change - // Auto-refresh: poll for new messages (newest page only) + // Auto-refresh: poll for NEW messages only (prepend to head). + // Does NOT touch nextCursor/hasMore — those belong to the "Load older" flow. useEffect(() => { if (!isTeamAlive && leadActivity !== 'active') return; const interval = setInterval(async () => { try { const page = await api.teams.getMessagesPage(teamName, { limit: PAGE_SIZE }); setFetchedMessages((prev) => { - // Merge: keep older messages that aren't in the new page - const newIds = new Set(page.messages.map((m) => m.messageId ?? m.timestamp)); - const older = prev.filter( - (m) => - !newIds.has(m.messageId ?? m.timestamp) && - !page.messages.some((n) => n.timestamp === m.timestamp && n.from === m.from) + const existingIds = new Set(prev.map((m) => m.messageId ?? `${m.timestamp}\0${m.from}`)); + const newMessages = page.messages.filter( + (m) => !existingIds.has(m.messageId ?? `${m.timestamp}\0${m.from}`) ); - return [...page.messages, ...older]; + return newMessages.length > 0 ? [...newMessages, ...prev] : prev; }); - setNextCursor(page.nextCursor); - setHasMore(page.hasMore); } catch { // best-effort } @@ -192,7 +188,14 @@ export const MessagesPanel = memo(function MessagesPanel({ beforeTimestamp: nextCursor, limit: PAGE_SIZE, }); - setFetchedMessages((prev) => [...prev, ...page.messages]); + // Dedup: only append messages we don't already have + setFetchedMessages((prev) => { + const existingIds = new Set(prev.map((m) => m.messageId ?? `${m.timestamp}\0${m.from}`)); + const newMessages = page.messages.filter( + (m) => !existingIds.has(m.messageId ?? `${m.timestamp}\0${m.from}`) + ); + return [...prev, ...newMessages]; + }); setNextCursor(page.nextCursor); setHasMore(page.hasMore); } catch { diff --git a/test/main/services/team/TeamDataService.test.ts b/test/main/services/team/TeamDataService.test.ts index 0e574462..d5cf430c 100644 --- a/test/main/services/team/TeamDataService.test.ts +++ b/test/main/services/team/TeamDataService.test.ts @@ -2145,4 +2145,97 @@ describe('TeamDataService', () => { }, }); }); + + describe('getMessagesPage', () => { + function createPaginationService(messages: Array<{ from: string; text: string; timestamp: string; messageId?: string; source?: string; leadSessionId?: string }>) { + return new TeamDataService( + { + listTeams: vi.fn(), + getConfig: vi.fn(async () => ({ + name: 'My team', + members: [{ name: 'team-lead', role: 'Lead' }], + leadSessionId: 'lead-1', + })), + } as never, + { getTasks: vi.fn(async () => []) } as never, + { + listInboxNames: vi.fn(async () => []), + getMessages: vi.fn(async () => + messages.map((m) => ({ ...m, read: true })) + ), + } as never, + {} as never, + {} as never, + { resolveMembers: vi.fn(() => []) } as never, + { getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })) } as never, + {} as never, + {} as never, + { readMessages: vi.fn(async () => []) } as never, + ); + } + + it('returns first page with cursor and hasMore', async () => { + const msgs = Array.from({ length: 5 }, (_, i) => ({ + from: 'alice', + text: `msg-${i}`, + timestamp: `2026-01-01T00:00:0${i}.000Z`, + messageId: `m${i}`, + source: 'inbox' as const, + })); + const service = createPaginationService(msgs); + const page = await service.getMessagesPage('my-team', { limit: 3 }); + + expect(page.messages).toHaveLength(3); + expect(page.hasMore).toBe(true); + expect(page.nextCursor).toBeTruthy(); + // Newest first + expect(page.messages[0].messageId).toBe('m4'); + }); + + it('cursor excludes already-seen messages without losing same-timestamp messages', async () => { + const msgs = [ + { from: 'a', text: '1', timestamp: '2026-01-01T00:00:02.000Z', messageId: 'x1' }, + { from: 'b', text: '2', timestamp: '2026-01-01T00:00:02.000Z', messageId: 'x2' }, + { from: 'c', text: '3', timestamp: '2026-01-01T00:00:01.000Z', messageId: 'x3' }, + ]; + const service = createPaginationService(msgs); + const page1 = await service.getMessagesPage('my-team', { limit: 1 }); + expect(page1.messages).toHaveLength(1); + expect(page1.hasMore).toBe(true); + + const page2 = await service.getMessagesPage('my-team', { + beforeTimestamp: page1.nextCursor!, + limit: 10, + }); + // Should get the remaining 2 messages, not lose the one with same timestamp + expect(page2.messages.length).toBeGreaterThanOrEqual(1); + const allIds = [...page1.messages, ...page2.messages].map((m) => m.messageId); + expect(new Set(allIds).size).toBe(allIds.length); // no duplicates + }); + + it('annotates slash command results in paginated path', async () => { + const msgs = [ + { + from: 'user', + text: '/cost', + timestamp: '2026-01-01T00:00:00.000Z', + messageId: 'cmd1', + source: 'user_sent', + leadSessionId: 'lead-1', + }, + { + from: 'team-lead', + text: 'Total cost: $1.05', + timestamp: '2026-01-01T00:00:01.000Z', + messageId: 'resp1', + source: 'lead_process', + leadSessionId: 'lead-1', + }, + ]; + const service = createPaginationService(msgs); + const page = await service.getMessagesPage('my-team', { limit: 10 }); + const result = page.messages.find((m) => m.messageId === 'resp1'); + expect(result?.messageKind).toBe('slash_command_result'); + }); + }); }); From 8570ed13fd5534de0af1096a0dbbed024b5a227b Mon Sep 17 00:00:00 2001 From: Artem Rootman <4586640+artemrootman@users.noreply.github.com> Date: Sun, 5 Apr 2026 18:25:43 +0000 Subject: [PATCH 18/22] fix: stabilize flaky ChangeExtractorService invalidation test The test races stale and fresh worker calls to verify that invalidation prevents stale results from populating the cache. On slow CI, the fresh worker mock could be reached before the stale deferred was resolved, causing the version guard to mismatch. Flush microtasks after starting freshPromise so it advances past internal awaits and reaches the worker mock before we resolve the stale deferred. --- test/main/services/team/ChangeExtractorService.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/main/services/team/ChangeExtractorService.test.ts b/test/main/services/team/ChangeExtractorService.test.ts index ea9fecbc..728f1575 100644 --- a/test/main/services/team/ChangeExtractorService.test.ts +++ b/test/main/services/team/ChangeExtractorService.test.ts @@ -651,6 +651,14 @@ describe('ChangeExtractorService', () => { const stalePromise = service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); await service.invalidateTaskChangeSummaries(TEAM_NAME, [TASK_ID], { deletePersisted: true }); const freshPromise = service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); + // Flush microtasks so freshPromise advances past its internal awaits + // and reaches the worker mock before we resolve the stale deferred. + // Without this, CI timing can cause the stale resolution to race with + // the fresh worker call, making the test flaky. + await vi.advanceTimersByTimeAsync?.(0).catch(() => undefined); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); first.resolve(makeTaskChangeResult()); const stale = await stalePromise; const fresh = await freshPromise; From dc0627c2853f4c7e69b6518b5341e4451fdacc83 Mon Sep 17 00:00:00 2001 From: iliya Date: Sun, 5 Apr 2026 21:54:52 +0300 Subject: [PATCH 19/22] fix message pagination consumers --- .../team/members/MemberDetailDialog.tsx | 54 +++++++++++++++- .../team/messages/MessagesPanel.tsx | 29 +++------ src/renderer/utils/mergeTeamMessages.ts | 27 ++++++++ src/shared/types/team.ts | 2 +- test/renderer/utils/mergeTeamMessages.test.ts | 62 +++++++++++++++++++ 5 files changed, 152 insertions(+), 22 deletions(-) create mode 100644 src/renderer/utils/mergeTeamMessages.ts create mode 100644 test/renderer/utils/mergeTeamMessages.test.ts diff --git a/src/renderer/components/team/members/MemberDetailDialog.tsx b/src/renderer/components/team/members/MemberDetailDialog.tsx index 796545ad..34ff49a0 100644 --- a/src/renderer/components/team/members/MemberDetailDialog.tsx +++ b/src/renderer/components/team/members/MemberDetailDialog.tsx @@ -1,9 +1,11 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import { api } from '@renderer/api'; import { Button } from '@renderer/components/ui/button'; import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'; import { useMemberStats } from '@renderer/hooks/useMemberStats'; +import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages'; import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection'; import { BarChart3, FileText, ListPlus, MessageSquare, UserMinus } from 'lucide-react'; @@ -40,6 +42,8 @@ interface MemberDetailDialogProps { onViewMemberChanges?: (memberName: string, filePath?: string) => void; } +const MEMBER_MESSAGES_PAGE_SIZE = 200; + export const MemberDetailDialog = ({ open, member, @@ -63,10 +67,56 @@ export const MemberDetailDialog = ({ [tasks, member] ); - const memberMessages = useMemo( + const seedMemberMessages = useMemo( () => (member ? messages.filter((m) => m.from === member.name || m.to === member.name) : []), [messages, member] ); + const [pagedMemberMessages, setPagedMemberMessages] = useState(null); + + useEffect(() => { + if (!open || !member) { + setPagedMemberMessages(null); + return; + } + + let cancelled = false; + setPagedMemberMessages(null); + + void (async () => { + let cursor: string | undefined; + let hasMore = true; + let allMessages: InboxMessage[] = []; + + while (!cancelled && hasMore) { + const page = await api.teams.getMessagesPage(teamName, { + beforeTimestamp: cursor, + limit: MEMBER_MESSAGES_PAGE_SIZE, + }); + allMessages = mergeTeamMessages(allMessages, page.messages); + hasMore = page.hasMore && page.nextCursor != null; + cursor = page.nextCursor ?? undefined; + } + + if (cancelled) return; + + setPagedMemberMessages( + allMessages.filter((message) => message.from === member.name || message.to === member.name) + ); + })().catch(() => { + if (!cancelled) { + setPagedMemberMessages([]); + } + }); + + return () => { + cancelled = true; + }; + }, [open, teamName, member?.name]); + + const memberMessages = useMemo( + () => mergeTeamMessages(seedMemberMessages, pagedMemberMessages ?? []), + [seedMemberMessages, pagedMemberMessages] + ); const inProgressTasks = useMemo( () => memberTasks.filter((t) => t.status === 'in_progress').length, diff --git a/src/renderer/components/team/messages/MessagesPanel.tsx b/src/renderer/components/team/messages/MessagesPanel.tsx index 9294d9b5..e23e9de8 100644 --- a/src/renderer/components/team/messages/MessagesPanel.tsx +++ b/src/renderer/components/team/messages/MessagesPanel.tsx @@ -8,6 +8,7 @@ import { useStableTeamMentionMeta } from '@renderer/hooks/useStableTeamMentionMe import { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded'; import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; import { useStore } from '@renderer/store'; +import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages'; import { useShallow } from 'zustand/react/shallow'; import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; @@ -166,13 +167,7 @@ export const MessagesPanel = memo(function MessagesPanel({ const interval = setInterval(async () => { try { const page = await api.teams.getMessagesPage(teamName, { limit: PAGE_SIZE }); - setFetchedMessages((prev) => { - const existingIds = new Set(prev.map((m) => m.messageId ?? `${m.timestamp}\0${m.from}`)); - const newMessages = page.messages.filter( - (m) => !existingIds.has(m.messageId ?? `${m.timestamp}\0${m.from}`) - ); - return newMessages.length > 0 ? [...newMessages, ...prev] : prev; - }); + setFetchedMessages((prev) => mergeTeamMessages(prev, page.messages)); } catch { // best-effort } @@ -188,14 +183,7 @@ export const MessagesPanel = memo(function MessagesPanel({ beforeTimestamp: nextCursor, limit: PAGE_SIZE, }); - // Dedup: only append messages we don't already have - setFetchedMessages((prev) => { - const existingIds = new Set(prev.map((m) => m.messageId ?? `${m.timestamp}\0${m.from}`)); - const newMessages = page.messages.filter( - (m) => !existingIds.has(m.messageId ?? `${m.timestamp}\0${m.from}`) - ); - return [...prev, ...newMessages]; - }); + setFetchedMessages((prev) => mergeTeamMessages(prev, page.messages)); setNextCursor(page.nextCursor); setHasMore(page.hasMore); } catch { @@ -206,7 +194,10 @@ export const MessagesPanel = memo(function MessagesPanel({ }, [teamName, nextCursor, messagesLoading]); // Use fetched messages, fall back to prop messages during initial load - const effectiveMessages = fetchedMessages.length > 0 ? fetchedMessages : messages; + const effectiveMessages = useMemo(() => { + if (fetchedMessages.length === 0) return messages; + return mergeTeamMessages(fetchedMessages, messages); + }, [fetchedMessages, messages]); const composerTextareaRef = useRef(null); const sidebarScrollRef = useRef(null); @@ -437,7 +428,7 @@ export const MessagesPanel = memo(function MessagesPanel({
(); + + for (const list of messageLists) { + for (const message of list) { + merged.set(toMessageKey(message), message); + } + } + + return Array.from(merged.values()).sort(compareMessages); +} diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index aad4d036..08a1e421 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -389,7 +389,7 @@ export interface InboxMessage { /** Cursor-based paginated messages response. */ export interface MessagesPage { messages: InboxMessage[]; - /** ISO timestamp cursor for fetching older messages. Null when no more pages. */ + /** Opaque cursor string for fetching older messages. Null when no more pages. */ nextCursor: string | null; hasMore: boolean; } diff --git a/test/renderer/utils/mergeTeamMessages.test.ts b/test/renderer/utils/mergeTeamMessages.test.ts new file mode 100644 index 00000000..d8da3645 --- /dev/null +++ b/test/renderer/utils/mergeTeamMessages.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; + +import { mergeTeamMessages } from '../../../src/renderer/utils/mergeTeamMessages'; + +import type { InboxMessage } from '@shared/types'; + +function makeMessage( + overrides: Partial & Pick +): InboxMessage { + const { from, text, timestamp, ...rest } = overrides; + return { + from, + text, + timestamp, + read: rest.read ?? true, + ...rest, + }; +} + +describe('mergeTeamMessages', () => { + it('deduplicates by stable message key and keeps newest-first order', () => { + const older = makeMessage({ + from: 'alice', + text: 'older', + timestamp: '2026-01-01T00:00:00.000Z', + messageId: 'm1', + }); + const newer = makeMessage({ + from: 'bob', + text: 'newer', + timestamp: '2026-01-01T00:00:01.000Z', + messageId: 'm2', + }); + const merged = mergeTeamMessages([older], [newer]); + + expect(merged.map((message) => message.messageId)).toEqual(['m2', 'm1']); + }); + + it('lets later arrays overlay duplicate messages', () => { + const persisted = makeMessage({ + from: 'team-lead', + text: 'hello', + timestamp: '2026-01-01T00:00:00.000Z', + messageId: 'm1', + summary: 'persisted', + }); + const live = makeMessage({ + from: 'team-lead', + text: 'hello', + timestamp: '2026-01-01T00:00:00.000Z', + messageId: 'm1', + summary: 'live', + source: 'lead_process', + }); + + const merged = mergeTeamMessages([persisted], [live]); + + expect(merged).toHaveLength(1); + expect(merged[0].summary).toBe('live'); + expect(merged[0].source).toBe('lead_process'); + }); +}); From 0531fc1dbf2e1028b0caf0adb32403255f42e0d0 Mon Sep 17 00:00:00 2001 From: iliya Date: Sun, 5 Apr 2026 22:03:11 +0300 Subject: [PATCH 20/22] fix worker lifecycle edge cases --- src/main/ipc/teams.ts | 6 ++++ .../services/team/TeamDataWorkerClient.ts | 34 ++++++++++--------- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index cee6de39..47fe6315 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -577,6 +577,12 @@ async function handleGetData( if (getDataMs >= 1500) { logger.warn(`[teams:getData] slow team=${tn} ms=${getDataMs}`); } + const teamDataService = getTeamDataService(); + if (data.processes.some((process) => !process.stoppedAt)) { + teamDataService.trackProcessHealthForTeam?.(tn); + } else { + teamDataService.untrackProcessHealthForTeam?.(tn); + } const provisioning = getTeamProvisioningService(); const isAlive = provisioning.isTeamAlive(tn); diff --git a/src/main/services/team/TeamDataWorkerClient.ts b/src/main/services/team/TeamDataWorkerClient.ts index 14e10d7e..314fe1a0 100644 --- a/src/main/services/team/TeamDataWorkerClient.ts +++ b/src/main/services/team/TeamDataWorkerClient.ts @@ -60,6 +60,18 @@ export class TeamDataWorkerClient { private warnedUnavailable = false; private pending = new Map(); + private failWorker(worker: Worker, error: Error): void { + if (this.worker !== worker) return; + + this.worker = null; + const pendingEntries = Array.from(this.pending.values()); + this.pending.clear(); + + for (const entry of pendingEntries) { + entry.reject(error); + } + } + isAvailable(): boolean { if (!this.workerPath && !this.warnedUnavailable) { this.warnedUnavailable = true; @@ -90,23 +102,13 @@ export class TeamDataWorkerClient { // Without this guard, a stale worker's exit event can reject // pending requests that belong to a newer replacement worker. w.on('error', (err) => { - if (this.worker !== w) return; logger.error('Worker error', err); - for (const [, entry] of this.pending) { - entry.reject(err instanceof Error ? err : new Error(String(err))); - } - this.pending.clear(); - this.worker = null; + this.failWorker(w, err instanceof Error ? err : new Error(String(err))); }); w.on('exit', (code) => { - if (this.worker !== w) return; if (code !== 0) logger.warn(`Worker exited with code ${code}`); - for (const [, entry] of this.pending) { - entry.reject(new Error(`Worker exited with code ${code}`)); - } - this.pending.clear(); - this.worker = null; + this.failWorker(w, new Error(`Worker exited with code ${code}`)); }); return w; @@ -121,10 +123,10 @@ export class TeamDataWorkerClient { return new Promise((resolve, reject) => { const timeout = setTimeout(() => { - this.pending.delete(id); - this.worker?.terminate().catch(() => undefined); - this.worker = null; - reject(new Error(`Worker call timeout after ${WORKER_CALL_TIMEOUT_MS}ms`)); + const timeoutError = new Error(`Worker call timeout after ${WORKER_CALL_TIMEOUT_MS}ms`); + this.failWorker(worker, timeoutError); + worker.terminate().catch(() => undefined); + reject(timeoutError); }, WORKER_CALL_TIMEOUT_MS); this.pending.set(id, { From 9296ce3988f4e3132bfd37492adbf10ff1e189e8 Mon Sep 17 00:00:00 2001 From: iliya Date: Sun, 5 Apr 2026 22:07:09 +0300 Subject: [PATCH 21/22] refine member message pagination --- .../team/members/MemberDetailDialog.tsx | 60 ++------------ .../team/members/MemberMessagesTab.tsx | 83 ++++++++++++++++++- 2 files changed, 87 insertions(+), 56 deletions(-) diff --git a/src/renderer/components/team/members/MemberDetailDialog.tsx b/src/renderer/components/team/members/MemberDetailDialog.tsx index 34ff49a0..17ed92a8 100644 --- a/src/renderer/components/team/members/MemberDetailDialog.tsx +++ b/src/renderer/components/team/members/MemberDetailDialog.tsx @@ -1,11 +1,8 @@ -import { useEffect, useMemo, useState } from 'react'; - -import { api } from '@renderer/api'; +import { useMemo, useState } from 'react'; import { Button } from '@renderer/components/ui/button'; import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'; import { useMemberStats } from '@renderer/hooks/useMemberStats'; -import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages'; import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection'; import { BarChart3, FileText, ListPlus, MessageSquare, UserMinus } from 'lucide-react'; @@ -42,8 +39,6 @@ interface MemberDetailDialogProps { onViewMemberChanges?: (memberName: string, filePath?: string) => void; } -const MEMBER_MESSAGES_PAGE_SIZE = 200; - export const MemberDetailDialog = ({ open, member, @@ -71,52 +66,7 @@ export const MemberDetailDialog = ({ () => (member ? messages.filter((m) => m.from === member.name || m.to === member.name) : []), [messages, member] ); - const [pagedMemberMessages, setPagedMemberMessages] = useState(null); - - useEffect(() => { - if (!open || !member) { - setPagedMemberMessages(null); - return; - } - - let cancelled = false; - setPagedMemberMessages(null); - - void (async () => { - let cursor: string | undefined; - let hasMore = true; - let allMessages: InboxMessage[] = []; - - while (!cancelled && hasMore) { - const page = await api.teams.getMessagesPage(teamName, { - beforeTimestamp: cursor, - limit: MEMBER_MESSAGES_PAGE_SIZE, - }); - allMessages = mergeTeamMessages(allMessages, page.messages); - hasMore = page.hasMore && page.nextCursor != null; - cursor = page.nextCursor ?? undefined; - } - - if (cancelled) return; - - setPagedMemberMessages( - allMessages.filter((message) => message.from === member.name || message.to === member.name) - ); - })().catch(() => { - if (!cancelled) { - setPagedMemberMessages([]); - } - }); - - return () => { - cancelled = true; - }; - }, [open, teamName, member?.name]); - - const memberMessages = useMemo( - () => mergeTeamMessages(seedMemberMessages, pagedMemberMessages ?? []), - [seedMemberMessages, pagedMemberMessages] - ); + const memberMessages = seedMemberMessages; const inProgressTasks = useMemo( () => memberTasks.filter((t) => t.status === 'in_progress').length, @@ -204,7 +154,11 @@ export const MemberDetailDialog = ({ - + void; } const MAX_MESSAGES = 100; +const MEMBER_MESSAGES_PAGE_SIZE = 50; export const MemberMessagesTab = ({ messages, teamName, + memberName, onCreateTask, }: MemberMessagesTabProps): React.JSX.Element => { + const [pagedMessages, setPagedMessages] = useState([]); + const [nextCursor, setNextCursor] = useState(null); + const [hasMore, setHasMore] = useState(false); + const [loading, setLoading] = useState(false); + + useEffect(() => { + let cancelled = false; + setPagedMessages([]); + setNextCursor(null); + setHasMore(false); + setLoading(true); + + void (async () => { + try { + const page = await api.teams.getMessagesPage(teamName, { limit: MEMBER_MESSAGES_PAGE_SIZE }); + if (cancelled) return; + const memberPageMessages = page.messages.filter( + (message) => message.from === memberName || message.to === memberName + ); + setPagedMessages(memberPageMessages); + setNextCursor(page.nextCursor); + setHasMore(page.hasMore); + } catch { + if (!cancelled) { + setPagedMessages([]); + setNextCursor(null); + setHasMore(false); + } + } finally { + if (!cancelled) setLoading(false); + } + })(); + + return () => { + cancelled = true; + }; + }, [teamName, memberName]); + + const loadOlderMessages = useCallback(async () => { + if (!nextCursor || loading) return; + setLoading(true); + try { + const page = await api.teams.getMessagesPage(teamName, { + beforeTimestamp: nextCursor, + limit: MEMBER_MESSAGES_PAGE_SIZE, + }); + const memberPageMessages = page.messages.filter( + (message) => message.from === memberName || message.to === memberName + ); + setPagedMessages((prev) => mergeTeamMessages(prev, memberPageMessages)); + setNextCursor(page.nextCursor); + setHasMore(page.hasMore); + } catch { + // best-effort + } finally { + setLoading(false); + } + }, [teamName, memberName, nextCursor, loading]); + + const effectiveMessages = useMemo( + () => mergeTeamMessages(messages, pagedMessages), + [messages, pagedMessages] + ); + const displayMessages = useMemo( () => - filterTeamMessages(messages, { + filterTeamMessages(effectiveMessages, { timeWindow: null, filter: { from: new Set(), to: new Set(), showNoise: true }, searchQuery: '', }).slice(0, MAX_MESSAGES), - [messages] + [effectiveMessages] ); if (displayMessages.length === 0) { @@ -47,6 +117,13 @@ export const MemberMessagesTab = ({ onCreateTask={onCreateTask} /> ))} + {hasMore && ( +
+ +
+ )}
); }; From 84e0bdb03ecc8110cd31d9cdc0037ffa4ee06d1e Mon Sep 17 00:00:00 2001 From: iliya Date: Sun, 5 Apr 2026 22:13:45 +0300 Subject: [PATCH 22/22] fix member messages empty pagination state --- .../team/members/MemberMessagesTab.tsx | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/src/renderer/components/team/members/MemberMessagesTab.tsx b/src/renderer/components/team/members/MemberMessagesTab.tsx index 5cd82129..b48ed530 100644 --- a/src/renderer/components/team/members/MemberMessagesTab.tsx +++ b/src/renderer/components/team/members/MemberMessagesTab.tsx @@ -99,24 +99,28 @@ export const MemberMessagesTab = ({ [effectiveMessages] ); - if (displayMessages.length === 0) { - return ( -
- No messages with this member -
- ); - } + const emptyStateText = loading + ? 'Loading messages...' + : hasMore + ? 'No loaded messages for this member yet' + : 'No messages with this member'; return (
- {displayMessages.map((msg, idx) => ( - - ))} + {displayMessages.length > 0 ? ( + displayMessages.map((msg, idx) => ( + + )) + ) : ( +
+ {emptyStateText} +
+ )} {hasMore && (