From 7609c548c575e22d172e85eb900669398a70d1f3 Mon Sep 17 00:00:00 2001 From: Mike Date: Sat, 2 May 2026 20:10:42 +0500 Subject: [PATCH 01/14] fix(tests): resolve pre-existing test failures on non-standard environments - TeamProvisioningServiceRelay: add missing stat fields (mode, dev, ino, mtimeMs, ctimeMs, birthtimeMs) to fs mock so new fingerprint-based TeamConfigReader cache can read config in tests - TeamMcpConfigBuilder: export clearResolvedNodePathForTests() to reset module-level node path cache between tests; restore execFileMock implementation in beforeEach after vi.restoreAllMocks() clears it; broaden node binary regex to accept versioned names (node-22, node-20) common on Fedora/RHEL systems - ScheduledTaskExecutor: strip CLAUDECODE at spawn site as last defence so nested-session detection is prevented even when buildProviderAwareCliEnv merges it back in from the outer process environment --- .../schedule/ScheduledTaskExecutor.ts | 4 +- .../services/team/TeamMcpConfigBuilder.ts | 4 + .../team/TeamMcpConfigBuilder.test.ts | 10 +- .../team/TeamProvisioningServiceRelay.test.ts | 142 +++++++++--------- 4 files changed, 88 insertions(+), 72 deletions(-) diff --git a/src/main/services/schedule/ScheduledTaskExecutor.ts b/src/main/services/schedule/ScheduledTaskExecutor.ts index 34cd0761..849250d5 100644 --- a/src/main/services/schedule/ScheduledTaskExecutor.ts +++ b/src/main/services/schedule/ScheduledTaskExecutor.ts @@ -178,7 +178,9 @@ export class ScheduledTaskExecutor { cwd: request.config.cwd, // shellEnv spread after buildEnrichedEnv ensures freshly-resolved values // take precedence over the cached snapshot inside buildEnrichedEnv. - env, + // CLAUDECODE stripped last to prevent nested-session detection regardless + // of what buildProviderAwareCliEnv merges in. + env: { ...env, CLAUDECODE: undefined }, stdio: ['ignore', 'pipe', 'pipe'], }); diff --git a/src/main/services/team/TeamMcpConfigBuilder.ts b/src/main/services/team/TeamMcpConfigBuilder.ts index 21ec5c63..32c19341 100644 --- a/src/main/services/team/TeamMcpConfigBuilder.ts +++ b/src/main/services/team/TeamMcpConfigBuilder.ts @@ -154,6 +154,10 @@ async function hasValidServerCopy(dir: string): Promise { let _resolvedNodePath: string | undefined; +export function clearResolvedNodePathForTests(): void { + _resolvedNodePath = undefined; +} + /** * Find the real `node` binary path. In Electron, process.execPath is the * Electron binary — NOT node — so we must resolve node separately. diff --git a/test/main/services/team/TeamMcpConfigBuilder.test.ts b/test/main/services/team/TeamMcpConfigBuilder.test.ts index 01b50283..cf98ab04 100644 --- a/test/main/services/team/TeamMcpConfigBuilder.test.ts +++ b/test/main/services/team/TeamMcpConfigBuilder.test.ts @@ -46,7 +46,10 @@ vi.mock('@main/utils/pathDecoder', async (importOriginal) => { }); import { setAppDataBasePath, setClaudeBasePathOverride } from '@main/utils/pathDecoder'; -import { TeamMcpConfigBuilder } from '@main/services/team/TeamMcpConfigBuilder'; +import { + TeamMcpConfigBuilder, + clearResolvedNodePathForTests, +} from '@main/services/team/TeamMcpConfigBuilder'; describe('TeamMcpConfigBuilder', () => { const createdPaths: string[] = []; @@ -93,7 +96,7 @@ describe('TeamMcpConfigBuilder', () => { entry: string ): void { expect(server?.args).toEqual([entry]); - expect(server?.command).toMatch(/(^node$|[\\/]node(?:\.exe)?$)/); + expect(server?.command).toMatch(/(^node(?:-\d+)?$|[\\/]node(?:-\d+)?(?:\.exe)?$)/); } function expectNodeTsxSourceEntry( @@ -102,7 +105,7 @@ describe('TeamMcpConfigBuilder', () => { sourceEntry: string ): void { expect(server?.args).toEqual([tsxCli, sourceEntry]); - expect(server?.command).toMatch(/(^node$|[\\/]node(?:\.exe)?$)/); + expect(server?.command).toMatch(/(^node(?:-\d+)?$|[\\/]node(?:-\d+)?(?:\.exe)?$)/); } function getBuiltWorkspaceEntry(): string { @@ -165,6 +168,7 @@ describe('TeamMcpConfigBuilder', () => { } beforeEach(() => { + clearResolvedNodePathForTests(); originalResourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath; tempAppData = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-appdata-')); createdDirs.push(tempAppData); diff --git a/test/main/services/team/TeamProvisioningServiceRelay.test.ts b/test/main/services/team/TeamProvisioningServiceRelay.test.ts index 1b5e8b7e..ab386b99 100644 --- a/test/main/services/team/TeamProvisioningServiceRelay.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRelay.test.ts @@ -16,9 +16,16 @@ const hoisted = vi.hoisted(() => { error.code = 'ENOENT'; throw error; } + const size = Buffer.byteLength(data, 'utf8'); return { isFile: () => true, - size: Buffer.byteLength(data, 'utf8'), + size, + mode: 0o644, + dev: 0, + ino: 0, + mtimeMs: 0, + ctimeMs: 0, + birthtimeMs: 0, }; }); @@ -54,22 +61,20 @@ const hoisted = vi.hoisted(() => { files.set(sentMessagesPath, JSON.stringify(rows)); return message; }), - sendInboxMessage: vi.fn( - (teamName: string, message: Record) => { - const member = - typeof message.member === 'string' - ? message.member - : typeof message.to === 'string' - ? message.to - : 'unknown'; - const p = `/mock/teams/${teamName}/inboxes/${member}.json`; - const current = files.get(p); - const rows = current ? (JSON.parse(current) as unknown[]) : []; - rows.push(message); - files.set(p, JSON.stringify(rows)); - return { deliveredToInbox: true, messageId: 'mock-id', message }; - } - ), + sendInboxMessage: vi.fn((teamName: string, message: Record) => { + const member = + typeof message.member === 'string' + ? message.member + : typeof message.to === 'string' + ? message.to + : 'unknown'; + const p = `/mock/teams/${teamName}/inboxes/${member}.json`; + const current = files.get(p); + const rows = current ? (JSON.parse(current) as unknown[]) : []; + rows.push(message); + files.set(p, JSON.stringify(rows)); + return { deliveredToInbox: true, messageId: 'mock-id', message }; + }), setAtomicWriteShouldFail: (next: boolean) => { atomicWriteShouldFail = next; }, @@ -371,7 +376,9 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { const payload = String(writeSpy.mock.calls[0]?.[0] ?? ''); expect(payload).toContain('Source: system_notification'); expect(payload).toContain('summary looks like \\"Comment on #...\\"'); - expect(payload).toContain('reply via task_add_comment only when you have a substantive board update'); + expect(payload).toContain( + 'reply via task_add_comment only when you have a substantive board update' + ); expect(payload).toContain('Do NOT post acknowledgement-only task comments'); (service as any).handleStreamJsonMessage(run, { @@ -492,9 +499,13 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { runId: 'run-old', }); const inboxDeferred = createDeferred(); - const inboxReader = (service as unknown as { - inboxReader: { getMessagesFor: (team: string, member: string) => Promise }; - }).inboxReader; + const inboxReader = ( + service as unknown as { + inboxReader: { + getMessagesFor: (team: string, member: string) => Promise; + }; + } + ).inboxReader; const inboxSpy = vi .spyOn(inboxReader, 'getMessagesFor') .mockImplementationOnce(async () => await inboxDeferred.promise) @@ -538,14 +549,13 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { const { runId: oldRunId } = attachAliveRun(service, teamName, { runId: 'run-old' }); const inboxDeferred = createDeferred<[typeof permissionMessage]>(); - const inboxReader = (service as unknown as { - inboxReader: { - getMessagesFor: ( - team: string, - member: string - ) => Promise<[typeof permissionMessage]>; - }; - }).inboxReader; + const inboxReader = ( + service as unknown as { + inboxReader: { + getMessagesFor: (team: string, member: string) => Promise<[typeof permissionMessage]>; + }; + } + ).inboxReader; const inboxSpy = vi .spyOn(inboxReader, 'getMessagesFor') .mockImplementationOnce(async () => await inboxDeferred.promise) @@ -654,7 +664,9 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { const payload = String(writeSpy.mock.calls[0]?.[0] ?? ''); expect(payload).toContain('Source: cross_team'); expect(payload).toContain('Cross-team conversationId: conv-explicit'); - expect(payload).toContain('Call the MCP tool named cross_team_send with toTeam=\\"other-team\\"'); + expect(payload).toContain( + 'Call the MCP tool named cross_team_send with toTeam=\\"other-team\\"' + ); expect(payload).toContain('replyToConversationId=\\"conv-explicit\\"'); expect(payload).toContain('NEVER set recipient/to to \\"cross_team_send\\"'); @@ -905,7 +917,11 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { attachAliveRun(service, teamName); const run = (service as unknown as { runs: Map }).runs.get('run-1') as { - silentUserDmForward: { target: string; startedAt: string; mode: 'user_dm' | 'member_inbox_relay' } | null; + silentUserDmForward: { + target: string; + startedAt: string; + mode: 'user_dm' | 'member_inbox_relay'; + } | null; }; run.silentUserDmForward = { target: 'alice', @@ -1072,9 +1088,13 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { runId: 'run-old', }); const inboxDeferred = createDeferred(); - const inboxReader = (service as unknown as { - inboxReader: { getMessagesFor: (team: string, member: string) => Promise }; - }).inboxReader; + const inboxReader = ( + service as unknown as { + inboxReader: { + getMessagesFor: (team: string, member: string) => Promise; + }; + } + ).inboxReader; const inboxSpy = vi .spyOn(inboxReader, 'getMessagesFor') .mockImplementationOnce(async () => await inboxDeferred.promise) @@ -1284,11 +1304,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { await (service as any).markInboxMessagesRead(teamName, 'alice', [ { - messageId: buildLegacyInboxMessageId( - legacyRow.from, - legacyRow.timestamp, - legacyRow.text - ), + messageId: buildLegacyInboxMessageId(legacyRow.from, legacyRow.timestamp, legacyRow.text), }, ]); @@ -1684,9 +1700,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { taskRefs: [{ teamName, taskId: 'task-1', displayId: 'abcd1234' }], }) ); - const rows = JSON.parse( - hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]' - ); + const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'); expect(rows[0].read).toBe(true); }); @@ -1732,9 +1746,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { failed: 0, lastDelivery: { delivered: true, responsePending: true }, }); - const rows = JSON.parse( - hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]' - ); + const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'); expect(rows[0].read).toBe(false); }); @@ -1866,9 +1878,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { teamName, expect.objectContaining({ messageId: 'opencode-terminal-new' }) ); - const rows = JSON.parse( - hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]' - ); + const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'); expect(rows.map((row: { read?: boolean }) => row.read)).toEqual([false, true]); }); @@ -1952,9 +1962,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { 'opencode_attachments_not_supported_for_secondary_runtime' ); vi.mocked(console.warn).mockClear(); - const rows = JSON.parse( - hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]' - ); + const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'); expect(rows[0].read).toBe(false); expect(records[0]).toMatchObject({ inboxMessageId: 'opencode-attachment-1', @@ -1979,7 +1987,10 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { ], }) ); - const identity = await (service as any).resolveOpenCodeMemberDeliveryIdentity(teamName, 'jack'); + const identity = await (service as any).resolveOpenCodeMemberDeliveryIdentity( + teamName, + 'jack' + ); expect(identity.ok).toBe(true); const laneId = identity.laneId; const records: any[] = []; @@ -2000,7 +2011,12 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { return record; }), markAcceptanceUnknown: vi.fn( - async (input: { id: string; reason: string; nextAttemptAt: string; markedAt: string }) => { + async (input: { + id: string; + reason: string; + nextAttemptAt: string; + markedAt: string; + }) => { const record = records.find((candidate) => candidate.id === input.id); Object.assign(record, { status: 'failed_retryable', @@ -2132,9 +2148,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { teamName, expect.objectContaining({ messageId: 'opencode-inflight-new' }) ); - const rows = JSON.parse( - hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]' - ); + const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'); expect(rows.map((row: { read?: boolean }) => row.read)).toEqual([true, true]); }); @@ -2211,9 +2225,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { const relay = await service.relayInboxFileToLiveRecipient(teamName, 'jack'); expect(relay).toMatchObject({ kind: 'opencode_member', relayed: 1 }); - const rows = JSON.parse( - hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]' - ); + const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'); expect(rows[0].read).toBe(true); }); @@ -2303,9 +2315,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { 'OpenCode inbox relay failed for jack/opencode-relay-failed-1' ); vi.mocked(console.warn).mockClear(); - const rows = JSON.parse( - hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]' - ); + const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'); expect(rows[0].read).toBe(false); }); @@ -2337,9 +2347,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { delivered: true, diagnostics: [], }); - vi.spyOn(service as any, 'markInboxMessagesRead').mockRejectedValue( - new Error('write failed') - ); + vi.spyOn(service as any, 'markInboxMessagesRead').mockRejectedValue(new Error('write failed')); const relay = await service.relayOpenCodeMemberInboxMessages(teamName, 'jack'); @@ -2360,9 +2368,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { 'opencode_inbox_mark_read_failed_after_delivery' ); vi.mocked(console.warn).mockClear(); - const rows = JSON.parse( - hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]' - ); + const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'); expect(rows[0].read).toBe(false); }); }); From f764af17d8a5e66ba1018a6b979ce64a24c4b52a Mon Sep 17 00:00:00 2001 From: Mike Date: Sat, 2 May 2026 20:29:19 +0500 Subject: [PATCH 02/14] perf(renderer): wrap heavy view components in React.memo TeamDetailView (3166L), TeamListView (1180L), DateGroupedSessions (1117L), and MarkdownViewer (1198L) were re-rendering on every parent render cycle. Wrapping them in memo() prevents cascading re-renders when their props and store subscriptions have not changed, targeting VSCode-level UI responsiveness. --- .../chat/viewers/MarkdownViewer.tsx | 424 +- .../sidebar/DateGroupedSessions.tsx | 6 +- .../components/team/TeamDetailView.tsx | 4222 +++++++++-------- src/renderer/components/team/TeamListView.tsx | 6 +- 4 files changed, 2344 insertions(+), 2314 deletions(-) diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index 5bd3b977..2c7d3922 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -946,47 +946,200 @@ export const CompactMarkdownPreview: React.FC = Rea } ); -export const MarkdownViewer: React.FC = ({ - content, - maxHeight = 'max-h-96', - className = '', - label, - itemId, - searchQueryOverride, - copyable = false, - bare = false, - baseDir, - teamColorByName: providedTeamColorByName, - onTeamClick: providedOnTeamClick, -}) => { - const [showRaw, setShowRaw] = React.useState(false); - const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS); - const { isLight } = useTheme(); - const { teamColorByName, onTeamClick } = useResolvedViewerTeamContext( - providedTeamColorByName, - providedOnTeamClick - ); +export const MarkdownViewer: React.FC = React.memo( + ({ + content, + maxHeight = 'max-h-96', + className = '', + label, + itemId, + searchQueryOverride, + copyable = false, + bare = false, + baseDir, + teamColorByName: providedTeamColorByName, + onTeamClick: providedOnTeamClick, + }) => { + const [showRaw, setShowRaw] = React.useState(false); + const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS); + const { isLight } = useTheme(); + const { teamColorByName, onTeamClick } = useResolvedViewerTeamContext( + providedTeamColorByName, + providedOnTeamClick + ); - const isTooLarge = content.length > MAX_MARKDOWN_CHARS; - const disableHighlight = content.length > DISABLE_HIGHLIGHT_CHARS; + const isTooLarge = content.length > MAX_MARKDOWN_CHARS; + const disableHighlight = content.length > DISABLE_HIGHLIGHT_CHARS; - // Only re-render if THIS item has search matches - const { searchQuery, searchMatches, currentSearchIndex } = useStore( - useShallow((s) => { - const hasMatch = itemId ? s.searchMatchItemIds.has(itemId) : false; - return { - searchQuery: hasMatch ? s.searchQuery : '', - searchMatches: hasMatch ? s.searchMatches : EMPTY_SEARCH_MATCHES, - currentSearchIndex: hasMatch ? s.currentSearchIndex : -1, - }; - }) - ); + // Only re-render if THIS item has search matches + const { searchQuery, searchMatches, currentSearchIndex } = useStore( + useShallow((s) => { + const hasMatch = itemId ? s.searchMatchItemIds.has(itemId) : false; + return { + searchQuery: hasMatch ? s.searchQuery : '', + searchMatches: hasMatch ? s.searchMatches : EMPTY_SEARCH_MATCHES, + currentSearchIndex: hasMatch ? s.currentSearchIndex : -1, + }; + }) + ); + + // Guard: very large markdown can freeze the renderer (remark/rehype + highlighting). + // For large content, default to a lightweight raw preview with manual expansion. + if (isTooLarge || showRaw) { + const shown = content.slice(0, Math.min(rawLimit, content.length)); + const isTruncated = shown.length < content.length; + return ( +
+ {copyable && !label && ( + + )} + + {label && ( +
+ + + {label} + + + Raw + + + + {copyable && } +
+ )} + + {!label && ( +
+ Raw preview + +
+ )} + + {isTooLarge && ( +
+ Content is very large ({content.length.toLocaleString()} chars). Showing raw preview + to keep the UI responsive. +
+ )} + +
+
+              {shown}
+            
+ {isTruncated && ( +
+ + Showing {shown.length.toLocaleString()} / {content.length.toLocaleString()} chars + +
+ + +
+
+ )} +
+
+ ); + } + + // Create search context (fresh each render so counter starts at 0) + const effectiveQuery = (searchQueryOverride ?? searchQuery).trim(); + const effectiveMatches = searchQueryOverride ? [] : searchMatches; + const effectiveIndex = searchQueryOverride ? -1 : currentSearchIndex; + const searchCtx = + effectiveQuery && itemId + ? createSearchContext(effectiveQuery, itemId, effectiveMatches, effectiveIndex) + : null; + // Local search (Claude logs): use bright highlight for all matches (no "current result" concept). + if (searchCtx && searchQueryOverride) { + searchCtx.forceAllActive = true; + } + + // Create markdown components with optional search highlighting + // When search is active, create fresh each render (match counter is stateful and must start at 0) + // useMemo would cache stale closures when parent re-renders without search deps changing + const baseComponents = searchCtx + ? createViewerMarkdownComponents(searchCtx, isLight, teamColorByName, onTeamClick, copyable) + : isLight + ? createViewerMarkdownComponents(null, true, teamColorByName, onTeamClick, copyable) + : createViewerMarkdownComponents(null, false, teamColorByName, onTeamClick, copyable); + + // When baseDir is set (editor preview), override img to load local files via IPC + const components = baseDir + ? { + ...baseComponents, + img: ({ src, alt }: { src?: string; alt?: string }) => { + if (src && isRelativeUrl(src)) { + return ; + } + return {alt; + }, + } + : baseComponents; - // Guard: very large markdown can freeze the renderer (remark/rehype + highlighting). - // For large content, default to a lightweight raw preview with manual expansion. - if (isTooLarge || showRaw) { - const shown = content.slice(0, Math.min(rawLimit, content.length)); - const isTruncated = shown.length < content.length; return (
= ({ } } > + {/* Copy button overlay (when no label header) */} {copyable && !label && ( )} + {/* Optional header - matches CodeBlockViewer style */} {label && (
= ({ {label} - - Raw - - - - {copyable && } -
- )} - - {!label && ( -
- Raw preview - -
- )} - - {isTooLarge && ( -
- Content is very large ({content.length.toLocaleString()} chars). Showing raw preview to - keep the UI responsive. + {copyable && ( + <> + + + + )}
)} + {/* Markdown content with scroll */}
-
-            {shown}
-          
- {isTruncated && ( -
- - Showing {shown.length.toLocaleString()} / {content.length.toLocaleString()} chars - -
- - -
-
- )} +
+ + {content} + +
); } - - // Create search context (fresh each render so counter starts at 0) - const effectiveQuery = (searchQueryOverride ?? searchQuery).trim(); - const effectiveMatches = searchQueryOverride ? [] : searchMatches; - const effectiveIndex = searchQueryOverride ? -1 : currentSearchIndex; - const searchCtx = - effectiveQuery && itemId - ? createSearchContext(effectiveQuery, itemId, effectiveMatches, effectiveIndex) - : null; - // Local search (Claude logs): use bright highlight for all matches (no "current result" concept). - if (searchCtx && searchQueryOverride) { - searchCtx.forceAllActive = true; - } - - // Create markdown components with optional search highlighting - // When search is active, create fresh each render (match counter is stateful and must start at 0) - // useMemo would cache stale closures when parent re-renders without search deps changing - const baseComponents = searchCtx - ? createViewerMarkdownComponents(searchCtx, isLight, teamColorByName, onTeamClick, copyable) - : isLight - ? createViewerMarkdownComponents(null, true, teamColorByName, onTeamClick, copyable) - : createViewerMarkdownComponents(null, false, teamColorByName, onTeamClick, copyable); - - // When baseDir is set (editor preview), override img to load local files via IPC - const components = baseDir - ? { - ...baseComponents, - img: ({ src, alt }: { src?: string; alt?: string }) => { - if (src && isRelativeUrl(src)) { - return ; - } - return {alt; - }, - } - : baseComponents; - - return ( -
- {/* Copy button overlay (when no label header) */} - {copyable && !label && ( - - )} - - {/* Optional header - matches CodeBlockViewer style */} - {label && ( -
- - - {label} - - {copyable && ( - <> - - - - )} -
- )} - - {/* Markdown content with scroll */} -
-
- - {content} - -
-
-
- ); -}; +); diff --git a/src/renderer/components/sidebar/DateGroupedSessions.tsx b/src/renderer/components/sidebar/DateGroupedSessions.tsx index fe578e07..39827dcb 100644 --- a/src/renderer/components/sidebar/DateGroupedSessions.tsx +++ b/src/renderer/components/sidebar/DateGroupedSessions.tsx @@ -4,7 +4,7 @@ * Supports multi-select with bulk actions and hidden session filtering. */ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { recordRecentProjectOpenPaths } from '@features/recent-projects/renderer'; @@ -184,7 +184,7 @@ function matchesSessionSearch(session: Session, query: string): boolean { return haystack.includes(query); } -export const DateGroupedSessions = (): React.JSX.Element => { +export const DateGroupedSessions = memo((): React.JSX.Element => { const { sessions, selectedSessionId, @@ -1114,4 +1114,4 @@ export const DateGroupedSessions = (): React.JSX.Element => { ); -}; +}); diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index dc992781..99f44e83 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -947,1734 +947,2117 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge( ); }); -export const TeamDetailView = ({ - teamName, - isPaneFocused = false, -}: TeamDetailViewProps): React.JSX.Element => { - const { isLight } = useTheme(); - const [requestChangesTaskId, setRequestChangesTaskId] = useState(null); - const [selectedTask, setSelectedTask] = useState(null); - const [selectedMember, setSelectedMember] = useState(null); - const [selectedMemberView, setSelectedMemberView] = useState<{ - initialTab?: MemberDetailTab; - initialActivityFilter?: MemberActivityFilter; - } | null>(null); - const [pendingRepliesByMember, setPendingRepliesByMember] = useState>(() => - getTeamPendingRepliesState(teamName) - ); - const [createTaskDialog, setCreateTaskDialog] = useState({ - open: false, - defaultSubject: '', - defaultDescription: '', - defaultOwner: '', - }); - const [creatingTask, setCreatingTask] = useState(false); - const [addMemberDialogOpen, setAddMemberDialogOpen] = useState(false); - const [addingMemberLoading, setAddingMemberLoading] = useState(false); - const [removeMemberConfirm, setRemoveMemberConfirm] = useState(null); - const [updatingRoleLoading, setUpdatingRoleLoading] = useState(false); - const [editDialogOpen, setEditDialogOpen] = useState(false); - const [launchDialogState, setLaunchDialogState] = useState<{ - open: boolean; - mode: TeamLaunchDialogMode; - }>({ - open: false, - mode: 'launch', - }); - const [editorOpen, setEditorOpen] = useState(false); - const [graphOpen, setGraphOpen] = useState(false); - const contentRef = useRef(null); - const [messagesPanelMountPoint, setMessagesPanelMountPoint] = useState( - null - ); - const provisioningBannerRef = useRef(null); - const wasProvisioningRef = useRef(false); - const handleOpenGraphTab = useCallback(() => { - const state = useStore.getState(); - const displayName = state.teamByName[teamName]?.displayName ?? teamName; - state.openTab({ - type: 'graph', - label: `${displayName} Graph`, - teamName, - }); - }, [teamName]); - const visualizeButtonStyle = useMemo( - () => - isLight - ? { - background: - 'linear-gradient(135deg, rgba(59,130,246,0.14) 0%, rgba(34,197,94,0.16) 100%)', - borderColor: 'rgba(59,130,246,0.30)', - color: '#0f172a', - boxShadow: '0 10px 24px rgba(59,130,246,0.12)', - } - : { - background: - 'linear-gradient(135deg, rgba(56,189,248,0.18) 0%, rgba(16,185,129,0.16) 100%)', - borderColor: 'rgba(56,189,248,0.34)', - color: 'rgba(236,253,255,0.96)', - boxShadow: '0 12px 28px rgba(8,145,178,0.22)', - }, - [isLight] - ); - - // Set inert on background content when editor/graph overlay is open (a11y focus trap) - useEffect(() => { - const el = contentRef.current; - if (!el) return; - if (editorOpen || graphOpen) { - el.setAttribute('inert', ''); - } else { - el.removeAttribute('inert'); - } - }, [editorOpen, graphOpen]); - - // Listen for Cmd+Shift+G keyboard shortcut — opens graph tab - useEffect(() => { - const handler = (e: Event) => { - const detail = (e as CustomEvent).detail; - if (detail?.teamName === teamName) { - handleOpenGraphTab(); - } - }; - window.addEventListener('toggle-team-graph', handler); - return () => window.removeEventListener('toggle-team-graph', handler); - }, [handleOpenGraphTab, teamName]); - - // Listen for graph tab actions (open task, send message) - useEffect(() => { - const onOpenTask = (e: Event) => { - const { teamName: tn, taskId } = (e as CustomEvent).detail ?? {}; - if (tn !== teamName || !data) return; - const task = data.tasks.find((t: { id: string }) => t.id === taskId); - if (task) setSelectedTask(task); - }; - const onSendMsg = (e: Event) => { - const { teamName: tn, memberName } = (e as CustomEvent).detail ?? {}; - if (tn !== teamName) return; - setSendDialogRecipient(memberName); - setSendDialogDefaultText(undefined); - setSendDialogDefaultChip(undefined); - setSendDialogOpen(true); - }; - const onOpenProfile = (e: Event) => { - const { - teamName: tn, - memberName, - initialTab, - initialActivityFilter, - } = (e as CustomEvent).detail ?? {}; - if (tn !== teamName || !data) return; - const member = members.find((m: { name: string }) => m.name === memberName); - if (member) { - setSelectedMember(member); - setSelectedMemberView({ - initialTab, - initialActivityFilter, - }); - } - }; - const onCreateTask = (e: Event) => { - const { teamName: tn, owner } = (e as CustomEvent).detail ?? {}; - if (tn !== teamName) return; - openCreateTaskDialog('', '', owner ?? ''); - }; - window.addEventListener('graph:open-task', onOpenTask); - window.addEventListener('graph:send-message', onSendMsg); - window.addEventListener('graph:open-profile', onOpenProfile); - window.addEventListener('graph:create-task', onCreateTask); - - // Task action events from graph - const taskAction = (handler: (taskId: string) => void) => (e: Event) => { - const { teamName: tn, taskId } = (e as CustomEvent).detail ?? {}; - if (tn !== teamName || !taskId) return; - handler(taskId); - }; - const onStartTask = taskAction((taskId) => { - void (async () => { - try { - const result = await startTaskByUser(teamName, taskId); - if (data?.isAlive) { - const task = data.tasks.find((t: { id: string }) => t.id === taskId); - try { - if (result.notifiedOwner && task?.owner) { - await api.teams.processSend( - teamName, - `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has started. Please begin working on it.` - ); - } - } catch { - /* best-effort */ - } - } - } catch { - /* error via store */ - } - })(); - }); - const onCompleteTask = taskAction((taskId) => { - void (async () => { - try { - await updateTaskStatus(teamName, taskId, 'completed'); - } catch { - /* */ - } - })(); - }); - const onApproveTask = taskAction((taskId) => { - void (async () => { - try { - await updateKanban(teamName, taskId, { op: 'set_column', column: 'approved' }); - } catch { - /* */ - } - })(); - }); - const onRequestReviewTask = taskAction((taskId) => { - void (async () => { - try { - await requestReview(teamName, taskId); - } catch { - /* */ - } - })(); - }); - const onRequestChangesTask = taskAction((taskId) => { - setRequestChangesTaskId(taskId); - }); - const onCancelTask = taskAction((taskId) => { - void (async () => { - try { - await updateTaskStatus(teamName, taskId, 'pending'); - } catch { - /* */ - } - })(); - }); - const onMoveBackToDoneTask = taskAction((taskId) => { - void (async () => { - try { - await updateKanban(teamName, taskId, { op: 'remove' }); - await updateTaskStatus(teamName, taskId, 'completed'); - } catch { - /* */ - } - })(); - }); - const onDeleteTaskGraph = taskAction((taskId) => handleDeleteTask(taskId)); - - window.addEventListener('graph:start-task', onStartTask); - window.addEventListener('graph:complete-task', onCompleteTask); - window.addEventListener('graph:approve-task', onApproveTask); - window.addEventListener('graph:request-review', onRequestReviewTask); - window.addEventListener('graph:request-changes', onRequestChangesTask); - window.addEventListener('graph:cancel-task', onCancelTask); - window.addEventListener('graph:move-back-to-done', onMoveBackToDoneTask); - window.addEventListener('graph:delete-task', onDeleteTaskGraph); - return () => { - window.removeEventListener('graph:open-task', onOpenTask); - window.removeEventListener('graph:send-message', onSendMsg); - window.removeEventListener('graph:open-profile', onOpenProfile); - window.removeEventListener('graph:create-task', onCreateTask); - window.removeEventListener('graph:start-task', onStartTask); - window.removeEventListener('graph:complete-task', onCompleteTask); - window.removeEventListener('graph:approve-task', onApproveTask); - window.removeEventListener('graph:request-review', onRequestReviewTask); - window.removeEventListener('graph:request-changes', onRequestChangesTask); - window.removeEventListener('graph:cancel-task', onCancelTask); - window.removeEventListener('graph:move-back-to-done', onMoveBackToDoneTask); - window.removeEventListener('graph:delete-task', onDeleteTaskGraph); - }; - }); - - const [sendDialogOpen, setSendDialogOpen] = useState(false); - const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); - const [stoppingTeam, setStoppingTeam] = useState(false); - const [trashOpen, setTrashOpen] = useState(false); - const [sendDialogRecipient, setSendDialogRecipient] = useState(undefined); - const [sendDialogDefaultText, setSendDialogDefaultText] = useState(undefined); - const [sendDialogDefaultChip, setSendDialogDefaultChip] = useState( - undefined - ); - const [replyQuote, setReplyQuote] = useState<{ from: string; text: string } | undefined>( - undefined - ); - const [reviewDialogState, setReviewDialogState] = useState<{ - open: boolean; - mode: 'agent' | 'task'; - memberName?: string; - taskId?: string; - initialFilePath?: string; - taskChangeRequestOptions?: TaskChangeRequestOptions; - }>({ open: false, mode: 'task' }); - - // Active teams for conflict warning in LaunchTeamDialog - const [activeTeamsForLaunch, setActiveTeamsForLaunch] = useState< - { teamName: string; displayName: string; projectPath: string }[] - >([]); - const launchDialogOpen = launchDialogState.open; - - // Session loading and filtering state - const [sessions, setSessions] = useState([]); - const [sessionsLoading, setSessionsLoading] = useState(false); - const [sessionsError, setSessionsError] = useState(null); - const [kanbanFilter, setKanbanFilter] = useState({ - sessionId: null, - selectedOwners: new Set(), - columns: new Set(), - }); - const [kanbanSort, setKanbanSort] = useState({ field: 'updatedAt' }); - - const { - data, - members, - loading, - error, - projects, - repositoryGroups, - initTabUIState, - selectTeam, - updateKanban, - updateKanbanColumnOrder, - updateTaskStatus, - updateTaskOwner, - sendTeamMessage, - requestReview, - createTeamTask, - startTaskByUser, - deleteTeam, - openTeamsTab, - closeTab, - sendingMessage, - sendMessageError, - sendMessageWarning, - sendMessageDebugDetails, - lastSendMessageResult, - reviewActionError, - addMember, - restartMember, - skipMemberForLaunch, - removeMember, - updateMemberRole, - launchTeam, - provisioningError, - clearProvisioningError, - isTeamProvisioning, - refreshTeamData, - refreshTeamMessagesHead, - refreshMemberActivityMeta, - syncTeamPendingReplyRefresh, - kanbanFilterQuery, - clearKanbanFilter, - softDeleteTask, - restoreTask, - fetchDeletedTasks, - deletedTasks, - launchParams, - messagesPanelMode, - messagesPanelWidth, - sidebarLogsHeight, - setMessagesPanelMode, - setMessagesPanelWidth, - setSidebarLogsHeight, - selectReviewFile, - pendingReviewRequest, - setPendingReviewRequest, - } = useStore( - useShallow((s) => ({ - projects: s.projects, - repositoryGroups: s.repositoryGroups, - initTabUIState: s.initTabUIState, - selectTeam: s.selectTeam, - updateKanban: s.updateKanban, - updateKanbanColumnOrder: s.updateKanbanColumnOrder, - updateTaskStatus: s.updateTaskStatus, - updateTaskOwner: s.updateTaskOwner, - sendTeamMessage: s.sendTeamMessage, - requestReview: s.requestReview, - createTeamTask: s.createTeamTask, - startTaskByUser: s.startTaskByUser, - deleteTeam: s.deleteTeam, - openTeamsTab: s.openTeamsTab, - closeTab: s.closeTab, - sendingMessage: s.sendingMessage, - sendMessageError: s.sendMessageError, - sendMessageWarning: s.sendMessageWarning, - sendMessageDebugDetails: s.sendMessageDebugDetails, - lastSendMessageResult: s.lastSendMessageResult, - reviewActionError: s.reviewActionError, - addMember: s.addMember, - restartMember: s.restartMember, - skipMemberForLaunch: s.skipMemberForLaunch, - removeMember: s.removeMember, - updateMemberRole: s.updateMemberRole, - launchTeam: s.launchTeam, - provisioningError: teamName ? (s.provisioningErrorByTeam[teamName] ?? null) : null, - clearProvisioningError: s.clearProvisioningError, - isTeamProvisioning: teamName ? isTeamProvisioningActive(s, teamName) : false, - data: s.selectedTeamName === teamName ? s.selectedTeamData : null, - members: selectResolvedMembersForTeamName(s, teamName), - loading: s.selectedTeamName === teamName ? s.selectedTeamLoading : false, - error: s.selectedTeamName === teamName ? s.selectedTeamError : null, - refreshTeamData: s.refreshTeamData, - refreshTeamMessagesHead: s.refreshTeamMessagesHead, - refreshMemberActivityMeta: s.refreshMemberActivityMeta, - syncTeamPendingReplyRefresh: s.syncTeamPendingReplyRefresh, - kanbanFilterQuery: s.kanbanFilterQuery, - clearKanbanFilter: s.clearKanbanFilter, - softDeleteTask: s.softDeleteTask, - restoreTask: s.restoreTask, - fetchDeletedTasks: s.fetchDeletedTasks, - deletedTasks: s.deletedTasks, - launchParams: teamName ? s.launchParamsByTeam[teamName] : undefined, - messagesPanelMode: s.messagesPanelMode, - messagesPanelWidth: s.messagesPanelWidth, - sidebarLogsHeight: s.sidebarLogsHeight, - setMessagesPanelMode: s.setMessagesPanelMode, - setMessagesPanelWidth: s.setMessagesPanelWidth, - setSidebarLogsHeight: s.setSidebarLogsHeight, - selectReviewFile: s.selectReviewFile, - pendingReviewRequest: s.pendingReviewRequest, - setPendingReviewRequest: s.setPendingReviewRequest, - })) - ); - - const tabId = useTabIdOptional(); - const activeTabId = useStore((s) => s.activeTabId); - const isThisTabActive = tabId ? activeTabId === tabId : false; - const wasInteractiveRef = useRef(false); - - // Messages panel resize - const { isResizing: isMessagesPanelResizing, handleProps: messagesPanelHandleProps } = - useResizablePanel({ - width: messagesPanelWidth, - onWidthChange: setMessagesPanelWidth, - minWidth: 280, - maxWidth: 600, - side: 'left', - }); - const { isResizing: isLogsPanelResizing, handleProps: logsPanelHandleProps } = useResizablePanel({ - height: sidebarLogsHeight, - onHeightChange: setSidebarLogsHeight, - minHeight: 120, - maxHeight: 520, - side: 'top', - }); - - const changeMessagesPanelMode = useCallback( - (mode: TeamMessagesPanelMode) => { - setMessagesPanelMode(mode); - }, - [setMessagesPanelMode] - ); - - useEffect(() => { - if (tabId) { - initTabUIState(tabId); - } - }, [tabId, initTabUIState]); - - useEffect(() => { - setPendingRepliesByMember(getTeamPendingRepliesState(teamName)); - }, [teamName]); - - useEffect(() => { - setTeamPendingRepliesState(teamName, pendingRepliesByMember); - }, [pendingRepliesByMember, teamName]); - - useEffect(() => { - const wasProvisioning = wasProvisioningRef.current; - wasProvisioningRef.current = isTeamProvisioning; - if (!wasProvisioning && isTeamProvisioning) { - provisioningBannerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); - } - }, [isTeamProvisioning]); - - const [kanbanSearch, setKanbanSearch] = useState(''); - - // Open editor overlay when a file reveal is requested (e.g. from chip click) - const pendingRevealFile = useStore((s) => s.editorPendingRevealFile); - useEffect(() => { - if (pendingRevealFile && data?.config.projectPath) { - setEditorOpen(true); - } - }, [pendingRevealFile, data?.config.projectPath]); - - useEffect(() => { - if (!teamName) { - return; - } - void selectTeam(teamName); - void fetchDeletedTasks(teamName); - }, [teamName, selectTeam, fetchDeletedTasks]); - - // Recovery: after HMR, all mounted TeamDetailView effects re-run simultaneously. - // With CSS display-toggle (all tabs stay mounted), the last selectTeam() call wins - // and other tabs get stuck with mismatched data (permanent skeleton). - // Re-trigger selectTeam when this tab becomes active and store data is stale. - const storedTeamName = data?.teamName; - useEffect(() => { - if (!isThisTabActive || !teamName || loading) return; - if (storedTeamName != null && storedTeamName !== teamName) { - void selectTeam(teamName); - } - }, [isThisTabActive, teamName, storedTeamName, loading, selectTeam]); - - useEffect(() => { - const isInteractive = isThisTabActive && isPaneFocused; - const justBecameInteractive = isInteractive && !wasInteractiveRef.current; - wasInteractiveRef.current = isInteractive; - if (!justBecameInteractive || !teamName) { - return; - } - - void (async () => { - try { - const headResult = await refreshTeamMessagesHead(teamName); - if (headResult.feedChanged) { - await refreshMemberActivityMeta(teamName); - } - } catch { - // Best-effort refresh on tab focus. - } - })(); - }, [ - isPaneFocused, - isThisTabActive, - refreshMemberActivityMeta, - refreshTeamMessagesHead, - teamName, - ]); - - // Fetch active teams when launch dialog opens (for conflict warning) - useEffect(() => { - if (!launchDialogOpen) return; - let cancelled = false; - const teamsSnapshot = useStore.getState().teams; - void (async () => { - try { - const aliveList = await api.teams.aliveList(); - if (cancelled) return; - const aliveSet = new Set(aliveList); - const refs = teamsSnapshot - .filter((t) => aliveSet.has(t.teamName) && t.projectPath) - .map((t) => ({ - teamName: t.teamName, - displayName: t.displayName, - projectPath: t.projectPath!, - })); - setActiveTeamsForLaunch(refs); - } catch { - // best-effort - } - })(); - return () => { - cancelled = true; - }; - }, [launchDialogOpen]); - - useEffect(() => { - if (kanbanFilterQuery) { - setKanbanSearch(kanbanFilterQuery); - clearKanbanFilter(); - } - }, [kanbanFilterQuery, clearKanbanFilter]); - - // Load sessions for the team's project - const projectId = useMemo( - () => resolveProjectIdByPath(data?.config.projectPath, projects, repositoryGroups), - [projects, repositoryGroups, data?.config.projectPath] - ); - - const leadSessionId = data?.config.leadSessionId ?? null; - const pendingReplyRefreshSourceId = useId(); - const sessionHistoryKey = useMemo( - () => (data?.config.sessionHistory ?? []).join('|'), - [data?.config.sessionHistory] - ); - - // Keep team message state fresh while we are explicitly waiting for a reply. - // This stays enabled even for hidden mounted tabs, because the waiting state - // is renderer-local and should keep its lightweight polling until resolved. - useEffect(() => { - const hasPendingReplies = Object.keys(pendingRepliesByMember).length > 0; - syncTeamPendingReplyRefresh( - teamName, - pendingReplyRefreshSourceId, - Boolean(data?.isAlive) && hasPendingReplies, - TEAM_PENDING_REPLY_REFRESH_DELAY_MS +export const TeamDetailView = memo( + ({ teamName, isPaneFocused = false }: TeamDetailViewProps): React.JSX.Element => { + const { isLight } = useTheme(); + const [requestChangesTaskId, setRequestChangesTaskId] = useState(null); + const [selectedTask, setSelectedTask] = useState(null); + const [selectedMember, setSelectedMember] = useState(null); + const [selectedMemberView, setSelectedMemberView] = useState<{ + initialTab?: MemberDetailTab; + initialActivityFilter?: MemberActivityFilter; + } | null>(null); + const [pendingRepliesByMember, setPendingRepliesByMember] = useState>( + () => getTeamPendingRepliesState(teamName) ); - - return () => { - syncTeamPendingReplyRefresh(teamName, pendingReplyRefreshSourceId, false); - }; - }, [ - data?.isAlive, - pendingRepliesByMember, - pendingReplyRefreshSourceId, - syncTeamPendingReplyRefresh, - teamName, - ]); - - useEffect(() => { - if (!projectId) return; - - let cancelled = false; - setSessionsLoading(true); - setSessionsError(null); - - void (async () => { - try { - const result = await api.getSessions(projectId); - if (!cancelled) { - setSessions(result); - } - } catch (e) { - if (!cancelled) { - setSessionsError(e instanceof Error ? e.message : 'Failed to load sessions'); - } - } finally { - if (!cancelled) { - setSessionsLoading(false); - } - } - })(); - - return () => { - cancelled = true; - }; - }, [projectId]); - - // Live git branch tracking for the lead project and member worktrees - const teamProjectPath = data?.config.projectPath?.trim() ?? null; - const leadProjectPath = useMemo(() => { - const explicitLeadPath = members.find((member) => isLeadMember(member))?.cwd?.trim(); - return explicitLeadPath && explicitLeadPath.length > 0 ? explicitLeadPath : teamProjectPath; - }, [members, teamProjectPath]); - const branchSyncPaths = useMemo(() => { - const uniquePaths = new Map(); - const addPath = (candidate: string | null | undefined): void => { - const trimmed = candidate?.trim(); - if (!trimmed) return; - const key = normalizePath(trimmed); - if (!key || uniquePaths.has(key)) return; - uniquePaths.set(key, trimmed); - }; - - addPath(leadProjectPath); - for (const member of members) { - addPath(member.cwd); - } - - return Array.from(uniquePaths.values()); - }, [members, leadProjectPath]); - useBranchSync(branchSyncPaths, { live: true }); - const trackedBranches = useStore( - useShallow((s) => - Object.fromEntries( - branchSyncPaths.map((projectPath) => { - const normalizedPath = normalizePath(projectPath); - return [normalizedPath, s.branchByPath[normalizedPath] ?? null] as const; - }) - ) - ) - ); - const leadBranch = leadProjectPath - ? (trackedBranches[normalizePath(leadProjectPath)] ?? null) - : null; - const membersWithLiveBranches = useMemo(() => { - if (!data) return []; - - return members.map((member) => { - const memberPath = member.cwd?.trim(); - const nextGitBranch = - memberPath && !isLeadMember(member) && leadBranch !== null - ? (() => { - const branch = trackedBranches[normalizePath(memberPath)] ?? null; - return branch && branch !== leadBranch ? branch : undefined; - })() - : undefined; - - if (member.gitBranch === nextGitBranch) { - return member; - } - - const nextMember: ResolvedTeamMember = { ...member }; - if (nextGitBranch) { - nextMember.gitBranch = nextGitBranch; - } else { - delete nextMember.gitBranch; - } - return nextMember; - }); - }, [leadBranch, members, trackedBranches]); - const resolvedMemberColorMap = useMemo( - () => buildMemberColorMap(membersWithLiveBranches), - [membersWithLiveBranches] - ); - - // Filter sessions to team-only using sessionHistory + leadSessionId - const teamSessionIds = useMemo(() => { - const sessionIds = new Set(); - if (data?.config.leadSessionId) { - sessionIds.add(data.config.leadSessionId); - } - if (data?.config.sessionHistory) { - for (const id of data.config.sessionHistory) { - sessionIds.add(id); - } - } - return sessionIds; - }, [data?.config.leadSessionId, data?.config.sessionHistory]); - - const teamSessions = useMemo(() => { - // If no session IDs known (backward compat), show all sessions - if (teamSessionIds.size === 0) return sessions; - return sessions.filter((s) => teamSessionIds.has(s.id)); - }, [sessions, teamSessionIds]); - - // Auto-reset session filter if the selected session is no longer in teamSessions - useEffect(() => { - if ( - kanbanFilter.sessionId !== null && - !teamSessions.some((s) => s.id === kanbanFilter.sessionId) - ) { - setKanbanFilter((prev) => ({ ...prev, sessionId: null })); - } - }, [kanbanFilter.sessionId, teamSessions]); - - // Compute time-window for session filtering - const timeWindow = useMemo(() => { - if (kanbanFilter.sessionId === null) return null; - - const sorted = [...teamSessions].sort((a, b) => a.createdAt - b.createdAt); - const idx = sorted.findIndex((s) => s.id === kanbanFilter.sessionId); - if (idx === -1) return null; - - const start = sorted[idx].createdAt; - const end = idx + 1 < sorted.length ? sorted[idx + 1].createdAt : Infinity; - return { start, end }; - }, [kanbanFilter.sessionId, teamSessions]); - - // Filter tasks by time-window and owner - const filteredTasks = useMemo(() => { - if (!data) return []; - let result = data.tasks; - - // Session time-window filter - if (timeWindow) { - result = result.filter((t) => { - if (!t.createdAt) return true; // legacy tasks always included - const ts = new Date(t.createdAt).getTime(); - return ts >= timeWindow.start && ts < timeWindow.end; - }); - } - - // Owner filter - if (kanbanFilter.selectedOwners.size > 0) { - result = result.filter((t) => - t.owner - ? kanbanFilter.selectedOwners.has(t.owner) - : kanbanFilter.selectedOwners.has(UNASSIGNED_OWNER) - ); - } - - return result; - }, [data, timeWindow, kanbanFilter.selectedOwners]); - - const activeMembers = useStableActiveMembers(membersWithLiveBranches); - - const kanbanDisplayTasks = useMemo(() => { - const query = kanbanSearch.trim(); - if (!query) return filteredTasks; - return filterKanbanTasks(filteredTasks, query); - }, [filteredTasks, kanbanSearch]); - - const activeTeammateCount = useMemo( - () => activeMembers.filter((m) => !isLeadMember(m)).length, - [activeMembers] - ); - const leadProviderId = useMemo(() => { - const activeLeadProviderId = activeMembers.find(isLeadMember)?.providerId; - if (activeLeadProviderId) return activeLeadProviderId; - const configuredLeadProviderId = data?.config.members?.find(isLeadMember)?.providerId; - if (configuredLeadProviderId) return configuredLeadProviderId; - return launchParams?.providerId; - }, [activeMembers, data?.config.members, launchParams?.providerId]); - const shouldShowLeadContextUi = canShowLeadContextUi(leadProviderId); - - const taskMap = useMemo(() => new Map((data?.tasks ?? []).map((t) => [t.id, t])), [data?.tasks]); - const taskMapRef = useRef(taskMap); - taskMapRef.current = taskMap; - - const memberTaskCounts = useMemo(() => buildTaskCountsByOwner(data?.tasks ?? []), [data?.tasks]); - - const openCreateTaskDialog = useCallback( - (subject = '', description = '', owner = '', startImmediately?: boolean): void => { - setCreateTaskDialog({ - open: true, - defaultSubject: subject, - defaultDescription: description, - defaultOwner: owner, - defaultStartImmediately: startImmediately, - }); - }, - [] - ); - - const closeCreateTaskDialog = useCallback((): void => { - setCreateTaskDialog({ + const [createTaskDialog, setCreateTaskDialog] = useState({ open: false, defaultSubject: '', defaultDescription: '', defaultOwner: '', - defaultStartImmediately: undefined, }); - }, []); - - const handleCreateTaskFromMessage = useCallback((subject: string, description: string) => { - openCreateTaskDialog(subject, description); - }, []); - - const handleReplyToMessage = useCallback((message: { from: string; text: string }) => { - setSendDialogRecipient(message.from); - setSendDialogDefaultText(undefined); - setSendDialogDefaultChip(undefined); - setReplyQuote({ from: message.from, text: stripAgentBlocks(message.text) }); - setSendDialogOpen(true); - }, []); - - const openLaunchDialog = useCallback((mode: TeamLaunchDialogMode) => { - setLaunchDialogState({ open: true, mode }); - }, []); - - const closeLaunchDialog = useCallback(() => { - setLaunchDialogState((prev) => ({ ...prev, open: false })); - }, []); - - const handleRestartTeam = useCallback(() => { - openLaunchDialog('relaunch'); - }, [openLaunchDialog]); - - const handleLaunchDialogSubmit = useCallback( - async (request: TeamLaunchRequest): Promise => { - await launchTeam(request); - }, - [launchTeam] - ); - - const handleRelaunchDialogSubmit = useCallback( - async ( - request: TeamLaunchRequest, - nextMembers: TeamCreateRequest['members'] - ): Promise => { - await executeTeamRelaunch({ + const [creatingTask, setCreatingTask] = useState(false); + const [addMemberDialogOpen, setAddMemberDialogOpen] = useState(false); + const [addingMemberLoading, setAddingMemberLoading] = useState(false); + const [removeMemberConfirm, setRemoveMemberConfirm] = useState(null); + const [updatingRoleLoading, setUpdatingRoleLoading] = useState(false); + const [editDialogOpen, setEditDialogOpen] = useState(false); + const [launchDialogState, setLaunchDialogState] = useState<{ + open: boolean; + mode: TeamLaunchDialogMode; + }>({ + open: false, + mode: 'launch', + }); + const [editorOpen, setEditorOpen] = useState(false); + const [graphOpen, setGraphOpen] = useState(false); + const contentRef = useRef(null); + const [messagesPanelMountPoint, setMessagesPanelMountPoint] = useState( + null + ); + const provisioningBannerRef = useRef(null); + const wasProvisioningRef = useRef(false); + const handleOpenGraphTab = useCallback(() => { + const state = useStore.getState(); + const displayName = state.teamByName[teamName]?.displayName ?? teamName; + state.openTab({ + type: 'graph', + label: `${displayName} Graph`, teamName, - isTeamAlive: data?.isAlive === true, - request, - members: nextMembers, - stopTeam: (nextTeamName) => api.teams.stop(nextTeamName), - replaceMembers: (nextTeamName, nextRequest) => - api.teams.replaceMembers(nextTeamName, nextRequest), - launchTeam, }); - }, - [data?.isAlive, launchTeam, teamName] - ); + }, [teamName]); + const visualizeButtonStyle = useMemo( + () => + isLight + ? { + background: + 'linear-gradient(135deg, rgba(59,130,246,0.14) 0%, rgba(34,197,94,0.16) 100%)', + borderColor: 'rgba(59,130,246,0.30)', + color: '#0f172a', + boxShadow: '0 10px 24px rgba(59,130,246,0.12)', + } + : { + background: + 'linear-gradient(135deg, rgba(56,189,248,0.18) 0%, rgba(16,185,129,0.16) 100%)', + borderColor: 'rgba(56,189,248,0.34)', + color: 'rgba(236,253,255,0.96)', + boxShadow: '0 12px 28px rgba(8,145,178,0.22)', + }, + [isLight] + ); - const handleChangeLeadRuntime = useCallback(() => { - setEditDialogOpen(false); - openLaunchDialog(data?.isAlive && !isTeamProvisioning ? 'relaunch' : 'launch'); - }, [data?.isAlive, isTeamProvisioning, openLaunchDialog]); - - const handleRestartMember = useCallback( - async (memberName: string): Promise => { - await restartMember(teamName, memberName); - }, - [restartMember, teamName] - ); - - const handleSkipMemberForLaunch = useCallback( - async (memberName: string): Promise => { - await skipMemberForLaunch(teamName, memberName); - }, - [skipMemberForLaunch, teamName] - ); - - const handleSelectMember = useCallback((member: ResolvedTeamMember) => { - setSelectedMember(member); - setSelectedMemberView(null); - }, []); - - const closeSelectedMemberDialog = useCallback(() => { - setSelectedMember(null); - setSelectedMemberView(null); - }, []); - - const handleSendMessageToMember = useCallback((member: ResolvedTeamMember) => { - setSendDialogRecipient(member.name); - setSendDialogDefaultText(undefined); - setSendDialogDefaultChip(undefined); - setReplyQuote(undefined); - setSendDialogOpen(true); - }, []); - - const handleAssignTaskToMember = useCallback( - (member: ResolvedTeamMember) => { - openCreateTaskDialog('', '', member.name); - }, - [openCreateTaskDialog] - ); - - const handleOpenTaskById = useCallback((taskId: string) => { - const task = taskMapRef.current.get(taskId); - if (task) { - setSelectedTask(task); - } - }, []); - - const handleOpenTask = useCallback((task: TeamTaskWithKanban) => { - setSelectedTask(task); - }, []); - - const handleTaskIdClick = useCallback( - (taskId: string) => { - const task = - taskMap.get(taskId) ?? data?.tasks.find((candidate) => candidate.displayId === taskId); - if (task) setSelectedTask(task); - }, - [taskMap, data?.tasks] - ); - - const handleEditorAction = useCallback( - (action: EditorSelectionAction) => { - const chip = createChipFromSelection(action, []) ?? undefined; - if (action.type === 'sendMessage') { - setSendDialogDefaultText(chip ? undefined : action.formattedContext); - setSendDialogDefaultChip(chip); - setSendDialogRecipient(undefined); - setReplyQuote(undefined); - setSendDialogOpen(true); - } else if (action.type === 'createTask') { - if (chip) { - setCreateTaskDialog({ - open: true, - defaultSubject: '', - defaultDescription: '', - defaultOwner: '', - defaultStartImmediately: undefined, - defaultChip: chip, - }); - } else { - openCreateTaskDialog('', action.formattedContext); - } + // Set inert on background content when editor/graph overlay is open (a11y focus trap) + useEffect(() => { + const el = contentRef.current; + if (!el) return; + if (editorOpen || graphOpen) { + el.setAttribute('inert', ''); + } else { + el.removeAttribute('inert'); } - }, + }, [editorOpen, graphOpen]); - [] - ); + // Listen for Cmd+Shift+G keyboard shortcut — opens graph tab + useEffect(() => { + const handler = (e: Event) => { + const detail = (e as CustomEvent).detail; + if (detail?.teamName === teamName) { + handleOpenGraphTab(); + } + }; + window.addEventListener('toggle-team-graph', handler); + return () => window.removeEventListener('toggle-team-graph', handler); + }, [handleOpenGraphTab, teamName]); - const handleStopTeam = useCallback(async (): Promise => { - setStoppingTeam(true); - try { - await api.teams.stop(teamName); - // Backend sends 'disconnected' progress which triggers store refresh, - // but refresh here too as a safety net (e.g. if progress event is missed). - await refreshTeamData(teamName); - } catch (err) { - console.error('Failed to stop team:', err); - } finally { - setStoppingTeam(false); - } - }, [teamName, refreshTeamData]); + // Listen for graph tab actions (open task, send message) + useEffect(() => { + const onOpenTask = (e: Event) => { + const { teamName: tn, taskId } = (e as CustomEvent).detail ?? {}; + if (tn !== teamName || !data) return; + const task = data.tasks.find((t: { id: string }) => t.id === taskId); + if (task) setSelectedTask(task); + }; + const onSendMsg = (e: Event) => { + const { teamName: tn, memberName } = (e as CustomEvent).detail ?? {}; + if (tn !== teamName) return; + setSendDialogRecipient(memberName); + setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); + setSendDialogOpen(true); + }; + const onOpenProfile = (e: Event) => { + const { + teamName: tn, + memberName, + initialTab, + initialActivityFilter, + } = (e as CustomEvent).detail ?? {}; + if (tn !== teamName || !data) return; + const member = members.find((m: { name: string }) => m.name === memberName); + if (member) { + setSelectedMember(member); + setSelectedMemberView({ + initialTab, + initialActivityFilter, + }); + } + }; + const onCreateTask = (e: Event) => { + const { teamName: tn, owner } = (e as CustomEvent).detail ?? {}; + if (tn !== teamName) return; + openCreateTaskDialog('', '', owner ?? ''); + }; + window.addEventListener('graph:open-task', onOpenTask); + window.addEventListener('graph:send-message', onSendMsg); + window.addEventListener('graph:open-profile', onOpenProfile); + window.addEventListener('graph:create-task', onCreateTask); - // Pick up pending review request from GlobalTaskDetailDialog - useEffect(() => { - if (!pendingReviewRequest) return; - setReviewDialogState({ - open: true, - mode: 'task', - taskId: pendingReviewRequest.taskId, - initialFilePath: pendingReviewRequest.filePath, - taskChangeRequestOptions: pendingReviewRequest.requestOptions, - }); - if (pendingReviewRequest.filePath) { - selectReviewFile(pendingReviewRequest.filePath); - } - setPendingReviewRequest(null); - }, [pendingReviewRequest, selectReviewFile, setPendingReviewRequest]); - - // Pick up pending member profile request from MemberHoverCard - const pendingMemberProfile = useStore((s) => s.pendingMemberProfile); - useEffect(() => { - if (!pendingMemberProfile || !data) return; - const member = membersWithLiveBranches.find((m) => m.name === pendingMemberProfile); - if (member) { - setSelectedMember(member); - setSelectedMemberView(null); - } - useStore.getState().closeMemberProfile(); - }, [pendingMemberProfile, membersWithLiveBranches]); - - const handleDeleteTask = useCallback( - (taskId: string) => { - void (async () => { - const confirmed = await confirm({ - title: 'Delete task', - message: `Move task #${deriveTaskDisplayId(taskId)} to trash?`, - confirmLabel: 'Delete', - cancelLabel: 'Cancel', - variant: 'danger', - }); - if (confirmed) { + // Task action events from graph + const taskAction = (handler: (taskId: string) => void) => (e: Event) => { + const { teamName: tn, taskId } = (e as CustomEvent).detail ?? {}; + if (tn !== teamName || !taskId) return; + handler(taskId); + }; + const onStartTask = taskAction((taskId) => { + void (async () => { try { - await softDeleteTask(teamName, taskId); + const result = await startTaskByUser(teamName, taskId); + if (data?.isAlive) { + const task = data.tasks.find((t: { id: string }) => t.id === taskId); + try { + if (result.notifiedOwner && task?.owner) { + await api.teams.processSend( + teamName, + `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has started. Please begin working on it.` + ); + } + } catch { + /* best-effort */ + } + } } catch { - // error via store + /* error via store */ + } + })(); + }); + const onCompleteTask = taskAction((taskId) => { + void (async () => { + try { + await updateTaskStatus(teamName, taskId, 'completed'); + } catch { + /* */ + } + })(); + }); + const onApproveTask = taskAction((taskId) => { + void (async () => { + try { + await updateKanban(teamName, taskId, { op: 'set_column', column: 'approved' }); + } catch { + /* */ + } + })(); + }); + const onRequestReviewTask = taskAction((taskId) => { + void (async () => { + try { + await requestReview(teamName, taskId); + } catch { + /* */ + } + })(); + }); + const onRequestChangesTask = taskAction((taskId) => { + setRequestChangesTaskId(taskId); + }); + const onCancelTask = taskAction((taskId) => { + void (async () => { + try { + await updateTaskStatus(teamName, taskId, 'pending'); + } catch { + /* */ + } + })(); + }); + const onMoveBackToDoneTask = taskAction((taskId) => { + void (async () => { + try { + await updateKanban(teamName, taskId, { op: 'remove' }); + await updateTaskStatus(teamName, taskId, 'completed'); + } catch { + /* */ + } + })(); + }); + const onDeleteTaskGraph = taskAction((taskId) => handleDeleteTask(taskId)); + + window.addEventListener('graph:start-task', onStartTask); + window.addEventListener('graph:complete-task', onCompleteTask); + window.addEventListener('graph:approve-task', onApproveTask); + window.addEventListener('graph:request-review', onRequestReviewTask); + window.addEventListener('graph:request-changes', onRequestChangesTask); + window.addEventListener('graph:cancel-task', onCancelTask); + window.addEventListener('graph:move-back-to-done', onMoveBackToDoneTask); + window.addEventListener('graph:delete-task', onDeleteTaskGraph); + return () => { + window.removeEventListener('graph:open-task', onOpenTask); + window.removeEventListener('graph:send-message', onSendMsg); + window.removeEventListener('graph:open-profile', onOpenProfile); + window.removeEventListener('graph:create-task', onCreateTask); + window.removeEventListener('graph:start-task', onStartTask); + window.removeEventListener('graph:complete-task', onCompleteTask); + window.removeEventListener('graph:approve-task', onApproveTask); + window.removeEventListener('graph:request-review', onRequestReviewTask); + window.removeEventListener('graph:request-changes', onRequestChangesTask); + window.removeEventListener('graph:cancel-task', onCancelTask); + window.removeEventListener('graph:move-back-to-done', onMoveBackToDoneTask); + window.removeEventListener('graph:delete-task', onDeleteTaskGraph); + }; + }); + + const [sendDialogOpen, setSendDialogOpen] = useState(false); + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [stoppingTeam, setStoppingTeam] = useState(false); + const [trashOpen, setTrashOpen] = useState(false); + const [sendDialogRecipient, setSendDialogRecipient] = useState(undefined); + const [sendDialogDefaultText, setSendDialogDefaultText] = useState( + undefined + ); + const [sendDialogDefaultChip, setSendDialogDefaultChip] = useState( + undefined + ); + const [replyQuote, setReplyQuote] = useState<{ from: string; text: string } | undefined>( + undefined + ); + const [reviewDialogState, setReviewDialogState] = useState<{ + open: boolean; + mode: 'agent' | 'task'; + memberName?: string; + taskId?: string; + initialFilePath?: string; + taskChangeRequestOptions?: TaskChangeRequestOptions; + }>({ open: false, mode: 'task' }); + + // Active teams for conflict warning in LaunchTeamDialog + const [activeTeamsForLaunch, setActiveTeamsForLaunch] = useState< + { teamName: string; displayName: string; projectPath: string }[] + >([]); + const launchDialogOpen = launchDialogState.open; + + // Session loading and filtering state + const [sessions, setSessions] = useState([]); + const [sessionsLoading, setSessionsLoading] = useState(false); + const [sessionsError, setSessionsError] = useState(null); + const [kanbanFilter, setKanbanFilter] = useState({ + sessionId: null, + selectedOwners: new Set(), + columns: new Set(), + }); + const [kanbanSort, setKanbanSort] = useState({ field: 'updatedAt' }); + + const { + data, + members, + loading, + error, + projects, + repositoryGroups, + initTabUIState, + selectTeam, + updateKanban, + updateKanbanColumnOrder, + updateTaskStatus, + updateTaskOwner, + sendTeamMessage, + requestReview, + createTeamTask, + startTaskByUser, + deleteTeam, + openTeamsTab, + closeTab, + sendingMessage, + sendMessageError, + sendMessageWarning, + sendMessageDebugDetails, + lastSendMessageResult, + reviewActionError, + addMember, + restartMember, + skipMemberForLaunch, + removeMember, + updateMemberRole, + launchTeam, + provisioningError, + clearProvisioningError, + isTeamProvisioning, + refreshTeamData, + refreshTeamMessagesHead, + refreshMemberActivityMeta, + syncTeamPendingReplyRefresh, + kanbanFilterQuery, + clearKanbanFilter, + softDeleteTask, + restoreTask, + fetchDeletedTasks, + deletedTasks, + launchParams, + messagesPanelMode, + messagesPanelWidth, + sidebarLogsHeight, + setMessagesPanelMode, + setMessagesPanelWidth, + setSidebarLogsHeight, + selectReviewFile, + pendingReviewRequest, + setPendingReviewRequest, + } = useStore( + useShallow((s) => ({ + projects: s.projects, + repositoryGroups: s.repositoryGroups, + initTabUIState: s.initTabUIState, + selectTeam: s.selectTeam, + updateKanban: s.updateKanban, + updateKanbanColumnOrder: s.updateKanbanColumnOrder, + updateTaskStatus: s.updateTaskStatus, + updateTaskOwner: s.updateTaskOwner, + sendTeamMessage: s.sendTeamMessage, + requestReview: s.requestReview, + createTeamTask: s.createTeamTask, + startTaskByUser: s.startTaskByUser, + deleteTeam: s.deleteTeam, + openTeamsTab: s.openTeamsTab, + closeTab: s.closeTab, + sendingMessage: s.sendingMessage, + sendMessageError: s.sendMessageError, + sendMessageWarning: s.sendMessageWarning, + sendMessageDebugDetails: s.sendMessageDebugDetails, + lastSendMessageResult: s.lastSendMessageResult, + reviewActionError: s.reviewActionError, + addMember: s.addMember, + restartMember: s.restartMember, + skipMemberForLaunch: s.skipMemberForLaunch, + removeMember: s.removeMember, + updateMemberRole: s.updateMemberRole, + launchTeam: s.launchTeam, + provisioningError: teamName ? (s.provisioningErrorByTeam[teamName] ?? null) : null, + clearProvisioningError: s.clearProvisioningError, + isTeamProvisioning: teamName ? isTeamProvisioningActive(s, teamName) : false, + data: s.selectedTeamName === teamName ? s.selectedTeamData : null, + members: selectResolvedMembersForTeamName(s, teamName), + loading: s.selectedTeamName === teamName ? s.selectedTeamLoading : false, + error: s.selectedTeamName === teamName ? s.selectedTeamError : null, + refreshTeamData: s.refreshTeamData, + refreshTeamMessagesHead: s.refreshTeamMessagesHead, + refreshMemberActivityMeta: s.refreshMemberActivityMeta, + syncTeamPendingReplyRefresh: s.syncTeamPendingReplyRefresh, + kanbanFilterQuery: s.kanbanFilterQuery, + clearKanbanFilter: s.clearKanbanFilter, + softDeleteTask: s.softDeleteTask, + restoreTask: s.restoreTask, + fetchDeletedTasks: s.fetchDeletedTasks, + deletedTasks: s.deletedTasks, + launchParams: teamName ? s.launchParamsByTeam[teamName] : undefined, + messagesPanelMode: s.messagesPanelMode, + messagesPanelWidth: s.messagesPanelWidth, + sidebarLogsHeight: s.sidebarLogsHeight, + setMessagesPanelMode: s.setMessagesPanelMode, + setMessagesPanelWidth: s.setMessagesPanelWidth, + setSidebarLogsHeight: s.setSidebarLogsHeight, + selectReviewFile: s.selectReviewFile, + pendingReviewRequest: s.pendingReviewRequest, + setPendingReviewRequest: s.setPendingReviewRequest, + })) + ); + + const tabId = useTabIdOptional(); + const activeTabId = useStore((s) => s.activeTabId); + const isThisTabActive = tabId ? activeTabId === tabId : false; + const wasInteractiveRef = useRef(false); + + // Messages panel resize + const { isResizing: isMessagesPanelResizing, handleProps: messagesPanelHandleProps } = + useResizablePanel({ + width: messagesPanelWidth, + onWidthChange: setMessagesPanelWidth, + minWidth: 280, + maxWidth: 600, + side: 'left', + }); + const { isResizing: isLogsPanelResizing, handleProps: logsPanelHandleProps } = + useResizablePanel({ + height: sidebarLogsHeight, + onHeightChange: setSidebarLogsHeight, + minHeight: 120, + maxHeight: 520, + side: 'top', + }); + + const changeMessagesPanelMode = useCallback( + (mode: TeamMessagesPanelMode) => { + setMessagesPanelMode(mode); + }, + [setMessagesPanelMode] + ); + + useEffect(() => { + if (tabId) { + initTabUIState(tabId); + } + }, [tabId, initTabUIState]); + + useEffect(() => { + setPendingRepliesByMember(getTeamPendingRepliesState(teamName)); + }, [teamName]); + + useEffect(() => { + setTeamPendingRepliesState(teamName, pendingRepliesByMember); + }, [pendingRepliesByMember, teamName]); + + useEffect(() => { + const wasProvisioning = wasProvisioningRef.current; + wasProvisioningRef.current = isTeamProvisioning; + if (!wasProvisioning && isTeamProvisioning) { + provisioningBannerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }, [isTeamProvisioning]); + + const [kanbanSearch, setKanbanSearch] = useState(''); + + // Open editor overlay when a file reveal is requested (e.g. from chip click) + const pendingRevealFile = useStore((s) => s.editorPendingRevealFile); + useEffect(() => { + if (pendingRevealFile && data?.config.projectPath) { + setEditorOpen(true); + } + }, [pendingRevealFile, data?.config.projectPath]); + + useEffect(() => { + if (!teamName) { + return; + } + void selectTeam(teamName); + void fetchDeletedTasks(teamName); + }, [teamName, selectTeam, fetchDeletedTasks]); + + // Recovery: after HMR, all mounted TeamDetailView effects re-run simultaneously. + // With CSS display-toggle (all tabs stay mounted), the last selectTeam() call wins + // and other tabs get stuck with mismatched data (permanent skeleton). + // Re-trigger selectTeam when this tab becomes active and store data is stale. + const storedTeamName = data?.teamName; + useEffect(() => { + if (!isThisTabActive || !teamName || loading) return; + if (storedTeamName != null && storedTeamName !== teamName) { + void selectTeam(teamName); + } + }, [isThisTabActive, teamName, storedTeamName, loading, selectTeam]); + + useEffect(() => { + const isInteractive = isThisTabActive && isPaneFocused; + const justBecameInteractive = isInteractive && !wasInteractiveRef.current; + wasInteractiveRef.current = isInteractive; + if (!justBecameInteractive || !teamName) { + return; + } + + void (async () => { + try { + const headResult = await refreshTeamMessagesHead(teamName); + if (headResult.feedChanged) { + await refreshMemberActivityMeta(teamName); + } + } catch { + // Best-effort refresh on tab focus. + } + })(); + }, [ + isPaneFocused, + isThisTabActive, + refreshMemberActivityMeta, + refreshTeamMessagesHead, + teamName, + ]); + + // Fetch active teams when launch dialog opens (for conflict warning) + useEffect(() => { + if (!launchDialogOpen) return; + let cancelled = false; + const teamsSnapshot = useStore.getState().teams; + void (async () => { + try { + const aliveList = await api.teams.aliveList(); + if (cancelled) return; + const aliveSet = new Set(aliveList); + const refs = teamsSnapshot + .filter((t) => aliveSet.has(t.teamName) && t.projectPath) + .map((t) => ({ + teamName: t.teamName, + displayName: t.displayName, + projectPath: t.projectPath!, + })); + setActiveTeamsForLaunch(refs); + } catch { + // best-effort + } + })(); + return () => { + cancelled = true; + }; + }, [launchDialogOpen]); + + useEffect(() => { + if (kanbanFilterQuery) { + setKanbanSearch(kanbanFilterQuery); + clearKanbanFilter(); + } + }, [kanbanFilterQuery, clearKanbanFilter]); + + // Load sessions for the team's project + const projectId = useMemo( + () => resolveProjectIdByPath(data?.config.projectPath, projects, repositoryGroups), + [projects, repositoryGroups, data?.config.projectPath] + ); + + const leadSessionId = data?.config.leadSessionId ?? null; + const pendingReplyRefreshSourceId = useId(); + const sessionHistoryKey = useMemo( + () => (data?.config.sessionHistory ?? []).join('|'), + [data?.config.sessionHistory] + ); + + // Keep team message state fresh while we are explicitly waiting for a reply. + // This stays enabled even for hidden mounted tabs, because the waiting state + // is renderer-local and should keep its lightweight polling until resolved. + useEffect(() => { + const hasPendingReplies = Object.keys(pendingRepliesByMember).length > 0; + syncTeamPendingReplyRefresh( + teamName, + pendingReplyRefreshSourceId, + Boolean(data?.isAlive) && hasPendingReplies, + TEAM_PENDING_REPLY_REFRESH_DELAY_MS + ); + + return () => { + syncTeamPendingReplyRefresh(teamName, pendingReplyRefreshSourceId, false); + }; + }, [ + data?.isAlive, + pendingRepliesByMember, + pendingReplyRefreshSourceId, + syncTeamPendingReplyRefresh, + teamName, + ]); + + useEffect(() => { + if (!projectId) return; + + let cancelled = false; + setSessionsLoading(true); + setSessionsError(null); + + void (async () => { + try { + const result = await api.getSessions(projectId); + if (!cancelled) { + setSessions(result); + } + } catch (e) { + if (!cancelled) { + setSessionsError(e instanceof Error ? e.message : 'Failed to load sessions'); + } + } finally { + if (!cancelled) { + setSessionsLoading(false); } } })(); - }, - [teamName, softDeleteTask] - ); - const handleViewChanges = useCallback( - (taskId: string) => { - const task = taskMap.get(taskId); - setReviewDialogState({ - open: true, - mode: 'task', - taskId, - taskChangeRequestOptions: task ? buildTaskChangeRequestOptions(task) : {}, - }); - }, - [taskMap] - ); + return () => { + cancelled = true; + }; + }, [projectId]); - const handleViewChangesForFile = useCallback( - (taskId: string, filePath?: string) => { - const task = taskMap.get(taskId); - setReviewDialogState({ - open: true, - mode: 'task', - taskId, - initialFilePath: filePath, - taskChangeRequestOptions: task ? buildTaskChangeRequestOptions(task) : {}, - }); - if (filePath) { - selectReviewFile(filePath); + // Live git branch tracking for the lead project and member worktrees + const teamProjectPath = data?.config.projectPath?.trim() ?? null; + const leadProjectPath = useMemo(() => { + const explicitLeadPath = members.find((member) => isLeadMember(member))?.cwd?.trim(); + return explicitLeadPath && explicitLeadPath.length > 0 ? explicitLeadPath : teamProjectPath; + }, [members, teamProjectPath]); + const branchSyncPaths = useMemo(() => { + const uniquePaths = new Map(); + const addPath = (candidate: string | null | undefined): void => { + const trimmed = candidate?.trim(); + if (!trimmed) return; + const key = normalizePath(trimmed); + if (!key || uniquePaths.has(key)) return; + uniquePaths.set(key, trimmed); + }; + + addPath(leadProjectPath); + for (const member of members) { + addPath(member.cwd); } - }, - [selectReviewFile, taskMap] - ); - const handleDeleteTeam = useCallback((): void => { - setDeleteConfirmOpen(true); - }, []); + return Array.from(uniquePaths.values()); + }, [members, leadProjectPath]); + useBranchSync(branchSyncPaths, { live: true }); + const trackedBranches = useStore( + useShallow((s) => + Object.fromEntries( + branchSyncPaths.map((projectPath) => { + const normalizedPath = normalizePath(projectPath); + return [normalizedPath, s.branchByPath[normalizedPath] ?? null] as const; + }) + ) + ) + ); + const leadBranch = leadProjectPath + ? (trackedBranches[normalizePath(leadProjectPath)] ?? null) + : null; + const membersWithLiveBranches = useMemo(() => { + if (!data) return []; - const confirmDeleteTeam = useCallback((): void => { - setDeleteConfirmOpen(false); - void (async () => { - try { - await deleteTeam(teamName); - if (tabId) closeTab(tabId); - openTeamsTab(); - } catch { - // error is shown via store - } - })(); - }, [teamName, deleteTeam, openTeamsTab, closeTab, tabId]); + return members.map((member) => { + const memberPath = member.cwd?.trim(); + const nextGitBranch = + memberPath && !isLeadMember(member) && leadBranch !== null + ? (() => { + const branch = trackedBranches[normalizePath(memberPath)] ?? null; + return branch && branch !== leadBranch ? branch : undefined; + })() + : undefined; - const handleCreateTask = ( - subject: string, - description: string, - owner?: string, - blockedBy?: string[], - related?: string[], - prompt?: string, - startImmediately?: boolean, - descriptionTaskRefs?: TaskRef[], - promptTaskRefs?: TaskRef[] - ): void => { - setCreatingTask(true); - void (async () => { - try { - await createTeamTask(teamName, { - subject, - description: description || undefined, - owner, - blockedBy, - related, - prompt, - descriptionTaskRefs, - promptTaskRefs, - startImmediately, - }); - - if (prompt && owner && data?.isAlive && !isTeamProvisioning && startImmediately !== false) { - const msg = `New task assigned to ${owner}: "${subject}". Instructions:\n${prompt}`; - try { - await api.teams.processSend(teamName, msg); - } catch { - // best-effort - } + if (member.gitBranch === nextGitBranch) { + return member; } - closeCreateTaskDialog(); - } catch { - // error shown via store - } finally { - setCreatingTask(false); - } - })(); - }; - - const sharedMessagesPanelProps = useMemo( - () => ({ - teamName, - onPositionChange: changeMessagesPanelMode, - mountPoint: messagesPanelMountPoint, - members: activeMembers, - tasks: data?.tasks ?? [], - isTeamAlive: data?.isAlive, - timeWindow, - teamSessionIds, - currentLeadSessionId: data?.config.leadSessionId, - pendingRepliesByMember, - onPendingReplyChange: setPendingRepliesByMember, - onMemberClick: handleSelectMember, - onTaskClick: handleOpenTask, - onCreateTaskFromMessage: handleCreateTaskFromMessage, - onReplyToMessage: handleReplyToMessage, - onRestartTeam: handleRestartTeam, - onTaskIdClick: handleTaskIdClick, - inlineScrollContainerRef: contentRef, - }), - [ - activeMembers, - data?.config.leadSessionId, - data?.isAlive, - data?.tasks, - handleCreateTaskFromMessage, - handleOpenTask, - handleReplyToMessage, - handleRestartTeam, - handleSelectMember, - handleTaskIdClick, - messagesPanelMountPoint, - pendingRepliesByMember, - teamName, - teamSessionIds, - timeWindow, - changeMessagesPanelMode, - ] - ); - - if (!teamName) { - return ( -
- Invalid team tab -
+ const nextMember: ResolvedTeamMember = { ...member }; + if (nextGitBranch) { + nextMember.gitBranch = nextGitBranch; + } else { + delete nextMember.gitBranch; + } + return nextMember; + }); + }, [leadBranch, members, trackedBranches]); + const resolvedMemberColorMap = useMemo( + () => buildMemberColorMap(membersWithLiveBranches), + [membersWithLiveBranches] ); - } - const spawnStatusWatcher = ( - - ); - const teamAgentRuntimeWatcher = ( - - ); - const leadContextWatcher = shouldShowLeadContextUi ? ( - - ) : null; + // Filter sessions to team-only using sessionHistory + leadSessionId + const teamSessionIds = useMemo(() => { + const sessionIds = new Set(); + if (data?.config.leadSessionId) { + sessionIds.add(data.config.leadSessionId); + } + if (data?.config.sessionHistory) { + for (const id of data.config.sessionHistory) { + sessionIds.add(id); + } + } + return sessionIds; + }, [data?.config.leadSessionId, data?.config.sessionHistory]); - const renderBody = (): React.JSX.Element => { - if ((loading && !data) || (data && data.teamName !== teamName)) { + const teamSessions = useMemo(() => { + // If no session IDs known (backward compat), show all sessions + if (teamSessionIds.size === 0) return sessions; + return sessions.filter((s) => teamSessionIds.has(s.id)); + }, [sessions, teamSessionIds]); + + // Auto-reset session filter if the selected session is no longer in teamSessions + useEffect(() => { + if ( + kanbanFilter.sessionId !== null && + !teamSessions.some((s) => s.id === kanbanFilter.sessionId) + ) { + setKanbanFilter((prev) => ({ ...prev, sessionId: null })); + } + }, [kanbanFilter.sessionId, teamSessions]); + + // Compute time-window for session filtering + const timeWindow = useMemo(() => { + if (kanbanFilter.sessionId === null) return null; + + const sorted = [...teamSessions].sort((a, b) => a.createdAt - b.createdAt); + const idx = sorted.findIndex((s) => s.id === kanbanFilter.sessionId); + if (idx === -1) return null; + + const start = sorted[idx].createdAt; + const end = idx + 1 < sorted.length ? sorted[idx + 1].createdAt : Infinity; + return { start, end }; + }, [kanbanFilter.sessionId, teamSessions]); + + // Filter tasks by time-window and owner + const filteredTasks = useMemo(() => { + if (!data) return []; + let result = data.tasks; + + // Session time-window filter + if (timeWindow) { + result = result.filter((t) => { + if (!t.createdAt) return true; // legacy tasks always included + const ts = new Date(t.createdAt).getTime(); + return ts >= timeWindow.start && ts < timeWindow.end; + }); + } + + // Owner filter + if (kanbanFilter.selectedOwners.size > 0) { + result = result.filter((t) => + t.owner + ? kanbanFilter.selectedOwners.has(t.owner) + : kanbanFilter.selectedOwners.has(UNASSIGNED_OWNER) + ); + } + + return result; + }, [data, timeWindow, kanbanFilter.selectedOwners]); + + const activeMembers = useStableActiveMembers(membersWithLiveBranches); + + const kanbanDisplayTasks = useMemo(() => { + const query = kanbanSearch.trim(); + if (!query) return filteredTasks; + return filterKanbanTasks(filteredTasks, query); + }, [filteredTasks, kanbanSearch]); + + const activeTeammateCount = useMemo( + () => activeMembers.filter((m) => !isLeadMember(m)).length, + [activeMembers] + ); + const leadProviderId = useMemo(() => { + const activeLeadProviderId = activeMembers.find(isLeadMember)?.providerId; + if (activeLeadProviderId) return activeLeadProviderId; + const configuredLeadProviderId = data?.config.members?.find(isLeadMember)?.providerId; + if (configuredLeadProviderId) return configuredLeadProviderId; + return launchParams?.providerId; + }, [activeMembers, data?.config.members, launchParams?.providerId]); + const shouldShowLeadContextUi = canShowLeadContextUi(leadProviderId); + + const taskMap = useMemo( + () => new Map((data?.tasks ?? []).map((t) => [t.id, t])), + [data?.tasks] + ); + const taskMapRef = useRef(taskMap); + taskMapRef.current = taskMap; + + const memberTaskCounts = useMemo( + () => buildTaskCountsByOwner(data?.tasks ?? []), + [data?.tasks] + ); + + const openCreateTaskDialog = useCallback( + (subject = '', description = '', owner = '', startImmediately?: boolean): void => { + setCreateTaskDialog({ + open: true, + defaultSubject: subject, + defaultDescription: description, + defaultOwner: owner, + defaultStartImmediately: startImmediately, + }); + }, + [] + ); + + const closeCreateTaskDialog = useCallback((): void => { + setCreateTaskDialog({ + open: false, + defaultSubject: '', + defaultDescription: '', + defaultOwner: '', + defaultStartImmediately: undefined, + }); + }, []); + + const handleCreateTaskFromMessage = useCallback((subject: string, description: string) => { + openCreateTaskDialog(subject, description); + }, []); + + const handleReplyToMessage = useCallback((message: { from: string; text: string }) => { + setSendDialogRecipient(message.from); + setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); + setReplyQuote({ from: message.from, text: stripAgentBlocks(message.text) }); + setSendDialogOpen(true); + }, []); + + const openLaunchDialog = useCallback((mode: TeamLaunchDialogMode) => { + setLaunchDialogState({ open: true, mode }); + }, []); + + const closeLaunchDialog = useCallback(() => { + setLaunchDialogState((prev) => ({ ...prev, open: false })); + }, []); + + const handleRestartTeam = useCallback(() => { + openLaunchDialog('relaunch'); + }, [openLaunchDialog]); + + const handleLaunchDialogSubmit = useCallback( + async (request: TeamLaunchRequest): Promise => { + await launchTeam(request); + }, + [launchTeam] + ); + + const handleRelaunchDialogSubmit = useCallback( + async ( + request: TeamLaunchRequest, + nextMembers: TeamCreateRequest['members'] + ): Promise => { + await executeTeamRelaunch({ + teamName, + isTeamAlive: data?.isAlive === true, + request, + members: nextMembers, + stopTeam: (nextTeamName) => api.teams.stop(nextTeamName), + replaceMembers: (nextTeamName, nextRequest) => + api.teams.replaceMembers(nextTeamName, nextRequest), + launchTeam, + }); + }, + [data?.isAlive, launchTeam, teamName] + ); + + const handleChangeLeadRuntime = useCallback(() => { + setEditDialogOpen(false); + openLaunchDialog(data?.isAlive && !isTeamProvisioning ? 'relaunch' : 'launch'); + }, [data?.isAlive, isTeamProvisioning, openLaunchDialog]); + + const handleRestartMember = useCallback( + async (memberName: string): Promise => { + await restartMember(teamName, memberName); + }, + [restartMember, teamName] + ); + + const handleSkipMemberForLaunch = useCallback( + async (memberName: string): Promise => { + await skipMemberForLaunch(teamName, memberName); + }, + [skipMemberForLaunch, teamName] + ); + + const handleSelectMember = useCallback((member: ResolvedTeamMember) => { + setSelectedMember(member); + setSelectedMemberView(null); + }, []); + + const closeSelectedMemberDialog = useCallback(() => { + setSelectedMember(null); + setSelectedMemberView(null); + }, []); + + const handleSendMessageToMember = useCallback((member: ResolvedTeamMember) => { + setSendDialogRecipient(member.name); + setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); + setReplyQuote(undefined); + setSendDialogOpen(true); + }, []); + + const handleAssignTaskToMember = useCallback( + (member: ResolvedTeamMember) => { + openCreateTaskDialog('', '', member.name); + }, + [openCreateTaskDialog] + ); + + const handleOpenTaskById = useCallback((taskId: string) => { + const task = taskMapRef.current.get(taskId); + if (task) { + setSelectedTask(task); + } + }, []); + + const handleOpenTask = useCallback((task: TeamTaskWithKanban) => { + setSelectedTask(task); + }, []); + + const handleTaskIdClick = useCallback( + (taskId: string) => { + const task = + taskMap.get(taskId) ?? data?.tasks.find((candidate) => candidate.displayId === taskId); + if (task) setSelectedTask(task); + }, + [taskMap, data?.tasks] + ); + + const handleEditorAction = useCallback( + (action: EditorSelectionAction) => { + const chip = createChipFromSelection(action, []) ?? undefined; + if (action.type === 'sendMessage') { + setSendDialogDefaultText(chip ? undefined : action.formattedContext); + setSendDialogDefaultChip(chip); + setSendDialogRecipient(undefined); + setReplyQuote(undefined); + setSendDialogOpen(true); + } else if (action.type === 'createTask') { + if (chip) { + setCreateTaskDialog({ + open: true, + defaultSubject: '', + defaultDescription: '', + defaultOwner: '', + defaultStartImmediately: undefined, + defaultChip: chip, + }); + } else { + openCreateTaskDialog('', action.formattedContext); + } + } + }, + + [] + ); + + const handleStopTeam = useCallback(async (): Promise => { + setStoppingTeam(true); + try { + await api.teams.stop(teamName); + // Backend sends 'disconnected' progress which triggers store refresh, + // but refresh here too as a safety net (e.g. if progress event is missed). + await refreshTeamData(teamName); + } catch (err) { + console.error('Failed to stop team:', err); + } finally { + setStoppingTeam(false); + } + }, [teamName, refreshTeamData]); + + // Pick up pending review request from GlobalTaskDetailDialog + useEffect(() => { + if (!pendingReviewRequest) return; + setReviewDialogState({ + open: true, + mode: 'task', + taskId: pendingReviewRequest.taskId, + initialFilePath: pendingReviewRequest.filePath, + taskChangeRequestOptions: pendingReviewRequest.requestOptions, + }); + if (pendingReviewRequest.filePath) { + selectReviewFile(pendingReviewRequest.filePath); + } + setPendingReviewRequest(null); + }, [pendingReviewRequest, selectReviewFile, setPendingReviewRequest]); + + // Pick up pending member profile request from MemberHoverCard + const pendingMemberProfile = useStore((s) => s.pendingMemberProfile); + useEffect(() => { + if (!pendingMemberProfile || !data) return; + const member = membersWithLiveBranches.find((m) => m.name === pendingMemberProfile); + if (member) { + setSelectedMember(member); + setSelectedMemberView(null); + } + useStore.getState().closeMemberProfile(); + }, [pendingMemberProfile, membersWithLiveBranches]); + + const handleDeleteTask = useCallback( + (taskId: string) => { + void (async () => { + const confirmed = await confirm({ + title: 'Delete task', + message: `Move task #${deriveTaskDisplayId(taskId)} to trash?`, + confirmLabel: 'Delete', + cancelLabel: 'Cancel', + variant: 'danger', + }); + if (confirmed) { + try { + await softDeleteTask(teamName, taskId); + } catch { + // error via store + } + } + })(); + }, + [teamName, softDeleteTask] + ); + + const handleViewChanges = useCallback( + (taskId: string) => { + const task = taskMap.get(taskId); + setReviewDialogState({ + open: true, + mode: 'task', + taskId, + taskChangeRequestOptions: task ? buildTaskChangeRequestOptions(task) : {}, + }); + }, + [taskMap] + ); + + const handleViewChangesForFile = useCallback( + (taskId: string, filePath?: string) => { + const task = taskMap.get(taskId); + setReviewDialogState({ + open: true, + mode: 'task', + taskId, + initialFilePath: filePath, + taskChangeRequestOptions: task ? buildTaskChangeRequestOptions(task) : {}, + }); + if (filePath) { + selectReviewFile(filePath); + } + }, + [selectReviewFile, taskMap] + ); + + const handleDeleteTeam = useCallback((): void => { + setDeleteConfirmOpen(true); + }, []); + + const confirmDeleteTeam = useCallback((): void => { + setDeleteConfirmOpen(false); + void (async () => { + try { + await deleteTeam(teamName); + if (tabId) closeTab(tabId); + openTeamsTab(); + } catch { + // error is shown via store + } + })(); + }, [teamName, deleteTeam, openTeamsTab, closeTab, tabId]); + + const handleCreateTask = ( + subject: string, + description: string, + owner?: string, + blockedBy?: string[], + related?: string[], + prompt?: string, + startImmediately?: boolean, + descriptionTaskRefs?: TaskRef[], + promptTaskRefs?: TaskRef[] + ): void => { + setCreatingTask(true); + void (async () => { + try { + await createTeamTask(teamName, { + subject, + description: description || undefined, + owner, + blockedBy, + related, + prompt, + descriptionTaskRefs, + promptTaskRefs, + startImmediately, + }); + + if ( + prompt && + owner && + data?.isAlive && + !isTeamProvisioning && + startImmediately !== false + ) { + const msg = `New task assigned to ${owner}: "${subject}". Instructions:\n${prompt}`; + try { + await api.teams.processSend(teamName, msg); + } catch { + // best-effort + } + } + + closeCreateTaskDialog(); + } catch { + // error shown via store + } finally { + setCreatingTask(false); + } + })(); + }; + + const sharedMessagesPanelProps = useMemo( + () => ({ + teamName, + onPositionChange: changeMessagesPanelMode, + mountPoint: messagesPanelMountPoint, + members: activeMembers, + tasks: data?.tasks ?? [], + isTeamAlive: data?.isAlive, + timeWindow, + teamSessionIds, + currentLeadSessionId: data?.config.leadSessionId, + pendingRepliesByMember, + onPendingReplyChange: setPendingRepliesByMember, + onMemberClick: handleSelectMember, + onTaskClick: handleOpenTask, + onCreateTaskFromMessage: handleCreateTaskFromMessage, + onReplyToMessage: handleReplyToMessage, + onRestartTeam: handleRestartTeam, + onTaskIdClick: handleTaskIdClick, + inlineScrollContainerRef: contentRef, + }), + [ + activeMembers, + data?.config.leadSessionId, + data?.isAlive, + data?.tasks, + handleCreateTaskFromMessage, + handleOpenTask, + handleReplyToMessage, + handleRestartTeam, + handleSelectMember, + handleTaskIdClick, + messagesPanelMountPoint, + pendingRepliesByMember, + teamName, + teamSessionIds, + timeWindow, + changeMessagesPanelMode, + ] + ); + + if (!teamName) { return ( -
-
-
- -
-
-
-
-
-
+
+ Invalid team tab
); } - if (error === 'TEAM_DRAFT') { - const draftTeamSummary = useStore.getState().teamByName[teamName]; - const draftDisplayName = draftTeamSummary?.displayName || teamName; - const draftMemberCount = draftTeamSummary?.memberCount ?? 0; + const spawnStatusWatcher = ( + + ); + const teamAgentRuntimeWatcher = ( + + ); + const leadContextWatcher = shouldShowLeadContextUi ? ( + + ) : null; - return ( - <> -
+ const renderBody = (): React.JSX.Element => { + if ((loading && !data) || (data && data.teamName !== teamName)) { + return ( +
+
-
-
-

Team not launched yet

-

- This is a draft team - {draftDisplayName} has been configured - with {draftMemberCount} member - {draftMemberCount === 1 ? '' : 's'} but hasn't been provisioned by CLI yet. - Click Launch to select a model and start the team. -

-
- - +
+
+
+
+
+
+ ); + } + + if (error === 'TEAM_DRAFT') { + const draftTeamSummary = useStore.getState().teamByName[teamName]; + const draftDisplayName = draftTeamSummary?.displayName || teamName; + const draftMemberCount = draftTeamSummary?.memberCount ?? 0; + + return ( + <> +
+
+ +
+
+
+

Team not launched yet

+

+ This is a draft team - {draftDisplayName} has been configured + with {draftMemberCount} member + {draftMemberCount === 1 ? '' : 's'} but hasn't been provisioned by CLI yet. + Click Launch to select a model and start the team. +

+
+ + +
-
- - - ); - } - - if (error) { - return ( -
-
-

Failed to load team

-

{error}

-
-
- ); - } - - if (!data) { - return ( -
-
- -
-
- Team data will appear once provisioning completes -
-
- ); - } - - const headerColorSet = data.config.color - ? getTeamColorSet(data.config.color) - : nameColorSet(data.config.name); - - return ( - <> -
- - - {/* Messages sidebar (left, after context panel) */} - - + + ); + } + + if (error) { + return ( +
+
+

Failed to load team

+

{error}

+
+
+ ); + } + + if (!data) { + return ( +
+
+ +
+
+ Team data will appear once provisioning completes +
+
+ ); + } + + const headerColorSet = data.config.color + ? getTeamColorSet(data.config.color) + : nameColorSet(data.config.name); + + return ( + <> +
+ + + {/* Messages sidebar (left, after context panel) */} + - - - + isActive={isThisTabActive} + isFocused={isPaneFocused} + > + + + -
-
-
- {headerColorSet ? ( +
+
+
+ {headerColorSet ? ( +
+ ) : null}
- ) : null} -
-
-
-

- {data.config.name} -

- {data.isAlive && ( - - - Running - - )} - {!data.isAlive && isTeamProvisioning && ( - - - Launching... - - )} + className={cn( + 'flex items-start justify-between gap-2', + headerColorSet && 'relative z-10' + )} + > +
+
+

+ {data.config.name} +

+ {data.isAlive && ( + + + Running + + )} + {!data.isAlive && isTeamProvisioning && ( + + + Launching... + + )} +
-
-
- {data.isAlive && ( +
+ {data.isAlive && ( + + + + + Stop team + + )} + + + + + + {isTeamProvisioning + ? 'Edit team is unavailable while provisioning is still in progress' + : 'Edit team'} + + - Stop team + Delete team - )} - - - - - - {isTeamProvisioning - ? 'Edit team is unavailable while provisioning is still in progress' - : 'Edit team'} - - - - - - - Delete team - +
-
- {data.config.description && ( -

- {data.config.description} -

- )} -
-
- {data.config.projectPath && ( - - - - - - {data.config.projectPath - .replace(/\\/g, '/') - .split('/') - .filter(Boolean) - .pop() ?? data.config.projectPath} - - - - - {formatProjectPath(data.config.projectPath)} - - - - - - - - Open project in built-in editor - - - )} - {leadBranch && ( - - - {leadBranch} - - )} -
- - - - - Open team graph - -
- {(() => { - const currentPath = data.config.projectPath; - const history = data.config.projectPathHistory?.filter((p) => p !== currentPath); - if (!history || history.length === 0) return null; - return ( -
- - - Previous: {history.map((p) => formatProjectPath(p)).join(', ')} - + {data.config.description} +

+ )} +
+
+ {data.config.projectPath && ( + + + + + + {data.config.projectPath + .replace(/\\/g, '/') + .split('/') + .filter(Boolean) + .pop() ?? data.config.projectPath} + + + + + {formatProjectPath(data.config.projectPath)} + + + + + + + + Open project in built-in editor + + + )} + {leadBranch && ( + + + {leadBranch} + + )}
- ); - })()} -
- - {!data.isAlive && !isTeamProvisioning ? ( - openLaunchDialog('launch')} - /> - ) : null} - -
- -
- - {data.warnings?.some((warning) => warning.toLowerCase().includes('kanban')) ? ( -
- Failed to fully load kanban. Displaying safe data. + + + + + Open team graph + +
+ {(() => { + const currentPath = data.config.projectPath; + const history = data.config.projectPathHistory?.filter( + (p) => p !== currentPath + ); + if (!history || history.length === 0) return null; + return ( +
+ + + Previous: {history.map((p) => formatProjectPath(p)).join(', ')} + +
+ ); + })()}
- ) : null} - {reviewActionError ? ( -
- {reviewActionError} -
- ) : null} - } - badge={activeTeammateCount === 0 ? 'Solo' : activeTeammateCount} - defaultOpen - action={ -
+ {!data.isAlive && !isTeamProvisioning ? ( + openLaunchDialog('launch')} + /> + ) : null} + +
+ +
+ + {data.warnings?.some((warning) => warning.toLowerCase().includes('kanban')) ? ( +
+ Failed to fully load kanban. Displaying safe data. +
+ ) : null} + {reviewActionError ? ( +
+ {reviewActionError} +
+ ) : null} + + } + badge={activeTeammateCount === 0 ? 'Solo' : activeTeammateCount} + defaultOpen + action={ +
+ +
+ } + > + +
+ + } + defaultOpen={false} + > + + setKanbanFilter((prev) => ({ ...prev, sessionId: id })) + } + projectPath={data.config.projectPath} + /> + + + } + badge={filteredTasks.length} + defaultOpen + forceOpen={kanbanSearch.trim().length > 0} + action={ -
- } - > - -
- - } - defaultOpen={false} - > - setKanbanFilter((prev) => ({ ...prev, sessionId: id }))} - projectPath={data.config.projectPath} - /> - - - } - badge={filteredTasks.length} - defaultOpen - forceOpen={kanbanSearch.trim().length > 0} - action={ - - } - > - } - onRequestReview={(taskId) => { - void (async () => { - try { - await requestReview(teamName, taskId); - } catch { - // error via store - } - })(); - }} - onApprove={(taskId) => { - void (async () => { - try { - await updateKanban(teamName, taskId, { - op: 'set_column', - column: 'approved', - }); - } catch { - // error via store - } - })(); - }} - onRequestChanges={(taskId) => { - setRequestChangesTaskId(taskId); - }} - onMoveBackToDone={(taskId) => { - void (async () => { - try { - await updateKanban(teamName, taskId, { op: 'remove' }); - await updateTaskStatus(teamName, taskId, 'completed'); - } catch { - // error via store - } - })(); - }} - onStartTask={(taskId) => { - void (async () => { - try { - const result = await startTaskByUser(teamName, taskId); - if (data?.isAlive) { - const task = data.tasks.find((t) => t.id === taskId); - try { - if (result.notifiedOwner && task?.owner) { - await api.teams.processSend( - teamName, - `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has started. Please begin working on it.` - ); - } else if (!result.notifiedOwner) { - const desc = task?.description?.trim() - ? `\nDescription: ${task.description.trim()}` + > + + } + onRequestReview={(taskId) => { + void (async () => { + try { + await requestReview(teamName, taskId); + } catch { + // error via store + } + })(); + }} + onApprove={(taskId) => { + void (async () => { + try { + await updateKanban(teamName, taskId, { + op: 'set_column', + column: 'approved', + }); + } catch { + // error via store + } + })(); + }} + onRequestChanges={(taskId) => { + setRequestChangesTaskId(taskId); + }} + onMoveBackToDone={(taskId) => { + void (async () => { + try { + await updateKanban(teamName, taskId, { op: 'remove' }); + await updateTaskStatus(teamName, taskId, 'completed'); + } catch { + // error via store + } + })(); + }} + onStartTask={(taskId) => { + void (async () => { + try { + const result = await startTaskByUser(teamName, taskId); + if (data?.isAlive) { + const task = data.tasks.find((t) => t.id === taskId); + try { + if (result.notifiedOwner && task?.owner) { + await api.teams.processSend( + teamName, + `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has started. Please begin working on it.` + ); + } else if (!result.notifiedOwner) { + const desc = task?.description?.trim() + ? `\nDescription: ${task.description.trim()}` + : ''; + await api.teams.processSend( + teamName, + `Task #${deriveTaskDisplayId(taskId)} "${task?.subject ?? ''}" has been moved to IN PROGRESS but has no assignee.${desc}\nPlease assign it to an available team member, or take it yourself if everyone is busy.` + ); + } + } catch { + // best-effort + } + } + } catch { + // error via store + } + })(); + }} + onCompleteTask={(taskId) => { + void (async () => { + try { + await updateTaskStatus(teamName, taskId, 'completed'); + } catch { + // error via store + } + })(); + }} + onCancelTask={(taskId) => { + void (async () => { + try { + const task = data?.tasks.find((t) => t.id === taskId); + await updateTaskStatus(teamName, taskId, 'pending'); + + // Notify assignee directly via inbox — they'll see it immediately + if (task?.owner) { + try { + await api.teams.sendMessage(teamName, { + member: task.owner, + text: `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has been CANCELLED by the user and moved back to TODO. Stop working on it immediately.`, + summary: `Task ${formatTaskDisplayLabel(task)} cancelled`, + }); + } catch { + // best-effort + } + } + + // Also notify team lead so they can reassign/coordinate + if (data?.isAlive) { + try { + const ownerSuffix = task?.owner + ? ` ${task.owner} has been notified to stop.` : ''; await api.teams.processSend( teamName, - `Task #${deriveTaskDisplayId(taskId)} "${task?.subject ?? ''}" has been moved to IN PROGRESS but has no assignee.${desc}\nPlease assign it to an available team member, or take it yourself if everyone is busy.` + `Task #${deriveTaskDisplayId(taskId)} "${task?.subject ?? ''}" has been cancelled and moved back to TODO.${ownerSuffix}` ); + } catch { + // best-effort } - } catch { - // best-effort } + } catch { + // error via store } - } catch { - // error via store + })(); + }} + onColumnOrderChange={(columnId, orderedTaskIds) => { + void (async () => { + try { + await updateKanbanColumnOrder(teamName, columnId, orderedTaskIds); + } catch { + // error via store + } + })(); + }} + onScrollToTask={(taskId) => { + const el = document.querySelector(`[data-task-id="${taskId}"]`); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + el.classList.remove('kanban-card-focus-pulse'); + void (el as HTMLElement).offsetWidth; + el.classList.add('kanban-card-focus-pulse'); + el.addEventListener( + 'animationend', + () => el.classList.remove('kanban-card-focus-pulse'), + { once: true } + ); } - })(); - }} - onCompleteTask={(taskId) => { - void (async () => { - try { - await updateTaskStatus(teamName, taskId, 'completed'); - } catch { - // error via store - } - })(); - }} - onCancelTask={(taskId) => { - void (async () => { - try { - const task = data?.tasks.find((t) => t.id === taskId); - await updateTaskStatus(teamName, taskId, 'pending'); + }} + onTaskClick={(task) => setSelectedTask(task)} + onViewChanges={handleViewChanges} + onAddTask={(startImmediately) => + openCreateTaskDialog('', '', '', startImmediately) + } + onDeleteTask={handleDeleteTask} + deletedTaskCount={deletedTasks.length} + onOpenTrash={() => setTrashOpen(true)} + /> + - // Notify assignee directly via inbox — they'll see it immediately - if (task?.owner) { - try { - await api.teams.sendMessage(teamName, { - member: task.owner, - text: `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has been CANCELLED by the user and moved back to TODO. Stop working on it immediately.`, - summary: `Task ${formatTaskDisplayLabel(task)} cancelled`, - }); - } catch { - // best-effort - } - } + } + defaultOpen={false} + > + + - // Also notify team lead so they can reassign/coordinate - if (data?.isAlive) { - try { - const ownerSuffix = task?.owner - ? ` ${task.owner} has been notified to stop.` - : ''; - await api.teams.processSend( - teamName, - `Task #${deriveTaskDisplayId(taskId)} "${task?.subject ?? ''}" has been cancelled and moved back to TODO.${ownerSuffix}` - ); - } catch { - // best-effort - } - } - } catch { - // error via store - } - })(); - }} - onColumnOrderChange={(columnId, orderedTaskIds) => { + {(data.processes?.length ?? 0) > 0 && ( + } + badge={data.processes.filter((p) => !p.stoppedAt).length} + headerExtra={ + data.processes.some((p) => !p.stoppedAt) ? ( + + + + + ) : null + } + defaultOpen + > + + + )} + + {messagesPanelMode !== 'sidebar' && } + + {messagesPanelMode === 'inline' && ( + + )} + + setRequestChangesTaskId(null)} + onSubmit={(comment, taskRefs) => { + if (!requestChangesTaskId) { + return; + } void (async () => { try { - await updateKanbanColumnOrder(teamName, columnId, orderedTaskIds); + await updateKanban(teamName, requestChangesTaskId, { + op: 'request_changes', + comment, + taskRefs, + }); + setRequestChangesTaskId(null); } catch { - // error via store + // error state is handled in the store and shown in the view } })(); }} + /> + + { + const name = selectedMember?.name ?? ''; + closeSelectedMemberDialog(); + setSendDialogRecipient(name || undefined); + setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); + setReplyQuote(undefined); + setSendDialogOpen(true); + }} + onAssignTask={() => { + const name = selectedMember?.name ?? ''; + closeSelectedMemberDialog(); + openCreateTaskDialog('', '', name); + }} + onRestartMember={handleRestartMember} + onTaskClick={(task) => { + closeSelectedMemberDialog(); + setSelectedTask(task); + }} + onUpdateRole={async (memberName, role) => { + setUpdatingRoleLoading(true); + try { + await updateMemberRole(teamName, memberName, role); + // Optimistically update local selectedMember to reflect new role + setSelectedMember((prev) => { + if (prev?.name !== memberName) return prev; + const normalized = + typeof role === 'string' && role.trim() ? role.trim() : undefined; + return { ...prev, role: normalized }; + }); + } finally { + setUpdatingRoleLoading(false); + } + }} + updatingRole={updatingRoleLoading} + onRemoveMember={() => { + const name = selectedMember?.name; + if (!name) return; + setRemoveMemberConfirm(name); + }} + onViewMemberChanges={(memberName, filePath) => { + closeSelectedMemberDialog(); + setReviewDialogState({ + open: true, + mode: 'agent', + memberName, + initialFilePath: filePath, + }); + }} + /> + + + + !isLeadMember(m))} + leadMember={membersWithLiveBranches.find((m) => isLeadMember(m)) ?? null} + resolvedMemberColorMap={resolvedMemberColorMap} + isTeamAlive={data.isAlive && !isTeamProvisioning} + isTeamProvisioning={isTeamProvisioning} + projectPath={data.config.projectPath} + onClose={() => setEditDialogOpen(false)} + onChangeLeadRuntime={handleChangeLeadRuntime} + onSaved={() => void selectTeam(teamName)} + /> + + m.name)} + existingMembers={membersWithLiveBranches} + projectPath={data.config.projectPath} + adding={addingMemberLoading} + onClose={() => setAddMemberDialogOpen(false)} + onAdd={(entries: AddMemberEntry[]) => { + setAddingMemberLoading(true); + void (async () => { + try { + for (const entry of entries) { + await addMember(teamName, { + name: entry.name, + role: entry.role, + workflow: entry.workflow, + isolation: entry.isolation, + providerId: entry.providerId, + model: entry.model, + effort: entry.effort, + }); + } + setAddMemberDialogOpen(false); + } catch { + // error shown via store + } finally { + setAddingMemberLoading(false); + } + })(); + }} + /> + + { + if (!open) setRemoveMemberConfirm(null); + }} + > + + + Remove member + + Remove “{removeMemberConfirm}” from the team? Tasks and messages + will be preserved, but this name cannot be reused. + + + + + + + + + + + + + Delete team + + Delete team “{data.config.name}”? This action is irreversible. + All team data and tasks will be deleted. + + + + + + + + + + + + { + const sentAtMs = Date.now(); + setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs })); + try { + const result = await sendTeamMessage(teamName, { + member, + text, + summary, + attachments, + actionMode, + taskRefs, + }); + if ( + result?.runtimeDelivery?.attempted === true && + result.runtimeDelivery.delivered === false + ) { + setPendingRepliesByMember((prev) => { + if (prev[member] !== sentAtMs) return prev; + const next = { ...prev }; + delete next[member]; + return next; + }); + } + return result; + } catch (error) { + setPendingRepliesByMember((prev) => { + if (prev[member] !== sentAtMs) return prev; + const next = { ...prev }; + delete next[member]; + return next; + }); + throw error; + } + }} + onClose={() => { + setSendDialogOpen(false); + setReplyQuote(undefined); + setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); + }} + /> + + setSelectedTask(null)} onScrollToTask={(taskId) => { + setSelectedTask(null); const el = document.querySelector(`[data-task-id="${taskId}"]`); if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); @@ -2688,479 +3071,124 @@ export const TeamDetailView = ({ ); } }} - onTaskClick={(task) => setSelectedTask(task)} - onViewChanges={handleViewChanges} - onAddTask={(startImmediately) => - openCreateTaskDialog('', '', '', startImmediately) - } - onDeleteTask={handleDeleteTask} - deletedTaskCount={deletedTasks.length} - onOpenTrash={() => setTrashOpen(true)} - /> - - - } - defaultOpen={false} - > - - - - {(data.processes?.length ?? 0) > 0 && ( - } - badge={data.processes.filter((p) => !p.stoppedAt).length} - headerExtra={ - data.processes.some((p) => !p.stoppedAt) ? ( - - - - - ) : null - } - defaultOpen - > - - - )} - - {messagesPanelMode !== 'sidebar' && } - - {messagesPanelMode === 'inline' && ( - - )} - - setRequestChangesTaskId(null)} - onSubmit={(comment, taskRefs) => { - if (!requestChangesTaskId) { - return; - } - void (async () => { - try { - await updateKanban(teamName, requestChangesTaskId, { - op: 'request_changes', - comment, - taskRefs, - }); - setRequestChangesTaskId(null); - } catch { - // error state is handled in the store and shown in the view - } - })(); - }} - /> - - { - const name = selectedMember?.name ?? ''; - closeSelectedMemberDialog(); - setSendDialogRecipient(name || undefined); - setSendDialogDefaultText(undefined); - setSendDialogDefaultChip(undefined); - setReplyQuote(undefined); - setSendDialogOpen(true); - }} - onAssignTask={() => { - const name = selectedMember?.name ?? ''; - closeSelectedMemberDialog(); - openCreateTaskDialog('', '', name); - }} - onRestartMember={handleRestartMember} - onTaskClick={(task) => { - closeSelectedMemberDialog(); - setSelectedTask(task); - }} - onUpdateRole={async (memberName, role) => { - setUpdatingRoleLoading(true); - try { - await updateMemberRole(teamName, memberName, role); - // Optimistically update local selectedMember to reflect new role - setSelectedMember((prev) => { - if (prev?.name !== memberName) return prev; - const normalized = - typeof role === 'string' && role.trim() ? role.trim() : undefined; - return { ...prev, role: normalized }; - }); - } finally { - setUpdatingRoleLoading(false); - } - }} - updatingRole={updatingRoleLoading} - onRemoveMember={() => { - const name = selectedMember?.name; - if (!name) return; - setRemoveMemberConfirm(name); - }} - onViewMemberChanges={(memberName, filePath) => { - closeSelectedMemberDialog(); - setReviewDialogState({ - open: true, - mode: 'agent', - memberName, - initialFilePath: filePath, - }); - }} - /> - - - - !isLeadMember(m))} - leadMember={membersWithLiveBranches.find((m) => isLeadMember(m)) ?? null} - resolvedMemberColorMap={resolvedMemberColorMap} - isTeamAlive={data.isAlive && !isTeamProvisioning} - isTeamProvisioning={isTeamProvisioning} - projectPath={data.config.projectPath} - onClose={() => setEditDialogOpen(false)} - onChangeLeadRuntime={handleChangeLeadRuntime} - onSaved={() => void selectTeam(teamName)} - /> - - m.name)} - existingMembers={membersWithLiveBranches} - projectPath={data.config.projectPath} - adding={addingMemberLoading} - onClose={() => setAddMemberDialogOpen(false)} - onAdd={(entries: AddMemberEntry[]) => { - setAddingMemberLoading(true); - void (async () => { - try { - for (const entry of entries) { - await addMember(teamName, { - name: entry.name, - role: entry.role, - workflow: entry.workflow, - isolation: entry.isolation, - providerId: entry.providerId, - model: entry.model, - effort: entry.effort, - }); + onOwnerChange={(taskId, owner) => { + void (async () => { + try { + await updateTaskOwner(teamName, taskId, owner); + } catch { + // error via store } - setAddMemberDialogOpen(false); - } catch { - // error shown via store - } finally { - setAddingMemberLoading(false); - } - })(); - }} - /> + })(); + }} + onViewChanges={handleViewChangesForFile} + onOpenInEditor={(filePath) => { + const { revealFileInEditor } = useStore.getState(); + revealFileInEditor(filePath); + }} + onDeleteTask={handleDeleteTask} + /> - { - if (!open) setRemoveMemberConfirm(null); - }} - > - - - Remove member - - Remove “{removeMemberConfirm}” from the team? Tasks and messages - will be preserved, but this name cannot be reused. - - - - - - - - + setTrashOpen(false)} + onRestore={(taskId) => { + void (async () => { + try { + await restoreTask(teamName, taskId); + } catch { + // error via store + } + })(); + }} + /> - - - - Delete team - - Delete team “{data.config.name}”? This action is irreversible. All - team data and tasks will be deleted. - - - - - - - - - - - - { - const sentAtMs = Date.now(); - setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs })); - try { - const result = await sendTeamMessage(teamName, { - member, - text, - summary, - attachments, - actionMode, - taskRefs, - }); - if ( - result?.runtimeDelivery?.attempted === true && - result.runtimeDelivery.delivered === false - ) { - setPendingRepliesByMember((prev) => { - if (prev[member] !== sentAtMs) return prev; - const next = { ...prev }; - delete next[member]; - return next; - }); - } - return result; - } catch (error) { - setPendingRepliesByMember((prev) => { - if (prev[member] !== sentAtMs) return prev; - const next = { ...prev }; - delete next[member]; - return next; - }); - throw error; + + setReviewDialogState((prev) => ({ + ...prev, + open, + ...(open + ? {} + : { initialFilePath: undefined, taskChangeRequestOptions: undefined }), + })) } - }} - onClose={() => { - setSendDialogOpen(false); - setReplyQuote(undefined); - setSendDialogDefaultText(undefined); - setSendDialogDefaultChip(undefined); - }} + teamName={teamName} + mode={reviewDialogState.mode} + memberName={reviewDialogState.memberName} + taskId={reviewDialogState.taskId} + initialFilePath={reviewDialogState.initialFilePath} + taskChangeRequestOptions={reviewDialogState.taskChangeRequestOptions} + projectPath={data.config.projectPath} + onEditorAction={handleEditorAction} + /> +
+
+ {messagesPanelMode === 'bottom-sheet' && ( + + )} +
+
- setSelectedTask(null)} - onScrollToTask={(taskId) => { - setSelectedTask(null); - const el = document.querySelector(`[data-task-id="${taskId}"]`); - if (el) { - el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - el.classList.remove('kanban-card-focus-pulse'); - void (el as HTMLElement).offsetWidth; - el.classList.add('kanban-card-focus-pulse'); - el.addEventListener( - 'animationend', - () => el.classList.remove('kanban-card-focus-pulse'), - { once: true } - ); - } - }} - onOwnerChange={(taskId, owner) => { - void (async () => { - try { - await updateTaskOwner(teamName, taskId, owner); - } catch { - // error via store - } - })(); - }} - onViewChanges={handleViewChangesForFile} - onOpenInEditor={(filePath) => { - const { revealFileInEditor } = useStore.getState(); - revealFileInEditor(filePath); - }} - onDeleteTask={handleDeleteTask} - /> - - setTrashOpen(false)} - onRestore={(taskId) => { - void (async () => { - try { - await restoreTask(teamName, taskId); - } catch { - // error via store - } - })(); - }} - /> - - - setReviewDialogState((prev) => ({ - ...prev, - open, - ...(open - ? {} - : { initialFilePath: undefined, taskChangeRequestOptions: undefined }), - })) - } - teamName={teamName} - mode={reviewDialogState.mode} - memberName={reviewDialogState.memberName} - taskId={reviewDialogState.taskId} - initialFilePath={reviewDialogState.initialFilePath} - taskChangeRequestOptions={reviewDialogState.taskChangeRequestOptions} + {editorOpen && data.config.projectPath && ( + + setEditorOpen(false)} onEditorAction={handleEditorAction} /> -
-
- {messagesPanelMode === 'bottom-sheet' && ( - - )} -
-
+ + )} - {editorOpen && data.config.projectPath && ( - - setEditorOpen(false)} - onEditorAction={handleEditorAction} - /> - - )} + {graphOpen && ( + + setGraphOpen(false)} + onPinAsTab={() => { + setGraphOpen(false); + useStore + .getState() + .openTab({ type: 'graph', label: `${data.config.name} Graph`, teamName }); + }} + onSendMessage={(memberName) => { + setSendDialogRecipient(memberName); + setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); + setSendDialogOpen(true); + }} + onOpenTaskDetail={(taskId) => { + const task = data.tasks.find((t) => t.id === taskId); + if (task) setSelectedTask(task); + }} + onOpenMemberProfile={(memberName, options) => { + const member = members.find((m) => m.name === memberName); + if (member) { + setSelectedMember(member); + setSelectedMemberView({ + initialTab: options?.initialTab, + initialActivityFilter: options?.initialActivityFilter, + }); + } + }} + /> + + )} + + ); + }; - {graphOpen && ( - - setGraphOpen(false)} - onPinAsTab={() => { - setGraphOpen(false); - useStore - .getState() - .openTab({ type: 'graph', label: `${data.config.name} Graph`, teamName }); - }} - onSendMessage={(memberName) => { - setSendDialogRecipient(memberName); - setSendDialogDefaultText(undefined); - setSendDialogDefaultChip(undefined); - setSendDialogOpen(true); - }} - onOpenTaskDetail={(taskId) => { - const task = data.tasks.find((t) => t.id === taskId); - if (task) setSelectedTask(task); - }} - onOpenMemberProfile={(memberName, options) => { - const member = members.find((m) => m.name === memberName); - if (member) { - setSelectedMember(member); - setSelectedMemberView({ - initialTab: options?.initialTab, - initialActivityFilter: options?.initialActivityFilter, - }); - } - }} - /> - - )} + return ( + <> + {spawnStatusWatcher} + {teamAgentRuntimeWatcher} + {leadContextWatcher} + {renderBody()} ); - }; - - return ( - <> - {spawnStatusWatcher} - {teamAgentRuntimeWatcher} - {leadContextWatcher} - {renderBody()} - - ); -}; + } +); diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index 357ee15a..cc07708c 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { memo, useCallback, useEffect, useMemo, useState } from 'react'; import { recordRecentProjectOpenPaths } from '@features/recent-projects/renderer'; import { api, isElectronMode } from '@renderer/api'; @@ -233,7 +233,7 @@ const StatusBadge = ({ status }: { status: TeamStatus }): React.JSX.Element => { } }; -export const TeamListView = (): React.JSX.Element => { +export const TeamListView = memo((): React.JSX.Element => { const { isLight } = useTheme(); const electronMode = isElectronMode(); const [showCreateDialog, setShowCreateDialog] = useState(false); @@ -1177,4 +1177,4 @@ export const TeamListView = (): React.JSX.Element => {
); -}; +}); From fa38b90f9cf20869a59734662b0d993bbc4cc707 Mon Sep 17 00:00:00 2001 From: Mike Date: Sat, 2 May 2026 20:49:16 +0500 Subject: [PATCH 03/14] perf(renderer): memoize chat and sidebar list item components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrap SessionItem, SubagentItem, ExecutionTrace, TextItem, ThinkingItem, and DisplayItemList in React.memo. These components render repeatedly in virtualized lists and AI chat groups — memoizing them eliminates redundant renders when their props have not changed, reducing CPU work in active sessions with many messages or long session sidebars. --- .../components/chat/DisplayItemList.tsx | 678 ++++++------ .../components/chat/items/ExecutionTrace.tsx | 453 ++++---- .../components/chat/items/SubagentItem.tsx | 966 +++++++++--------- .../components/chat/items/TextItem.tsx | 96 +- .../components/chat/items/ThinkingItem.tsx | 96 +- .../components/sidebar/SessionItem.tsx | 452 ++++---- 6 files changed, 1385 insertions(+), 1356 deletions(-) diff --git a/src/renderer/components/chat/DisplayItemList.tsx b/src/renderer/components/chat/DisplayItemList.tsx index ec0412d5..cd2c5754 100644 --- a/src/renderer/components/chat/DisplayItemList.tsx +++ b/src/renderer/components/chat/DisplayItemList.tsx @@ -87,345 +87,353 @@ function truncateText(text: string, maxLength: number): string { * * The list is completely flat with no nested toggles or hierarchies. */ -export const DisplayItemList = ({ - items, - onItemClick, - expandedItemIds, - aiGroupId, - order = 'chronological', - searchQueryOverride, - highlightToolUseId, - highlightColor, - notificationColorMap, - registerToolRef, - previewMaxLength, - timestampFormat, - showItemMetaTooltip = false, -}: Readonly): React.JSX.Element => { - // Reply-link highlight: when hovering a reply badge, dim everything except the linked pair - const [replyLinkToolId, setReplyLinkToolId] = useState(null); +export const DisplayItemList = React.memo( + ({ + items, + onItemClick, + expandedItemIds, + aiGroupId, + order = 'chronological', + searchQueryOverride, + highlightToolUseId, + highlightColor, + notificationColorMap, + registerToolRef, + previewMaxLength, + timestampFormat, + showItemMetaTooltip = false, + }: Readonly): React.JSX.Element => { + // Reply-link highlight: when hovering a reply badge, dim everything except the linked pair + const [replyLinkToolId, setReplyLinkToolId] = useState(null); - const handleReplyHover = useCallback((toolId: string | null) => { - setReplyLinkToolId(toolId); - }, []); + const handleReplyHover = useCallback((toolId: string | null) => { + setReplyLinkToolId(toolId); + }, []); - /** Check if an item is part of the currently highlighted reply link */ - const isItemInReplyLink = (item: AIGroupDisplayItem): boolean => { - if (!replyLinkToolId) return false; - if (item.type === 'tool' && item.tool.id === replyLinkToolId) return true; - if (item.type === 'teammate_message' && item.teammateMessage.replyToToolId === replyLinkToolId) - return true; - return false; - }; + /** Check if an item is part of the currently highlighted reply link */ + const isItemInReplyLink = (item: AIGroupDisplayItem): boolean => { + if (!replyLinkToolId) return false; + if (item.type === 'tool' && item.tool.id === replyLinkToolId) return true; + if ( + item.type === 'teammate_message' && + item.teammateMessage.replyToToolId === replyLinkToolId + ) + return true; + return false; + }; + + if (!items || items.length === 0) { + return ( +
+ No items to display +
+ ); + } - if (!items || items.length === 0) { return ( -
- No items to display +
+ {items.map((item, index) => { + let itemKey = ''; + let element: React.ReactNode = null; + + switch (item.type) { + case 'thinking': { + itemKey = `thinking-${index}`; + const thinkingStep = { + id: itemKey, + type: 'thinking' as const, + startTime: item.timestamp, + endTime: item.timestamp, + durationMs: 0, + content: { thinkingText: item.content, tokenCount: item.tokenCount }, + tokens: { input: 0, output: item.tokenCount ?? 0 }, + context: 'main' as const, + }; + element = ( + onItemClick(itemKey)} + isExpanded={expandedItemIds.has(itemKey)} + timestamp={item.timestamp} + timestampFormat={timestampFormat} + titleText={ + showItemMetaTooltip + ? buildItemMetaTooltip(item.timestamp, item.tokenCount, 'tokens') + : undefined + } + markdownItemId={searchQueryOverride ? `${aiGroupId}:${itemKey}` : undefined} + searchQueryOverride={searchQueryOverride} + /> + ); + break; + } + + case 'output': { + itemKey = `output-${index}`; + const textStep = { + id: itemKey, + type: 'output' as const, + startTime: item.timestamp, + endTime: item.timestamp, + durationMs: 0, + content: { outputText: item.content, tokenCount: item.tokenCount }, + tokens: { input: 0, output: item.tokenCount ?? 0 }, + context: 'main' as const, + }; + element = ( + onItemClick(itemKey)} + isExpanded={expandedItemIds.has(itemKey)} + timestamp={item.timestamp} + timestampFormat={timestampFormat} + titleText={ + showItemMetaTooltip + ? buildItemMetaTooltip(item.timestamp, item.tokenCount, 'tokens') + : undefined + } + markdownItemId={searchQueryOverride ? `${aiGroupId}:${itemKey}` : undefined} + searchQueryOverride={searchQueryOverride} + /> + ); + break; + } + + case 'tool': { + itemKey = `tool-${item.tool.id}-${index}`; + element = ( + onItemClick(itemKey)} + isExpanded={expandedItemIds.has(itemKey)} + timestamp={item.tool.startTime} + timestampFormat={timestampFormat} + titleText={ + showItemMetaTooltip + ? buildItemMetaTooltip( + item.tool.startTime, + getToolContextTokens(item.tool), + 'tokens' + ) + : undefined + } + searchQueryOverride={searchQueryOverride} + isHighlighted={highlightToolUseId === item.tool.id} + highlightColor={highlightColor} + notificationDotColor={notificationColorMap?.get(item.tool.id)} + registerRef={ + registerToolRef ? (el) => registerToolRef(item.tool.id, el) : undefined + } + /> + ); + break; + } + + case 'subagent': { + itemKey = `subagent-${item.subagent.id}-${index}`; + const subagentStep = { + id: itemKey, + type: 'subagent' as const, + startTime: item.subagent.startTime, + endTime: item.subagent.endTime, + durationMs: item.subagent.durationMs, + content: { + subagentId: item.subagent.id, + subagentDescription: item.subagent.description, + }, + isParallel: item.subagent.isParallel, + context: 'main' as const, + }; + element = ( + onItemClick(itemKey)} + isExpanded={expandedItemIds.has(itemKey)} + aiGroupId={aiGroupId} + highlightToolUseId={highlightToolUseId} + highlightColor={highlightColor} + notificationColorMap={notificationColorMap} + registerToolRef={registerToolRef} + /> + ); + break; + } + + case 'slash': { + itemKey = `slash-${item.slash.name}-${index}`; + element = ( + onItemClick(itemKey)} + isExpanded={expandedItemIds.has(itemKey)} + timestamp={item.slash.timestamp} + timestampFormat={timestampFormat} + titleText={ + showItemMetaTooltip + ? buildItemMetaTooltip( + item.slash.timestamp, + item.slash.instructionsTokenCount, + 'tokens' + ) + : undefined + } + /> + ); + break; + } + + case 'teammate_message': { + itemKey = `teammate-${item.teammateMessage.id}-${index}`; + element = ( + onItemClick(itemKey)} + isExpanded={expandedItemIds.has(itemKey)} + onReplyHover={handleReplyHover} + /> + ); + break; + } + + case 'subagent_input': { + itemKey = `input-${index}`; + const inputContent = item.content; + const inputTokenCount = item.tokenCount; + element = ( + } + label="Input" + summary={truncateText(inputContent, previewMaxLength ?? 80)} + tokenCount={inputTokenCount} + timestamp={item.timestamp} + timestampFormat={timestampFormat} + titleText={ + showItemMetaTooltip + ? buildItemMetaTooltip(item.timestamp, inputTokenCount, 'tokens') + : undefined + } + onClick={() => onItemClick(itemKey)} + isExpanded={expandedItemIds.has(itemKey)} + > + + + ); + break; + } + + case 'compact_boundary': { + itemKey = `compact-${index}`; + const compactContent = item.content; + const compactExpanded = expandedItemIds.has(itemKey); + element = ( +
+ + {compactExpanded && compactContent && ( +
+
+ +
+
+ )} +
+ ); + break; + } + + default: + return null; + } + + // Apply reply-link spotlight: dim items not in the highlighted pair + const isDimmed = replyLinkToolId !== null && !isItemInReplyLink(item); + return ( +
+ {element} +
+ ); + })}
); } - - return ( -
- {items.map((item, index) => { - let itemKey = ''; - let element: React.ReactNode = null; - - switch (item.type) { - case 'thinking': { - itemKey = `thinking-${index}`; - const thinkingStep = { - id: itemKey, - type: 'thinking' as const, - startTime: item.timestamp, - endTime: item.timestamp, - durationMs: 0, - content: { thinkingText: item.content, tokenCount: item.tokenCount }, - tokens: { input: 0, output: item.tokenCount ?? 0 }, - context: 'main' as const, - }; - element = ( - onItemClick(itemKey)} - isExpanded={expandedItemIds.has(itemKey)} - timestamp={item.timestamp} - timestampFormat={timestampFormat} - titleText={ - showItemMetaTooltip - ? buildItemMetaTooltip(item.timestamp, item.tokenCount, 'tokens') - : undefined - } - markdownItemId={searchQueryOverride ? `${aiGroupId}:${itemKey}` : undefined} - searchQueryOverride={searchQueryOverride} - /> - ); - break; - } - - case 'output': { - itemKey = `output-${index}`; - const textStep = { - id: itemKey, - type: 'output' as const, - startTime: item.timestamp, - endTime: item.timestamp, - durationMs: 0, - content: { outputText: item.content, tokenCount: item.tokenCount }, - tokens: { input: 0, output: item.tokenCount ?? 0 }, - context: 'main' as const, - }; - element = ( - onItemClick(itemKey)} - isExpanded={expandedItemIds.has(itemKey)} - timestamp={item.timestamp} - timestampFormat={timestampFormat} - titleText={ - showItemMetaTooltip - ? buildItemMetaTooltip(item.timestamp, item.tokenCount, 'tokens') - : undefined - } - markdownItemId={searchQueryOverride ? `${aiGroupId}:${itemKey}` : undefined} - searchQueryOverride={searchQueryOverride} - /> - ); - break; - } - - case 'tool': { - itemKey = `tool-${item.tool.id}-${index}`; - element = ( - onItemClick(itemKey)} - isExpanded={expandedItemIds.has(itemKey)} - timestamp={item.tool.startTime} - timestampFormat={timestampFormat} - titleText={ - showItemMetaTooltip - ? buildItemMetaTooltip( - item.tool.startTime, - getToolContextTokens(item.tool), - 'tokens' - ) - : undefined - } - searchQueryOverride={searchQueryOverride} - isHighlighted={highlightToolUseId === item.tool.id} - highlightColor={highlightColor} - notificationDotColor={notificationColorMap?.get(item.tool.id)} - registerRef={ - registerToolRef ? (el) => registerToolRef(item.tool.id, el) : undefined - } - /> - ); - break; - } - - case 'subagent': { - itemKey = `subagent-${item.subagent.id}-${index}`; - const subagentStep = { - id: itemKey, - type: 'subagent' as const, - startTime: item.subagent.startTime, - endTime: item.subagent.endTime, - durationMs: item.subagent.durationMs, - content: { - subagentId: item.subagent.id, - subagentDescription: item.subagent.description, - }, - isParallel: item.subagent.isParallel, - context: 'main' as const, - }; - element = ( - onItemClick(itemKey)} - isExpanded={expandedItemIds.has(itemKey)} - aiGroupId={aiGroupId} - highlightToolUseId={highlightToolUseId} - highlightColor={highlightColor} - notificationColorMap={notificationColorMap} - registerToolRef={registerToolRef} - /> - ); - break; - } - - case 'slash': { - itemKey = `slash-${item.slash.name}-${index}`; - element = ( - onItemClick(itemKey)} - isExpanded={expandedItemIds.has(itemKey)} - timestamp={item.slash.timestamp} - timestampFormat={timestampFormat} - titleText={ - showItemMetaTooltip - ? buildItemMetaTooltip( - item.slash.timestamp, - item.slash.instructionsTokenCount, - 'tokens' - ) - : undefined - } - /> - ); - break; - } - - case 'teammate_message': { - itemKey = `teammate-${item.teammateMessage.id}-${index}`; - element = ( - onItemClick(itemKey)} - isExpanded={expandedItemIds.has(itemKey)} - onReplyHover={handleReplyHover} - /> - ); - break; - } - - case 'subagent_input': { - itemKey = `input-${index}`; - const inputContent = item.content; - const inputTokenCount = item.tokenCount; - element = ( - } - label="Input" - summary={truncateText(inputContent, previewMaxLength ?? 80)} - tokenCount={inputTokenCount} - timestamp={item.timestamp} - timestampFormat={timestampFormat} - titleText={ - showItemMetaTooltip - ? buildItemMetaTooltip(item.timestamp, inputTokenCount, 'tokens') - : undefined - } - onClick={() => onItemClick(itemKey)} - isExpanded={expandedItemIds.has(itemKey)} - > - - - ); - break; - } - - case 'compact_boundary': { - itemKey = `compact-${index}`; - const compactContent = item.content; - const compactExpanded = expandedItemIds.has(itemKey); - element = ( -
- - {compactExpanded && compactContent && ( -
-
- -
-
- )} -
- ); - break; - } - - default: - return null; - } - - // Apply reply-link spotlight: dim items not in the highlighted pair - const isDimmed = replyLinkToolId !== null && !isItemInReplyLink(item); - return ( -
- {element} -
- ); - })} -
- ); -}; +); diff --git a/src/renderer/components/chat/items/ExecutionTrace.tsx b/src/renderer/components/chat/items/ExecutionTrace.tsx index f13241fd..e81ebe20 100644 --- a/src/renderer/components/chat/items/ExecutionTrace.tsx +++ b/src/renderer/components/chat/items/ExecutionTrace.tsx @@ -46,234 +46,239 @@ interface ExecutionTraceProps { // Execution Trace Component // ============================================================================= -export const ExecutionTrace: React.FC = ({ - items, - aiGroupId: _aiGroupId, - highlightToolUseId, - highlightColor, - notificationColorMap, - searchExpandedItemId, - registerToolRef, -}): React.JSX.Element => { - const [manualExpandedItemId, setManualExpandedItemId] = useState(null); +export const ExecutionTrace: React.FC = React.memo( + ({ + items, + aiGroupId: _aiGroupId, + highlightToolUseId, + highlightColor, + notificationColorMap, + searchExpandedItemId, + registerToolRef, + }): React.JSX.Element => { + const [manualExpandedItemId, setManualExpandedItemId] = useState(null); - // Use searchExpandedItemId if set, otherwise use manually expanded item - const expandedItemId = searchExpandedItemId ?? manualExpandedItemId; + // Use searchExpandedItemId if set, otherwise use manually expanded item + const expandedItemId = searchExpandedItemId ?? manualExpandedItemId; - const handleItemClick = (itemId: string): void => { - setManualExpandedItemId((prev) => (prev === itemId ? null : itemId)); - }; + const handleItemClick = (itemId: string): void => { + setManualExpandedItemId((prev) => (prev === itemId ? null : itemId)); + }; + + if (!items || items.length === 0) { + return ( +
+ No execution items +
+ ); + } - if (!items || items.length === 0) { return ( -
- No execution items +
+ {items.map((item, index) => { + switch (item.type) { + case 'thinking': { + const itemId = `subagent-thinking-${index}`; + const thinkingStep = { + id: itemId, + type: 'thinking' as const, + startTime: item.timestamp, + endTime: item.timestamp, + durationMs: 0, + content: { thinkingText: item.content, tokenCount: item.tokenCount }, + tokens: { input: 0, output: item.tokenCount ?? 0 }, + context: 'subagent' as const, + }; + const preview = truncateText(item.content, 150); + const isExpanded = expandedItemId === itemId; + return ( + handleItemClick(itemId)} + isExpanded={isExpanded} + timestamp={item.timestamp} + /> + ); + } + + case 'output': { + const itemId = `subagent-output-${index}`; + const textStep = { + id: itemId, + type: 'output' as const, + startTime: item.timestamp, + endTime: item.timestamp, + durationMs: 0, + content: { outputText: item.content, tokenCount: item.tokenCount }, + tokens: { input: 0, output: item.tokenCount ?? 0 }, + context: 'subagent' as const, + }; + const preview = truncateText(item.content, 150); + const isExpanded = expandedItemId === itemId; + return ( + handleItemClick(itemId)} + isExpanded={isExpanded} + timestamp={item.timestamp} + /> + ); + } + + case 'tool': { + const itemId = `subagent-tool-${item.tool.id}`; + const isExpanded = expandedItemId === itemId; + const isHighlighted = highlightToolUseId === item.tool.id; + return ( + handleItemClick(itemId)} + isExpanded={isExpanded} + timestamp={item.tool.startTime} + isHighlighted={isHighlighted} + highlightColor={highlightColor} + notificationDotColor={notificationColorMap?.get(item.tool.id)} + registerRef={ + registerToolRef ? (el) => registerToolRef(item.tool.id, el) : undefined + } + /> + ); + } + + case 'subagent': + return ( +
+ Nested: {item.subagent.description ?? item.subagent.id} +
+ ); + + case 'subagent_input': { + const itemId = `subagent-input-${index}`; + const isExpanded = expandedItemId === itemId; + return ( + } + label="Input" + summary={truncateText(item.content, 80)} + tokenCount={item.tokenCount} + timestamp={item.timestamp} + onClick={() => handleItemClick(itemId)} + isExpanded={isExpanded} + > + + + ); + } + + case 'teammate_message': { + const itemId = `subagent-teammate-${item.teammateMessage.id}-${index}`; + const isExpanded = expandedItemId === itemId; + return ( + handleItemClick(itemId)} + isExpanded={isExpanded} + /> + ); + } + + case 'compact_boundary': { + const itemId = `subagent-compact-${index}`; + const isExpanded = expandedItemId === itemId; + return ( +
+ {/* Header — matches CompactBoundary.tsx amber styling */} + + {/* Expanded content */} + {isExpanded && item.content && ( +
+
+ +
+
+ )} +
+ ); + } + + default: + return null; + } + })}
); } - - return ( -
- {items.map((item, index) => { - switch (item.type) { - case 'thinking': { - const itemId = `subagent-thinking-${index}`; - const thinkingStep = { - id: itemId, - type: 'thinking' as const, - startTime: item.timestamp, - endTime: item.timestamp, - durationMs: 0, - content: { thinkingText: item.content, tokenCount: item.tokenCount }, - tokens: { input: 0, output: item.tokenCount ?? 0 }, - context: 'subagent' as const, - }; - const preview = truncateText(item.content, 150); - const isExpanded = expandedItemId === itemId; - return ( - handleItemClick(itemId)} - isExpanded={isExpanded} - timestamp={item.timestamp} - /> - ); - } - - case 'output': { - const itemId = `subagent-output-${index}`; - const textStep = { - id: itemId, - type: 'output' as const, - startTime: item.timestamp, - endTime: item.timestamp, - durationMs: 0, - content: { outputText: item.content, tokenCount: item.tokenCount }, - tokens: { input: 0, output: item.tokenCount ?? 0 }, - context: 'subagent' as const, - }; - const preview = truncateText(item.content, 150); - const isExpanded = expandedItemId === itemId; - return ( - handleItemClick(itemId)} - isExpanded={isExpanded} - timestamp={item.timestamp} - /> - ); - } - - case 'tool': { - const itemId = `subagent-tool-${item.tool.id}`; - const isExpanded = expandedItemId === itemId; - const isHighlighted = highlightToolUseId === item.tool.id; - return ( - handleItemClick(itemId)} - isExpanded={isExpanded} - timestamp={item.tool.startTime} - isHighlighted={isHighlighted} - highlightColor={highlightColor} - notificationDotColor={notificationColorMap?.get(item.tool.id)} - registerRef={ - registerToolRef ? (el) => registerToolRef(item.tool.id, el) : undefined - } - /> - ); - } - - case 'subagent': - return ( -
- Nested: {item.subagent.description ?? item.subagent.id} -
- ); - - case 'subagent_input': { - const itemId = `subagent-input-${index}`; - const isExpanded = expandedItemId === itemId; - return ( - } - label="Input" - summary={truncateText(item.content, 80)} - tokenCount={item.tokenCount} - timestamp={item.timestamp} - onClick={() => handleItemClick(itemId)} - isExpanded={isExpanded} - > - - - ); - } - - case 'teammate_message': { - const itemId = `subagent-teammate-${item.teammateMessage.id}-${index}`; - const isExpanded = expandedItemId === itemId; - return ( - handleItemClick(itemId)} - isExpanded={isExpanded} - /> - ); - } - - case 'compact_boundary': { - const itemId = `subagent-compact-${index}`; - const isExpanded = expandedItemId === itemId; - return ( -
- {/* Header — matches CompactBoundary.tsx amber styling */} - - {/* Expanded content */} - {isExpanded && item.content && ( -
-
- -
-
- )} -
- ); - } - - default: - return null; - } - })} -
- ); -}; +); diff --git a/src/renderer/components/chat/items/SubagentItem.tsx b/src/renderer/components/chat/items/SubagentItem.tsx index c2aeeca4..0c79394e 100644 --- a/src/renderer/components/chat/items/SubagentItem.tsx +++ b/src/renderer/components/chat/items/SubagentItem.tsx @@ -67,249 +67,178 @@ interface SubagentItemProps { // Main Component - Linear-style DevTools Card // ============================================================================= -export const SubagentItem: React.FC = ({ - step, - subagent, - onClick, - isExpanded, - aiGroupId, - highlightToolUseId, - highlightColor, - notificationColorMap, - registerToolRef, -}) => { - const description = subagent.description ?? step.content.subagentDescription ?? 'Subagent'; - const subagentType = subagent.subagentType ?? 'Task'; - const truncatedDesc = description.length > 60 ? description.slice(0, 60) + '...' : description; +export const SubagentItem: React.FC = React.memo( + ({ + step, + subagent, + onClick, + isExpanded, + aiGroupId, + highlightToolUseId, + highlightColor, + notificationColorMap, + registerToolRef, + }) => { + const description = subagent.description ?? step.content.subagentDescription ?? 'Subagent'; + const subagentType = subagent.subagentType ?? 'Task'; + const truncatedDesc = description.length > 60 ? description.slice(0, 60) + '...' : description; - // Agent configs from .claude/agents/ for color lookup - const agentConfigs = useStore(useShallow((s) => s.agentConfigs)); + // Agent configs from .claude/agents/ for color lookup + const agentConfigs = useStore(useShallow((s) => s.agentConfigs)); - // Team member colors (when this subagent is a team member) - const teamColors = subagent.team ? getTeamColorSet(subagent.team.memberColor) : null; - const { isLight } = useTheme(); - // Type-based colors for non-team subagents (from agent config or deterministic hash) - const typeColors = !teamColors ? getSubagentTypeColorSet(subagentType, agentConfigs) : null; + // Team member colors (when this subagent is a team member) + const teamColors = subagent.team ? getTeamColorSet(subagent.team.memberColor) : null; + const { isLight } = useTheme(); + // Type-based colors for non-team subagents (from agent config or deterministic hash) + const typeColors = !teamColors ? getSubagentTypeColorSet(subagentType, agentConfigs) : null; - // Detect shutdown-only team activations (trivial: just a shutdown_response) - const isShutdownOnly = useMemo(() => { - if (!subagent.team || !subagent.messages?.length) return false; - const assistantMsgs = subagent.messages.filter((m) => m.type === 'assistant'); - if (assistantMsgs.length !== 1) return false; - const calls = assistantMsgs[0].toolCalls ?? []; - return ( - calls.length === 1 && - calls[0].name === 'SendMessage' && - calls[0].input?.type === 'shutdown_response' - ); - }, [subagent.team, subagent.messages]); + // Detect shutdown-only team activations (trivial: just a shutdown_response) + const isShutdownOnly = useMemo(() => { + if (!subagent.team || !subagent.messages?.length) return false; + const assistantMsgs = subagent.messages.filter((m) => m.type === 'assistant'); + if (assistantMsgs.length !== 1) return false; + const calls = assistantMsgs[0].toolCalls ?? []; + return ( + calls.length === 1 && + calls[0].name === 'SendMessage' && + calls[0].input?.type === 'shutdown_response' + ); + }, [subagent.team, subagent.messages]); - // Per-tab trace expansion state (replaces local useState for true per-tab isolation) - const { isSubagentTraceExpanded, toggleSubagentTraceExpansion } = useTabUI(); - const isTraceManuallyExpanded = isSubagentTraceExpanded(subagent.id); + // Per-tab trace expansion state (replaces local useState for true per-tab isolation) + const { isSubagentTraceExpanded, toggleSubagentTraceExpansion } = useTabUI(); + const isTraceManuallyExpanded = isSubagentTraceExpanded(subagent.id); - // Check if contains highlighted error - // Also matches when the highlight targets the parent Task tool_use that spawned this subagent - const containsHighlightedError = useMemo(() => { - if (!highlightToolUseId) return false; - // Match parent Task tool_use ID (trigger matched the Task call itself) - if (subagent.parentTaskId === highlightToolUseId) return true; - // Match inner tool calls/results within the subagent - if (!subagent.messages) return false; - for (const msg of subagent.messages) { - if (msg.toolCalls?.some((tc) => tc.id === highlightToolUseId)) return true; - if (msg.toolResults?.some((tr) => tr.toolUseId === highlightToolUseId)) return true; - } - return false; - }, [highlightToolUseId, subagent.parentTaskId, subagent.messages]); - - // Build display items - const displayItems = useMemo(() => { - if ((!isExpanded && !containsHighlightedError) || !subagent.messages?.length) { - return []; - } - return buildDisplayItemsFromMessages(subagent.messages, []); - }, [isExpanded, containsHighlightedError, subagent.messages]); - - // Build summary - const itemsSummary = useMemo(() => { - if (!isExpanded && !containsHighlightedError) { - const toolCount = - subagent.messages?.filter( - (m) => - m.type === 'assistant' && - Array.isArray(m.content) && - m.content.some((b) => b.type === 'tool_use') - ).length ?? 0; - return toolCount > 0 ? `${toolCount} tools` : ''; - } - return buildSummary(displayItems); - }, [isExpanded, containsHighlightedError, displayItems, subagent.messages]); - - // Model info - const modelInfo = useMemo(() => { - const msg = subagent.messages?.find( - (m) => m.type === 'assistant' && m.model && m.model !== '' - ); - return msg?.model ? parseModelString(msg.model) : null; - }, [subagent.messages]); - - // Last usage - const lastUsage = useMemo(() => { - const messages = subagent.messages ?? []; - for (let i = messages.length - 1; i >= 0; i--) { - if (messages[i].type === 'assistant' && messages[i].usage) { - return messages[i].usage; + // Check if contains highlighted error + // Also matches when the highlight targets the parent Task tool_use that spawned this subagent + const containsHighlightedError = useMemo(() => { + if (!highlightToolUseId) return false; + // Match parent Task tool_use ID (trigger matched the Task call itself) + if (subagent.parentTaskId === highlightToolUseId) return true; + // Match inner tool calls/results within the subagent + if (!subagent.messages) return false; + for (const msg of subagent.messages) { + if (msg.toolCalls?.some((tc) => tc.id === highlightToolUseId)) return true; + if (msg.toolResults?.some((tr) => tr.toolUseId === highlightToolUseId)) return true; } - } - return null; - }, [subagent.messages]); + return false; + }, [highlightToolUseId, subagent.parentTaskId, subagent.messages]); - // Multi-phase context breakdown (for subagents with compaction) - const phaseData = useMemo(() => { - if (!subagent.messages?.length) return null; - return computeSubagentPhaseBreakdown(subagent.messages); - }, [subagent.messages]); - - // Search expansion - const searchExpandedSubagentIds = useStore(useShallow((s) => s.searchExpandedSubagentIds)); - const searchCurrentSubagentItemId = useStore((s) => s.searchCurrentSubagentItemId); - const shouldExpandForSearch = searchExpandedSubagentIds.has(subagent.id); - - // Combine manual expansion with auto-expansion for errors/search - const isTraceExpanded = - isTraceManuallyExpanded || containsHighlightedError || shouldExpandForSearch; - const [isTraceHeaderHovered, setIsTraceHeaderHovered] = useState(false); - - // Outer card highlight when this subagent contains the highlighted tool - const outerHighlight = useMemo(() => { - if (!containsHighlightedError) - return { className: '', style: undefined as React.CSSProperties | undefined }; - return getHighlightProps(highlightColor); - }, [containsHighlightedError, highlightColor]); - - // Register outer card as a tool ref target for the parent Task tool_use ID - // so the navigation controller can scroll directly to this SubagentItem - const outerCardRef = useCallback( - (el: HTMLDivElement | null) => { - if (subagent.parentTaskId && registerToolRef) { - registerToolRef(subagent.parentTaskId, el); + // Build display items + const displayItems = useMemo(() => { + if ((!isExpanded && !containsHighlightedError) || !subagent.messages?.length) { + return []; } - }, - [subagent.parentTaskId, registerToolRef] - ); + return buildDisplayItemsFromMessages(subagent.messages, []); + }, [isExpanded, containsHighlightedError, subagent.messages]); - // Cumulative metrics for team members — show total output generated - const cumulativeMetrics = useMemo(() => { - if (!subagent.team || !subagent.metrics) return undefined; - const turnCount = - subagent.messages?.filter((m) => m.type === 'assistant' && m.usage).length ?? 0; - return { - outputTokens: subagent.metrics.outputTokens, - turnCount, - }; - }, [subagent.team, subagent.metrics, subagent.messages]); + // Build summary + const itemsSummary = useMemo(() => { + if (!isExpanded && !containsHighlightedError) { + const toolCount = + subagent.messages?.filter( + (m) => + m.type === 'assistant' && + Array.isArray(m.content) && + m.content.some((b) => b.type === 'tool_use') + ).length ?? 0; + return toolCount > 0 ? `${toolCount} tools` : ''; + } + return buildSummary(displayItems); + }, [isExpanded, containsHighlightedError, displayItems, subagent.messages]); - // Computed values for metrics - const hasMainImpact = subagent.mainSessionImpact && subagent.mainSessionImpact.totalTokens > 0; - const hasIsolated = lastUsage && lastUsage.input_tokens + lastUsage.output_tokens > 0; - const isMultiPhase = phaseData != null && phaseData.compactionCount > 0; - const isolatedTotal = isMultiPhase - ? phaseData.totalConsumption - : lastUsage - ? lastUsage.input_tokens + - lastUsage.output_tokens + - (lastUsage.cache_read_input_tokens ?? 0) + - (lastUsage.cache_creation_input_tokens ?? 0) - : 0; + // Model info + const modelInfo = useMemo(() => { + const msg = subagent.messages?.find( + (m) => m.type === 'assistant' && m.model && m.model !== '' + ); + return msg?.model ? parseModelString(msg.model) : null; + }, [subagent.messages]); - // Shutdown-only team activations: minimal inline row (no metrics, no expand) - if (isShutdownOnly && teamColors && subagent.team) { - return ( -
- - { + const messages = subagent.messages ?? []; + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].type === 'assistant' && messages[i].usage) { + return messages[i].usage; + } + } + return null; + }, [subagent.messages]); + + // Multi-phase context breakdown (for subagents with compaction) + const phaseData = useMemo(() => { + if (!subagent.messages?.length) return null; + return computeSubagentPhaseBreakdown(subagent.messages); + }, [subagent.messages]); + + // Search expansion + const searchExpandedSubagentIds = useStore(useShallow((s) => s.searchExpandedSubagentIds)); + const searchCurrentSubagentItemId = useStore((s) => s.searchCurrentSubagentItemId); + const shouldExpandForSearch = searchExpandedSubagentIds.has(subagent.id); + + // Combine manual expansion with auto-expansion for errors/search + const isTraceExpanded = + isTraceManuallyExpanded || containsHighlightedError || shouldExpandForSearch; + const [isTraceHeaderHovered, setIsTraceHeaderHovered] = useState(false); + + // Outer card highlight when this subagent contains the highlighted tool + const outerHighlight = useMemo(() => { + if (!containsHighlightedError) + return { className: '', style: undefined as React.CSSProperties | undefined }; + return getHighlightProps(highlightColor); + }, [containsHighlightedError, highlightColor]); + + // Register outer card as a tool ref target for the parent Task tool_use ID + // so the navigation controller can scroll directly to this SubagentItem + const outerCardRef = useCallback( + (el: HTMLDivElement | null) => { + if (subagent.parentTaskId && registerToolRef) { + registerToolRef(subagent.parentTaskId, el); + } + }, + [subagent.parentTaskId, registerToolRef] + ); + + // Cumulative metrics for team members — show total output generated + const cumulativeMetrics = useMemo(() => { + if (!subagent.team || !subagent.metrics) return undefined; + const turnCount = + subagent.messages?.filter((m) => m.type === 'assistant' && m.usage).length ?? 0; + return { + outputTokens: subagent.metrics.outputTokens, + turnCount, + }; + }, [subagent.team, subagent.metrics, subagent.messages]); + + // Computed values for metrics + const hasMainImpact = subagent.mainSessionImpact && subagent.mainSessionImpact.totalTokens > 0; + const hasIsolated = lastUsage && lastUsage.input_tokens + lastUsage.output_tokens > 0; + const isMultiPhase = phaseData != null && phaseData.compactionCount > 0; + const isolatedTotal = isMultiPhase + ? phaseData.totalConsumption + : lastUsage + ? lastUsage.input_tokens + + lastUsage.output_tokens + + (lastUsage.cache_read_input_tokens ?? 0) + + (lastUsage.cache_creation_input_tokens ?? 0) + : 0; + + // Shutdown-only team activations: minimal inline row (no metrics, no expand) + if (isShutdownOnly && teamColors && subagent.team) { + return ( +
- {subagent.team.memberName} - - - Shutdown confirmed - - - - {formatDuration(subagent.durationMs)} - -
- ); - } - - return ( -
- {/* ========== Level 1: Clickable Header ========== */} -
{ - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onClick(); - } - }} - className="flex cursor-pointer items-center gap-2 px-3 py-2 transition-colors" - style={{ - backgroundColor: isExpanded ? CARD_HEADER_BG : 'transparent', - borderBottom: isExpanded ? CARD_BORDER_STYLE : 'none', - }} - > - {/* Expand chevron */} - - - {/* Icon - colored dot for team members/typed subagents, Bot icon for generic */} - {teamColors || typeColors ? ( - ) : ( - - )} - - {/* Type badge - team member name or typed subagent */} - {teamColors && subagent.team ? ( = ({ > {subagent.team.memberName} - ) : ( + + Shutdown confirmed + + - {subagentType} + {formatDuration(subagent.durationMs)} - )} +
+ ); + } - {/* Model */} - {modelInfo && ( - - {modelInfo.name} - - )} - - {/* Description */} - - {truncatedDesc} - - - {/* Status indicator */} - {subagent.isOngoing ? ( - - ) : ( - - )} - - {/* Unified Metrics Pill — team members don't show mainSessionImpact - (spawn cost only; real main impact comes from teammate messages) */} - 0 ? phaseData.totalConsumption : undefined - } - phaseBreakdown={phaseData?.phases} - /> - - {/* Duration */} - + {/* ========== Level 1: Clickable Header ========== */} +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick(); + } + }} + className="flex cursor-pointer items-center gap-2 px-3 py-2 transition-colors" + style={{ + backgroundColor: isExpanded ? CARD_HEADER_BG : 'transparent', + borderBottom: isExpanded ? CARD_BORDER_STYLE : 'none', + }} > - {formatDuration(subagent.durationMs)} - + {/* Expand chevron */} + - {/* Timestamp — rightmost info element */} - - {format(subagent.startTime, 'HH:mm:ss')} - -
- - {/* ========== Level 1 Expanded: Dashboard Content ========== */} - {isExpanded && ( -
- {/* ========== Row 1: Meta Info (Horizontal Flow) ========== */} -
- - Type{' '} - - {subagentType} - - - - - Duration{' '} - - {formatDuration(subagent.durationMs)} - - - {modelInfo && ( - <> - - - Model{' '} - - {modelInfo.name} - - - - )} - - - ID{' '} - - {subagent.id.slice(0, 8)} - - -
- - {/* ========== Row 2: Context Usage (Clean List) ========== */} - {(hasMainImpact ?? hasIsolated) && ( -
- {/* Overline title */} -
- Context Usage -
- - {/* Token rows - floating alignment */} -
- {hasMainImpact && !subagent.team && ( -
-
- - - Main Context - -
- - {subagent.mainSessionImpact!.totalTokens.toLocaleString()} - -
- )} - - {cumulativeMetrics && ( -
-
- - - Total Output - -
- - {cumulativeMetrics.outputTokens.toLocaleString()} - - {' '} - ({cumulativeMetrics.turnCount} turns) - - -
- )} - - {hasIsolated && ( -
-
- - - {subagent.team ? 'Context Window' : 'Subagent Context'} - -
- - {isolatedTotal.toLocaleString()} - -
- )} - - {/* Per-phase breakdown when multi-phase */} - {isMultiPhase && - phaseData.phases.map((phase) => ( -
- - Phase {phase.phaseNumber} - - - {formatTokensCompact(phase.peakTokens)} - {phase.postCompaction != null && ( - - {' '} - → {formatTokensCompact(phase.postCompaction)} - - )} - -
- ))} -
-
+ {/* Icon - colored dot for team members/typed subagents, Bot icon for generic */} + {teamColors || typeColors ? ( + + ) : ( + )} - {/* ========== Level 2: Execution Trace Toggle ========== */} - {displayItems.length > 0 && ( -
- {/* Trace Header (clickable) */} + {subagent.team.memberName} + + ) : ( + + {subagentType} + + )} + + {/* Model */} + {modelInfo && ( + + {modelInfo.name} + + )} + + {/* Description */} + + {truncatedDesc} + + + {/* Status indicator */} + {subagent.isOngoing ? ( + + ) : ( + + )} + + {/* Unified Metrics Pill — team members don't show mainSessionImpact + (spawn cost only; real main impact comes from teammate messages) */} + 0 ? phaseData.totalConsumption : undefined + } + phaseBreakdown={phaseData?.phases} + /> + + {/* Duration */} + + {formatDuration(subagent.durationMs)} + + + {/* Timestamp — rightmost info element */} + + {format(subagent.startTime, 'HH:mm:ss')} + +
+ + {/* ========== Level 1 Expanded: Dashboard Content ========== */} + {isExpanded && ( +
+ {/* ========== Row 1: Meta Info (Horizontal Flow) ========== */} +
+ + Type{' '} + + {subagentType} + + + + + Duration{' '} + + {formatDuration(subagent.durationMs)} + + + {modelInfo && ( + <> + + + Model{' '} + + {modelInfo.name} + + + + )} + + + ID{' '} + + {subagent.id.slice(0, 8)} + + +
+ + {/* ========== Row 2: Context Usage (Clean List) ========== */} + {(hasMainImpact ?? hasIsolated) && ( +
+ {/* Overline title */} +
+ Context Usage +
+ + {/* Token rows - floating alignment */} +
+ {hasMainImpact && !subagent.team && ( +
+
+ + + Main Context + +
+ + {subagent.mainSessionImpact!.totalTokens.toLocaleString()} + +
+ )} + + {cumulativeMetrics && ( +
+
+ + + Total Output + +
+ + {cumulativeMetrics.outputTokens.toLocaleString()} + + {' '} + ({cumulativeMetrics.turnCount} turns) + + +
+ )} + + {hasIsolated && ( +
+
+ + + {subagent.team ? 'Context Window' : 'Subagent Context'} + +
+ + {isolatedTotal.toLocaleString()} + +
+ )} + + {/* Per-phase breakdown when multi-phase */} + {isMultiPhase && + phaseData.phases.map((phase) => ( +
+ + Phase {phase.phaseNumber} + + + {formatTokensCompact(phase.peakTokens)} + {phase.postCompaction != null && ( + + {' '} + → {formatTokensCompact(phase.postCompaction)} + + )} + +
+ ))} +
+
+ )} + + {/* ========== Level 2: Execution Trace Toggle ========== */} + {displayItems.length > 0 && (
{ - e.stopPropagation(); - toggleSubagentTraceExpansion(subagent.id); + className="overflow-hidden rounded-md" + style={{ + border: CARD_BORDER_STYLE, + backgroundColor: CARD_HEADER_BG, }} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); + > + {/* Trace Header (clickable) */} +
{ e.stopPropagation(); toggleSubagentTraceExpansion(subagent.id); - } - }} - className="flex cursor-pointer items-center gap-2 px-3 py-2 transition-colors" - style={{ - borderBottom: isTraceExpanded ? CARD_BORDER_STYLE : 'none', - backgroundColor: isTraceHeaderHovered ? CARD_HEADER_HOVER : 'transparent', - }} - onMouseEnter={() => setIsTraceHeaderHovered(true)} - onMouseLeave={() => setIsTraceHeaderHovered(false)} - > - - - - Execution Trace - - - · {itemsSummary} - -
- - {/* Trace Content */} - {isTraceExpanded && ( -
- { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + toggleSubagentTraceExpansion(subagent.id); } - registerToolRef={registerToolRef} + }} + className="flex cursor-pointer items-center gap-2 px-3 py-2 transition-colors" + style={{ + borderBottom: isTraceExpanded ? CARD_BORDER_STYLE : 'none', + backgroundColor: isTraceHeaderHovered ? CARD_HEADER_HOVER : 'transparent', + }} + onMouseEnter={() => setIsTraceHeaderHovered(true)} + onMouseLeave={() => setIsTraceHeaderHovered(false)} + > + + + + Execution Trace + + + · {itemsSummary} +
- )} -
- )} -
- )} -
- ); -}; + + {/* Trace Content */} + {isTraceExpanded && ( +
+ +
+ )} +
+ )} +
+ )} +
+ ); + } +); diff --git a/src/renderer/components/chat/items/TextItem.tsx b/src/renderer/components/chat/items/TextItem.tsx index 9e94e566..ed53418d 100644 --- a/src/renderer/components/chat/items/TextItem.tsx +++ b/src/renderer/components/chat/items/TextItem.tsx @@ -31,52 +31,54 @@ interface TextItemProps { titleText?: string; } -export const TextItem: React.FC = ({ - step, - preview, - onClick, - isExpanded, - timestamp, - timestampFormat, - searchQueryOverride, - markdownItemId, - highlightClasses, - highlightStyle, - notificationDotColor, - titleText, -}) => { - const fullContent = step.content.outputText ?? preview; - const summary = searchQueryOverride - ? highlightQueryInText(preview, searchQueryOverride, `${markdownItemId ?? step.id}:summary`, { - forceAllActive: true, - }) - : preview; +export const TextItem: React.FC = React.memo( + ({ + step, + preview, + onClick, + isExpanded, + timestamp, + timestampFormat, + searchQueryOverride, + markdownItemId, + highlightClasses, + highlightStyle, + notificationDotColor, + titleText, + }) => { + const fullContent = step.content.outputText ?? preview; + const summary = searchQueryOverride + ? highlightQueryInText(preview, searchQueryOverride, `${markdownItemId ?? step.id}:summary`, { + forceAllActive: true, + }) + : preview; - // Get token count from step.tokens.output or step.content.tokenCount - const tokenCount = step.tokens?.output ?? step.content.tokenCount ?? 0; + // Get token count from step.tokens.output or step.content.tokenCount + const tokenCount = step.tokens?.output ?? step.content.tokenCount ?? 0; - return ( - } - label="Output" - summary={summary} - tokenCount={tokenCount} - timestamp={timestamp} - timestampFormat={timestampFormat} - titleText={titleText} - onClick={onClick} - isExpanded={isExpanded} - highlightClasses={highlightClasses} - highlightStyle={highlightStyle} - notificationDotColor={notificationDotColor} - > - - - ); -}; + return ( + } + label="Output" + summary={summary} + tokenCount={tokenCount} + timestamp={timestamp} + timestampFormat={timestampFormat} + titleText={titleText} + onClick={onClick} + isExpanded={isExpanded} + highlightClasses={highlightClasses} + highlightStyle={highlightStyle} + notificationDotColor={notificationDotColor} + > + + + ); + } +); diff --git a/src/renderer/components/chat/items/ThinkingItem.tsx b/src/renderer/components/chat/items/ThinkingItem.tsx index 116a9680..5a681ff5 100644 --- a/src/renderer/components/chat/items/ThinkingItem.tsx +++ b/src/renderer/components/chat/items/ThinkingItem.tsx @@ -31,52 +31,54 @@ interface ThinkingItemProps { titleText?: string; } -export const ThinkingItem: React.FC = ({ - step, - preview, - onClick, - isExpanded, - timestamp, - timestampFormat, - searchQueryOverride, - markdownItemId, - highlightClasses, - highlightStyle, - notificationDotColor, - titleText, -}) => { - const fullContent = step.content.thinkingText ?? preview; - const summary = searchQueryOverride - ? highlightQueryInText(preview, searchQueryOverride, `${markdownItemId ?? step.id}:summary`, { - forceAllActive: true, - }) - : preview; +export const ThinkingItem: React.FC = React.memo( + ({ + step, + preview, + onClick, + isExpanded, + timestamp, + timestampFormat, + searchQueryOverride, + markdownItemId, + highlightClasses, + highlightStyle, + notificationDotColor, + titleText, + }) => { + const fullContent = step.content.thinkingText ?? preview; + const summary = searchQueryOverride + ? highlightQueryInText(preview, searchQueryOverride, `${markdownItemId ?? step.id}:summary`, { + forceAllActive: true, + }) + : preview; - // Get token count from step.tokens.output or step.content.tokenCount - const tokenCount = step.tokens?.output ?? step.content.tokenCount ?? 0; + // Get token count from step.tokens.output or step.content.tokenCount + const tokenCount = step.tokens?.output ?? step.content.tokenCount ?? 0; - return ( - } - label="Thinking" - summary={summary} - tokenCount={tokenCount} - timestamp={timestamp} - timestampFormat={timestampFormat} - titleText={titleText} - onClick={onClick} - isExpanded={isExpanded} - highlightClasses={highlightClasses} - highlightStyle={highlightStyle} - notificationDotColor={notificationDotColor} - > - - - ); -}; + return ( + } + label="Thinking" + summary={summary} + tokenCount={tokenCount} + timestamp={timestamp} + timestampFormat={timestampFormat} + titleText={titleText} + onClick={onClick} + isExpanded={isExpanded} + highlightClasses={highlightClasses} + highlightStyle={highlightStyle} + notificationDotColor={notificationDotColor} + > + + + ); + } +); diff --git a/src/renderer/components/sidebar/SessionItem.tsx b/src/renderer/components/sidebar/SessionItem.tsx index ab6b72a9..10477dc9 100644 --- a/src/renderer/components/sidebar/SessionItem.tsx +++ b/src/renderer/components/sidebar/SessionItem.tsx @@ -4,7 +4,7 @@ * Supports right-click context menu for pane management. */ -import { useCallback, useRef, useState } from 'react'; +import { memo, useCallback, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; @@ -156,238 +156,242 @@ const SessionRuntimeBadge = ({ ); }; -export const SessionItem = ({ - session, - isActive, - isPinned, - isHidden, - multiSelectActive, - isSelected, - onToggleSelect, -}: Readonly): React.JSX.Element => { - const { - openTab, - activeProjectId, - selectSession, - paneCount, - splitPane, - togglePinSession, - toggleHideSession, - } = useStore( - useShallow((s) => ({ - openTab: s.openTab, - activeProjectId: s.activeProjectId, - selectSession: s.selectSession, - paneCount: s.paneLayout.panes.length, - splitPane: s.splitPane, - togglePinSession: s.togglePinSession, - toggleHideSession: s.toggleHideSession, - })) - ); - - const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); - - const handleClick = (event: React.MouseEvent): void => { - if (!activeProjectId) return; - - // In multi-select mode, clicks toggle selection - if (multiSelectActive && onToggleSelect) { - onToggleSelect(); - return; - } - - // Cmd/Ctrl+click: open in new tab; plain click: replace current tab - const forceNewTab = event.ctrlKey || event.metaKey; - - openTab( - { - type: 'session', - sessionId: session.id, - projectId: activeProjectId, - label: formatSessionLabel(session.firstMessage), - }, - forceNewTab ? { forceNewTab } : { replaceActiveTab: true } +export const SessionItem = memo( + ({ + session, + isActive, + isPinned, + isHidden, + multiSelectActive, + isSelected, + onToggleSelect, + }: Readonly): React.JSX.Element => { + const { + openTab, + activeProjectId, + selectSession, + paneCount, + splitPane, + togglePinSession, + toggleHideSession, + } = useStore( + useShallow((s) => ({ + openTab: s.openTab, + activeProjectId: s.activeProjectId, + selectSession: s.selectSession, + paneCount: s.paneLayout.panes.length, + splitPane: s.splitPane, + togglePinSession: s.togglePinSession, + toggleHideSession: s.toggleHideSession, + })) ); - selectSession(session.id); - }; + const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); - const handleContextMenu = useCallback((e: React.MouseEvent) => { - e.preventDefault(); - setContextMenu({ x: e.clientX, y: e.clientY }); - }, []); + const handleClick = (event: React.MouseEvent): void => { + if (!activeProjectId) return; - const sessionLabel = formatSessionLabel(session.firstMessage); + // In multi-select mode, clicks toggle selection + if (multiSelectActive && onToggleSelect) { + onToggleSelect(); + return; + } - const handleOpenInCurrentPane = useCallback(() => { - if (!activeProjectId) return; - openTab( - { + // Cmd/Ctrl+click: open in new tab; plain click: replace current tab + const forceNewTab = event.ctrlKey || event.metaKey; + + openTab( + { + type: 'session', + sessionId: session.id, + projectId: activeProjectId, + label: formatSessionLabel(session.firstMessage), + }, + forceNewTab ? { forceNewTab } : { replaceActiveTab: true } + ); + + selectSession(session.id); + }; + + const handleContextMenu = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + setContextMenu({ x: e.clientX, y: e.clientY }); + }, []); + + const sessionLabel = formatSessionLabel(session.firstMessage); + + const handleOpenInCurrentPane = useCallback(() => { + if (!activeProjectId) return; + openTab( + { + type: 'session', + sessionId: session.id, + projectId: activeProjectId, + label: sessionLabel, + }, + { replaceActiveTab: true } + ); + selectSession(session.id); + }, [activeProjectId, openTab, selectSession, session.id, sessionLabel]); + + const handleOpenInNewTab = useCallback(() => { + if (!activeProjectId) return; + openTab( + { + type: 'session', + sessionId: session.id, + projectId: activeProjectId, + label: sessionLabel, + }, + { forceNewTab: true } + ); + selectSession(session.id); + }, [activeProjectId, openTab, selectSession, session.id, sessionLabel]); + + const handleSplitRightAndOpen = useCallback(() => { + if (!activeProjectId) return; + // First open the tab in the focused pane + openTab({ type: 'session', sessionId: session.id, projectId: activeProjectId, label: sessionLabel, - }, - { replaceActiveTab: true } - ); - selectSession(session.id); - }, [activeProjectId, openTab, selectSession, session.id, sessionLabel]); + }); + selectSession(session.id); + // Then split it to the right + const state = useStore.getState(); + const focusedPaneId = state.paneLayout.focusedPaneId; + const activeTabId = state.activeTabId; + if (activeTabId) { + splitPane(focusedPaneId, activeTabId, 'right'); + } + }, [activeProjectId, openTab, selectSession, session.id, sessionLabel, splitPane]); - const handleOpenInNewTab = useCallback(() => { - if (!activeProjectId) return; - openTab( - { - type: 'session', - sessionId: session.id, - projectId: activeProjectId, - label: sessionLabel, - }, - { forceNewTab: true } - ); - selectSession(session.id); - }, [activeProjectId, openTab, selectSession, session.id, sessionLabel]); - - const handleSplitRightAndOpen = useCallback(() => { - if (!activeProjectId) return; - // First open the tab in the focused pane - openTab({ - type: 'session', - sessionId: session.id, - projectId: activeProjectId, - label: sessionLabel, - }); - selectSession(session.id); - // Then split it to the right - const state = useStore.getState(); - const focusedPaneId = state.paneLayout.focusedPaneId; - const activeTabId = state.activeTabId; - if (activeTabId) { - splitPane(focusedPaneId, activeTabId, 'right'); - } - }, [activeProjectId, openTab, selectSession, session.id, sessionLabel, splitPane]); - - // Height must match SESSION_HEIGHT (54px) in DateGroupedSessions.tsx for virtual scroll - return ( - <> - + )} + {session.isOngoing && } + {isPinned && } + {isHidden && } + {isTeam ? ( + + + {parsed.displayText} + + ) : ( + + {parsed.displayText} + + )} +
- {contextMenu && - activeProjectId && - createPortal( - setContextMenu(null)} - onOpenInCurrentPane={handleOpenInCurrentPane} - onOpenInNewTab={handleOpenInNewTab} - onSplitRightAndOpen={handleSplitRightAndOpen} - onTogglePin={() => void togglePinSession(session.id)} - onToggleHide={() => void toggleHideSession(session.id)} - />, - document.body - )} - - ); -}; + {/* Second line: metadata */} +
+ {isTeam && parsed.projectName && ( + <> + {parsed.projectName} + · + + )} + {isTeam && ( + <> + + {parsed.kind === 'team-resume' ? ( + + ) : ( + + )} + {parsed.kind === 'team-resume' ? 'resume' : 'new'} + + · + + )} + + + {session.messageCount} + + · + + {formatShortTime(new Date(session.createdAt))} + + {session.model && ( + <> + · + + + )} + {session.contextConsumption != null && session.contextConsumption > 0 && ( + <> + · + + + )} +
+ + ); + })()} + + + {contextMenu && + activeProjectId && + createPortal( + setContextMenu(null)} + onOpenInCurrentPane={handleOpenInCurrentPane} + onOpenInNewTab={handleOpenInNewTab} + onSplitRightAndOpen={handleSplitRightAndOpen} + onTogglePin={() => void togglePinSession(session.id)} + onToggleHide={() => void toggleHideSession(session.id)} + />, + document.body + )} + + ); + } +); From 2bda324e1a2a7e8bd4c1665e1c3daefac2f6453c Mon Sep 17 00:00:00 2001 From: Mike Date: Sat, 2 May 2026 21:10:24 +0500 Subject: [PATCH 04/14] perf(renderer): stable callbacks and lazy-load large dialogs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move toggleSidebarSessionSelection into SessionItem's own store subscription, eliminating the inline arrow function prop that was breaking its memo on every sidebar render. Lazy-load LaunchTeamDialog (2918L) and CreateTeamDialog (2208L) in all four host components (TeamDetailView, TeamListView, SchedulesView, ScheduleSection). These dialogs are never needed at initial mount — they only open on user action. Deferring their parse/compile saves ~175KB of JS from the initial render path. --- .../components/schedules/SchedulesView.tsx | 23 ++++--- .../sidebar/DateGroupedSessions.tsx | 3 - .../components/sidebar/SessionItem.tsx | 10 +-- .../components/team/TeamDetailView.tsx | 59 +++++++++------- src/renderer/components/team/TeamListView.tsx | 69 +++++++++++-------- .../team/schedule/ScheduleSection.tsx | 24 ++++--- 6 files changed, 105 insertions(+), 83 deletions(-) diff --git a/src/renderer/components/schedules/SchedulesView.tsx b/src/renderer/components/schedules/SchedulesView.tsx index 4ee09057..3535cc28 100644 --- a/src/renderer/components/schedules/SchedulesView.tsx +++ b/src/renderer/components/schedules/SchedulesView.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react'; import { Button } from '@renderer/components/ui/button'; import { Input } from '@renderer/components/ui/input'; @@ -24,8 +24,11 @@ import { } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; -import { LaunchTeamDialog } from '../team/dialogs/LaunchTeamDialog'; import { ScheduleRunLogDialog } from '../team/schedule/ScheduleRunLogDialog'; + +const LaunchTeamDialog = lazy(() => + import('../team/dialogs/LaunchTeamDialog').then((m) => ({ default: m.LaunchTeamDialog })) +); import { ScheduleRunRow } from '../team/schedule/ScheduleRunRow'; import { ScheduleStatusBadge } from '../team/schedule/ScheduleStatusBadge'; @@ -562,13 +565,15 @@ export const SchedulesView = (): React.JSX.Element => {
{/* Create/Edit Dialog */} - + + +
); }; diff --git a/src/renderer/components/sidebar/DateGroupedSessions.tsx b/src/renderer/components/sidebar/DateGroupedSessions.tsx index 39827dcb..5efd93bf 100644 --- a/src/renderer/components/sidebar/DateGroupedSessions.tsx +++ b/src/renderer/components/sidebar/DateGroupedSessions.tsx @@ -202,7 +202,6 @@ export const DateGroupedSessions = memo((): React.JSX.Element => { toggleShowHiddenSessions, sidebarSelectedSessionIds, sidebarMultiSelectActive, - toggleSidebarSessionSelection, clearSidebarSelection, toggleSidebarMultiSelect, hideMultipleSessions, @@ -239,7 +238,6 @@ export const DateGroupedSessions = memo((): React.JSX.Element => { toggleShowHiddenSessions: s.toggleShowHiddenSessions, sidebarSelectedSessionIds: s.sidebarSelectedSessionIds, sidebarMultiSelectActive: s.sidebarMultiSelectActive, - toggleSidebarSessionSelection: s.toggleSidebarSessionSelection, clearSidebarSelection: s.clearSidebarSelection, toggleSidebarMultiSelect: s.toggleSidebarMultiSelect, hideMultipleSessions: s.hideMultipleSessions, @@ -1104,7 +1102,6 @@ export const DateGroupedSessions = memo((): React.JSX.Element => { isHidden={item.isHidden} multiSelectActive={sidebarMultiSelectActive} isSelected={selectedSet.has(item.session.id)} - onToggleSelect={() => toggleSidebarSessionSelection(item.session.id)} /> )}
diff --git a/src/renderer/components/sidebar/SessionItem.tsx b/src/renderer/components/sidebar/SessionItem.tsx index 10477dc9..dbf534c4 100644 --- a/src/renderer/components/sidebar/SessionItem.tsx +++ b/src/renderer/components/sidebar/SessionItem.tsx @@ -30,7 +30,6 @@ interface SessionItemProps { isHidden?: boolean; multiSelectActive?: boolean; isSelected?: boolean; - onToggleSelect?: () => void; } /** @@ -164,7 +163,6 @@ export const SessionItem = memo( isHidden, multiSelectActive, isSelected, - onToggleSelect, }: Readonly): React.JSX.Element => { const { openTab, @@ -174,6 +172,7 @@ export const SessionItem = memo( splitPane, togglePinSession, toggleHideSession, + toggleSidebarSessionSelection, } = useStore( useShallow((s) => ({ openTab: s.openTab, @@ -183,6 +182,7 @@ export const SessionItem = memo( splitPane: s.splitPane, togglePinSession: s.togglePinSession, toggleHideSession: s.toggleHideSession, + toggleSidebarSessionSelection: s.toggleSidebarSessionSelection, })) ); @@ -192,8 +192,8 @@ export const SessionItem = memo( if (!activeProjectId) return; // In multi-select mode, clicks toggle selection - if (multiSelectActive && onToggleSelect) { - onToggleSelect(); + if (multiSelectActive) { + toggleSidebarSessionSelection(session.id); return; } @@ -291,7 +291,7 @@ export const SessionItem = memo( onToggleSelect?.()} + onChange={() => toggleSidebarSessionSelection(session.id)} onClick={(e) => e.stopPropagation()} className="size-3.5 shrink-0 accent-blue-500" /> diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 99f44e83..d949b9b2 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -80,7 +80,7 @@ import { useShallow } from 'zustand/react/shallow'; import { AddMemberDialog } from './dialogs/AddMemberDialog'; import { CreateTaskDialog } from './dialogs/CreateTaskDialog'; import { EditTeamDialog } from './dialogs/EditTeamDialog'; -import { LaunchTeamDialog, type TeamLaunchDialogMode } from './dialogs/LaunchTeamDialog'; +import type { TeamLaunchDialogMode } from './dialogs/LaunchTeamDialog'; import { ReviewDialog } from './dialogs/ReviewDialog'; import { SendMessageDialog } from './dialogs/SendMessageDialog'; import { TaskDetailDialog } from './dialogs/TaskDetailDialog'; @@ -96,6 +96,9 @@ import type { AddMemberEntry } from './dialogs/AddMemberDialog'; import type { TeamMessagesPanelMode } from '@renderer/types/teamMessagesPanelMode'; import type { ComponentProps, CSSProperties } from 'react'; +const LaunchTeamDialog = lazy(() => + import('./dialogs/LaunchTeamDialog').then((m) => ({ default: m.LaunchTeamDialog })) +); const ProjectEditorOverlay = lazy(() => import('./editor/ProjectEditorOverlay').then((m) => ({ default: m.ProjectEditorOverlay })) ); @@ -2176,18 +2179,20 @@ export const TeamDetailView = memo(
- + + + ); } @@ -2976,19 +2981,21 @@ export const TeamDetailView = memo( - + + + + import('./dialogs/CreateTeamDialog').then((m) => ({ default: m.CreateTeamDialog })) +); +const LaunchTeamDialog = lazy(() => + import('./dialogs/LaunchTeamDialog').then((m) => ({ default: m.LaunchTeamDialog })) +); + import type { TeamListFilterState } from './TeamListFilterPopover'; import type { TeamStatus } from '@renderer/utils/teamListStatus'; import type { @@ -732,35 +737,39 @@ export const TeamListView = memo((): React.JSX.Element => { } const createDialogElement = ( - t.teamName)} - provisioningTeamNames={provisioningTeamNames} - activeTeams={activeTeams} - initialData={copyData ?? undefined} - defaultProjectPath={currentProjectPath} - onClose={handleCreateDialogClose} - onCreate={handleCreateSubmit} - onOpenTeam={openTeamTab} - /> + + t.teamName)} + provisioningTeamNames={provisioningTeamNames} + activeTeams={activeTeams} + initialData={copyData ?? undefined} + defaultProjectPath={currentProjectPath} + onClose={handleCreateDialogClose} + onCreate={handleCreateSubmit} + onOpenTeam={openTeamTab} + /> + ); const launchDialogElement = ( - setLaunchDialogOpen(false)} - onLaunch={handleLaunchSubmit} - /> + + setLaunchDialogOpen(false)} + onLaunch={handleLaunchSubmit} + /> + ); const renderHeader = (): React.JSX.Element => ( diff --git a/src/renderer/components/team/schedule/ScheduleSection.tsx b/src/renderer/components/team/schedule/ScheduleSection.tsx index b8f44bd4..9de22a43 100644 --- a/src/renderer/components/team/schedule/ScheduleSection.tsx +++ b/src/renderer/components/team/schedule/ScheduleSection.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { lazy, Suspense, useCallback, useEffect, useState } from 'react'; import { Button } from '@renderer/components/ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; @@ -18,9 +18,11 @@ import { } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; -import { LaunchTeamDialog } from '../dialogs/LaunchTeamDialog'; - import { ScheduleEmptyState } from './ScheduleEmptyState'; + +const LaunchTeamDialog = lazy(() => + import('../dialogs/LaunchTeamDialog').then((m) => ({ default: m.LaunchTeamDialog })) +); import { ScheduleRunLogDialog } from './ScheduleRunLogDialog'; import { ScheduleRunRow } from './ScheduleRunRow'; import { ScheduleStatusBadge } from './ScheduleStatusBadge'; @@ -305,13 +307,15 @@ export const ScheduleSection = ({ teamName }: ScheduleSectionProps): React.JSX.E )} {/* Create/Edit Dialog */} - + + +
); }; From 8b30930c043a97bd420ba4f07db9725f32d3f501 Mon Sep 17 00:00:00 2001 From: Mike Date: Sat, 2 May 2026 21:20:25 +0500 Subject: [PATCH 05/14] perf: memoize KanbanBoard, KanbanGridLayout, MemberCard, TaskRow, SidebarTaskItem Wrap five hot-path components in React.memo to prevent unnecessary re-renders when parent state changes don't affect their props. --- .../components/sidebar/SidebarTaskItem.tsx | 378 +++--- .../components/team/kanban/KanbanBoard.tsx | 837 ++++++------ .../team/kanban/KanbanGridLayout.tsx | 118 +- .../components/team/members/MemberCard.tsx | 1162 +++++++++-------- .../components/team/tasks/TaskRow.tsx | 6 +- 5 files changed, 1263 insertions(+), 1238 deletions(-) diff --git a/src/renderer/components/sidebar/SidebarTaskItem.tsx b/src/renderer/components/sidebar/SidebarTaskItem.tsx index 688aa958..5469b20f 100644 --- a/src/renderer/components/sidebar/SidebarTaskItem.tsx +++ b/src/renderer/components/sidebar/SidebarTaskItem.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { memo, useEffect, useMemo, useRef, useState } from 'react'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { getTeamColorSet } from '@renderer/constants/teamColors'; @@ -69,218 +69,220 @@ interface SidebarTaskItemProps { getDisplaySubject?: (task: GlobalTask) => string | undefined; } -export const SidebarTaskItem = ({ - task, - hideTeamName, - showTeamName, - renamingKey, - onRenameComplete, - onRenameCancel, - getDisplaySubject, -}: SidebarTaskItemProps): React.JSX.Element => { - const openGlobalTaskDetail = useStore((s) => s.openGlobalTaskDetail); - const teamMembers = useStore(useShallow((s) => s.teamByName[task.teamName]?.members)); - const unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments); - const { isLight } = useTheme(); +export const SidebarTaskItem = memo( + ({ + task, + hideTeamName, + showTeamName, + renamingKey, + onRenameComplete, + onRenameCancel, + getDisplaySubject, + }: SidebarTaskItemProps): React.JSX.Element => { + const openGlobalTaskDetail = useStore((s) => s.openGlobalTaskDetail); + const teamMembers = useStore(useShallow((s) => s.teamByName[task.teamName]?.members)); + const unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments); + const { isLight } = useTheme(); - const isRenaming = renamingKey === `${task.teamName}:${task.id}`; - const displaySubject = getDisplaySubject?.(task) ?? task.subject; - const [editValue, setEditValue] = useState(displaySubject); - const inputRef = useRef(null); - // Focus input when rename starts - useEffect(() => { - if (!isRenaming) return; - const raf = requestAnimationFrame(() => { - inputRef.current?.focus(); - inputRef.current?.select(); - }); - return () => cancelAnimationFrame(raf); - }, [isRenaming]); + const isRenaming = renamingKey === `${task.teamName}:${task.id}`; + const displaySubject = getDisplaySubject?.(task) ?? task.subject; + const [editValue, setEditValue] = useState(displaySubject); + const inputRef = useRef(null); + // Focus input when rename starts + useEffect(() => { + if (!isRenaming) return; + const raf = requestAnimationFrame(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }); + return () => cancelAnimationFrame(raf); + }, [isRenaming]); - // Reset edit value when renaming starts - useEffect(() => { - if (isRenaming) { - // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change - setEditValue(displaySubject); - } - }, [isRenaming, displaySubject]); + // Reset edit value when renaming starts + useEffect(() => { + if (isRenaming) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change + setEditValue(displaySubject); + } + }, [isRenaming, displaySubject]); - const reviewColumn = getTaskKanbanColumn(task); - const cfg = - reviewColumn === 'approved' - ? ({ icon: ShieldCheck, color: 'text-teal-400', label: 'approved' } as const) - : reviewColumn === 'review' - ? ({ icon: Eye, color: 'text-orange-400', label: 'in review' } as const) - : (statusConfig[task.status] ?? statusConfig.pending); - const StatusIcon = cfg.icon; - const updatedLabel = formatUpdatedLabel(task); - const dateLabel = updatedLabel ?? formatTaskDate(task.createdAt); + const reviewColumn = getTaskKanbanColumn(task); + const cfg = + reviewColumn === 'approved' + ? ({ icon: ShieldCheck, color: 'text-teal-400', label: 'approved' } as const) + : reviewColumn === 'review' + ? ({ icon: Eye, color: 'text-orange-400', label: 'in review' } as const) + : (statusConfig[task.status] ?? statusConfig.pending); + const StatusIcon = cfg.icon; + const updatedLabel = formatUpdatedLabel(task); + const dateLabel = updatedLabel ?? formatTaskDate(task.createdAt); - const ownerColorSet = useMemo(() => { - if (!teamMembers || !task.owner) return null; - const colorMap = buildMemberColorMap(teamMembers); - const colorName = colorMap.get(task.owner); - return colorName ? getTeamColorSet(colorName) : null; - }, [teamMembers, task.owner]); + const ownerColorSet = useMemo(() => { + if (!teamMembers || !task.owner) return null; + const colorMap = buildMemberColorMap(teamMembers); + const colorName = colorMap.get(task.owner); + return colorName ? getTeamColorSet(colorName) : null; + }, [teamMembers, task.owner]); - const ownerTextColor = useMemo(() => { - if (!ownerColorSet) return undefined; - return isLight && ownerColorSet.textLight ? ownerColorSet.textLight : ownerColorSet.text; - }, [ownerColorSet, isLight]); + const ownerTextColor = useMemo(() => { + if (!ownerColorSet) return undefined; + return isLight && ownerColorSet.textLight ? ownerColorSet.textLight : ownerColorSet.text; + }, [ownerColorSet, isLight]); - const projectLabel = useMemo(() => { - if (!task.projectPath?.trim()) return null; - return projectLabelFromPath(task.projectPath); - }, [task.projectPath]); + const projectLabel = useMemo(() => { + if (!task.projectPath?.trim()) return null; + return projectLabelFromPath(task.projectPath); + }, [task.projectPath]); - const projectColorSet = useMemo( - () => (projectLabel ? projectColor(projectLabel, isLight) : null), - [projectLabel, isLight] - ); + const projectColorSet = useMemo( + () => (projectLabel ? projectColor(projectLabel, isLight) : null), + [projectLabel, isLight] + ); - const teamColor = useMemo( - () => (showTeamName ? nameColorSet(task.teamDisplayName, isLight) : null), - [showTeamName, task.teamDisplayName, isLight] - ); + const teamColor = useMemo( + () => (showTeamName ? nameColorSet(task.teamDisplayName, isLight) : null), + [showTeamName, task.teamDisplayName, isLight] + ); - const showTeamRow = showTeamName && !hideTeamName; - const unreadBackgroundClass = - unreadCount > 0 ? (isLight ? 'bg-blue-500/[0.03]' : 'bg-blue-500/[0.05]') : ''; + const showTeamRow = showTeamName && !hideTeamName; + const unreadBackgroundClass = + unreadCount > 0 ? (isLight ? 'bg-blue-500/[0.03]' : 'bg-blue-500/[0.05]') : ''; - return ( -
+ + + )} +
- {/* Row 2: project + owner (when no team row) + date */} -
- {task.teamDeleted && } - {projectLabel && ( - + {task.teamDeleted && } + {projectLabel && ( + + {projectLabel} + + )} + {!showTeamRow && ( + <> + {projectLabel && ·} + + {task.owner ?? 'unassigned'} + + + )} + {dateLabel && ( + + {dateLabel} + + )} +
+ + {/* Row 3: Team: name · owner */} + {showTeamRow && ( +
- {projectLabel} - - )} - {!showTeamRow && ( - <> - {projectLabel && ·} + Team: + + {task.teamDisplayName} + + · {task.owner ?? 'unassigned'} - +
)} - {dateLabel && ( - - {dateLabel} - - )} -
- - {/* Row 3: Team: name · owner */} - {showTeamRow && ( -
- Team: - - {task.teamDisplayName} - - · - - {task.owner ?? 'unassigned'} - -
- )} - - ); -}; + + ); + } +); diff --git a/src/renderer/components/team/kanban/KanbanBoard.tsx b/src/renderer/components/team/kanban/KanbanBoard.tsx index 62d5759e..056f0014 100644 --- a/src/renderer/components/team/kanban/KanbanBoard.tsx +++ b/src/renderer/components/team/kanban/KanbanBoard.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { DndContext, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; import { arrayMove } from '@dnd-kit/sortable'; @@ -311,445 +311,454 @@ const SortableKanbanTaskCard = ({ ); }; -export const KanbanBoard = ({ - tasks, - teamName, - kanbanState, - filter, - sort, - sessions, - leadSessionId, - members, - onFilterChange, - onSortChange, - onRequestReview, - onApprove, - onRequestChanges, - onMoveBackToDone, - onStartTask, - onCompleteTask, - onCancelTask, - onScrollToTask, - onTaskClick, - onViewChanges, - onColumnOrderChange, - toolbarLeft, - onAddTask, - onDeleteTask, - deletedTaskCount, - onOpenTrash, -}: KanbanBoardProps): React.JSX.Element => { - const boardRef = useRef(null); - const scrollRestoreTimeoutsRef = useRef([]); - const [viewMode, setViewMode] = useState('grid'); - const [gridPrimaryColumnWidth, setGridPrimaryColumnWidth] = useState(null); - const [gridSkeletonDelayMs, setGridSkeletonDelayMs] = useState(SKELETON_HIDE_DELAY_MS); - const hasReviewers = kanbanState.reviewers.length > 0; - const enableTaskSorting = - viewMode === 'columns' && !!onColumnOrderChange && sort.field === 'manual'; +export const KanbanBoard = memo( + ({ + tasks, + teamName, + kanbanState, + filter, + sort, + sessions, + leadSessionId, + members, + onFilterChange, + onSortChange, + onRequestReview, + onApprove, + onRequestChanges, + onMoveBackToDone, + onStartTask, + onCompleteTask, + onCancelTask, + onScrollToTask, + onTaskClick, + onViewChanges, + onColumnOrderChange, + toolbarLeft, + onAddTask, + onDeleteTask, + deletedTaskCount, + onOpenTrash, + }: KanbanBoardProps): React.JSX.Element => { + const boardRef = useRef(null); + const scrollRestoreTimeoutsRef = useRef([]); + const [viewMode, setViewMode] = useState('grid'); + const [gridPrimaryColumnWidth, setGridPrimaryColumnWidth] = useState(null); + const [gridSkeletonDelayMs, setGridSkeletonDelayMs] = useState(SKELETON_HIDE_DELAY_MS); + const hasReviewers = kanbanState.reviewers.length > 0; + const enableTaskSorting = + viewMode === 'columns' && !!onColumnOrderChange && sort.field === 'manual'; - const stableTaskMapRef = useRef<{ - signatures: string[]; - map: Map; - } | null>(null); - const taskMap = useMemo(() => { - const signatures = tasks.map( - (task) => `${task.id}\0${task.displayId ?? ''}\0${task.subject}\0${task.status}` - ); - const previous = stableTaskMapRef.current; - if ( - previous?.signatures.length === signatures.length && - previous.signatures.every((signature, index) => signature === signatures[index]) - ) { - return previous.map; - } - - const next = new Map(tasks.map((task) => [task.id, task])); - stableTaskMapRef.current = { signatures, map: next }; - return next; - }, [tasks]); - const memberColorMap = useMemo(() => buildMemberColorMap(members), [members]); - const grouped = useMemo(() => { - const result = new Map( - COLUMNS.map(({ id }) => [id, [] as TeamTask[]]) - ); - for (const task of tasks) { - const column = getTaskColumn(task, kanbanState); - if (!column) { - continue; - } - result.get(column)?.push(task); - } - return result; - }, [tasks, kanbanState]); - - const groupedOrdered = useMemo(() => { - const result = new Map(); - for (const column of COLUMNS) { - const columnTasks = grouped.get(column.id) ?? []; - const order = kanbanState.columnOrder?.[column.id]; - result.set(column.id, sortColumnTasksByField(columnTasks, sort.field, order)); - } - return result; - }, [grouped, kanbanState.columnOrder, sort.field]); - - const sensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { distance: 8 }, - }) - ); - - const handleDragEnd = useCallback( - (event: DragEndEvent) => { - const { active, over } = event; - if (!onColumnOrderChange || !over || active.id === over.id) { - return; - } - const activeData = active.data.current; - if (activeData?.type !== 'kanban-task') { - return; - } - const columnId = activeData.columnId as KanbanColumnId; - const orderedIds = groupedOrdered.get(columnId)?.map((t) => t.id) ?? []; - const oldIndex = orderedIds.indexOf(active.id as string); - const newIndex = orderedIds.indexOf(over.id as string); - if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) { - return; - } - const newOrder = arrayMove(orderedIds, oldIndex, newIndex); - onColumnOrderChange(columnId, newOrder); - }, - [onColumnOrderChange, groupedOrdered] - ); - - const renderCards = ( - columnId: KanbanColumnId, - columnTasks: TeamTask[], - compact?: boolean - ): React.JSX.Element => { - const addHandler = - onAddTask && columnId === 'todo' - ? () => onAddTask(false) - : onAddTask && columnId === 'in_progress' - ? () => onAddTask(true) - : undefined; - - const addButton = addHandler ? ( - - ) : null; - - if (columnTasks.length === 0) { - return ( - addButton ?? ( -
- No tasks -
- ) + const stableTaskMapRef = useRef<{ + signatures: string[]; + map: Map; + } | null>(null); + const taskMap = useMemo(() => { + const signatures = tasks.map( + (task) => `${task.id}\0${task.displayId ?? ''}\0${task.subject}\0${task.status}` ); - } - if (enableTaskSorting) { - const itemIds = columnTasks.map((t) => t.id); + const previous = stableTaskMapRef.current; + if ( + previous?.signatures.length === signatures.length && + previous.signatures.every((signature, index) => signature === signatures[index]) + ) { + return previous.map; + } + + const next = new Map(tasks.map((task) => [task.id, task])); + stableTaskMapRef.current = { signatures, map: next }; + return next; + }, [tasks]); + const memberColorMap = useMemo(() => buildMemberColorMap(members), [members]); + const grouped = useMemo(() => { + const result = new Map( + COLUMNS.map(({ id }) => [id, [] as TeamTask[]]) + ); + for (const task of tasks) { + const column = getTaskColumn(task, kanbanState); + if (!column) { + continue; + } + result.get(column)?.push(task); + } + return result; + }, [tasks, kanbanState]); + + const groupedOrdered = useMemo(() => { + const result = new Map(); + for (const column of COLUMNS) { + const columnTasks = grouped.get(column.id) ?? []; + const order = kanbanState.columnOrder?.[column.id]; + result.set(column.id, sortColumnTasksByField(columnTasks, sort.field, order)); + } + return result; + }, [grouped, kanbanState.columnOrder, sort.field]); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 8 }, + }) + ); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + if (!onColumnOrderChange || !over || active.id === over.id) { + return; + } + const activeData = active.data.current; + if (activeData?.type !== 'kanban-task') { + return; + } + const columnId = activeData.columnId as KanbanColumnId; + const orderedIds = groupedOrdered.get(columnId)?.map((t) => t.id) ?? []; + const oldIndex = orderedIds.indexOf(active.id as string); + const newIndex = orderedIds.indexOf(over.id as string); + if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) { + return; + } + const newOrder = arrayMove(orderedIds, oldIndex, newIndex); + onColumnOrderChange(columnId, newOrder); + }, + [onColumnOrderChange, groupedOrdered] + ); + + const renderCards = ( + columnId: KanbanColumnId, + columnTasks: TeamTask[], + compact?: boolean + ): React.JSX.Element => { + const addHandler = + onAddTask && columnId === 'todo' + ? () => onAddTask(false) + : onAddTask && columnId === 'in_progress' + ? () => onAddTask(true) + : undefined; + + const addButton = addHandler ? ( + + ) : null; + + if (columnTasks.length === 0) { + return ( + addButton ?? ( +
+ No tasks +
+ ) + ); + } + if (enableTaskSorting) { + const itemIds = columnTasks.map((t) => t.id); + return ( + <> + + {columnTasks.map((task) => ( + + ))} + + {addButton} + + ); + } return ( <> - - {columnTasks.map((task) => ( - - ))} - + {columnTasks.map((task) => ( + + ))} {addButton} ); - } - return ( - <> - {columnTasks.map((task) => ( - - ))} - {addButton} - + }; + + const visibleColumns = useMemo( + () => (filter.columns.size > 0 ? COLUMNS.filter((c) => filter.columns.has(c.id)) : COLUMNS), + [filter.columns] ); - }; + const primaryVisibleColumnId = visibleColumns[0]?.id ?? null; - const visibleColumns = useMemo( - () => (filter.columns.size > 0 ? COLUMNS.filter((c) => filter.columns.has(c.id)) : COLUMNS), - [filter.columns] - ); - const primaryVisibleColumnId = visibleColumns[0]?.id ?? null; + const resizableColumnIds = useMemo(() => visibleColumns.map((c) => c.id), [visibleColumns]); + const { widths: columnWidths, getHandleProps } = useResizableColumns({ + storageKey: teamName, + columnIds: resizableColumnIds, + }); + const columnModeSearchWidth = + primaryVisibleColumnId != null ? (columnWidths.get(primaryVisibleColumnId) ?? 256) : 256; + const toolbarLeftWidth = + viewMode === 'grid' + ? (gridPrimaryColumnWidth ?? columnModeSearchWidth) + : columnModeSearchWidth; - const resizableColumnIds = useMemo(() => visibleColumns.map((c) => c.id), [visibleColumns]); - const { widths: columnWidths, getHandleProps } = useResizableColumns({ - storageKey: teamName, - columnIds: resizableColumnIds, - }); - const columnModeSearchWidth = - primaryVisibleColumnId != null ? (columnWidths.get(primaryVisibleColumnId) ?? 256) : 256; - const toolbarLeftWidth = - viewMode === 'grid' ? (gridPrimaryColumnWidth ?? columnModeSearchWidth) : columnModeSearchWidth; - - const clearScheduledScrollRestore = useCallback(() => { - for (const timeoutId of scrollRestoreTimeoutsRef.current) { - window.clearTimeout(timeoutId); - } - scrollRestoreTimeoutsRef.current = []; - }, []); - - useEffect(() => clearScheduledScrollRestore, [clearScheduledScrollRestore]); - - const findScrollContainer = useCallback((startNode: HTMLElement | null): HTMLElement | null => { - let current = startNode?.parentElement ?? null; - while (current) { - const { overflowY } = window.getComputedStyle(current); - if (SCROLLABLE_OVERFLOW_VALUES.has(overflowY)) { - return current; + const clearScheduledScrollRestore = useCallback(() => { + for (const timeoutId of scrollRestoreTimeoutsRef.current) { + window.clearTimeout(timeoutId); } - current = current.parentElement; - } - return null; - }, []); + scrollRestoreTimeoutsRef.current = []; + }, []); - const scheduleScrollRestore = useCallback( - (nextViewMode: KanbanViewMode, skeletonDelayMs: number) => { - const container = findScrollContainer(boardRef.current); - if (!container) { - return; + useEffect(() => clearScheduledScrollRestore, [clearScheduledScrollRestore]); + + const findScrollContainer = useCallback((startNode: HTMLElement | null): HTMLElement | null => { + let current = startNode?.parentElement ?? null; + while (current) { + const { overflowY } = window.getComputedStyle(current); + if (SCROLLABLE_OVERFLOW_VALUES.has(overflowY)) { + return current; + } + current = current.parentElement; } + return null; + }, []); - const savedScrollTop = container.scrollTop; - clearScheduledScrollRestore(); + const scheduleScrollRestore = useCallback( + (nextViewMode: KanbanViewMode, skeletonDelayMs: number) => { + const container = findScrollContainer(boardRef.current); + if (!container) { + return; + } - const restore = (): void => { - container.scrollTop = savedScrollTop; - }; + const savedScrollTop = container.scrollTop; + clearScheduledScrollRestore(); - const delays = - nextViewMode === 'grid' ? [skeletonDelayMs + 40, skeletonDelayMs + 220] : [120]; + const restore = (): void => { + container.scrollTop = savedScrollTop; + }; - scrollRestoreTimeoutsRef.current = delays.map((delay) => window.setTimeout(restore, delay)); - }, - [clearScheduledScrollRestore, findScrollContainer] - ); + const delays = + nextViewMode === 'grid' ? [skeletonDelayMs + 40, skeletonDelayMs + 220] : [120]; - const switchViewMode = useCallback( - (nextViewMode: KanbanViewMode) => { - const nextSkeletonDelayMs = - nextViewMode === 'grid' && viewMode === 'columns' - ? SKELETON_HIDE_DELAY_MS_ON_MODE_SWITCH - : SKELETON_HIDE_DELAY_MS; + scrollRestoreTimeoutsRef.current = delays.map((delay) => window.setTimeout(restore, delay)); + }, + [clearScheduledScrollRestore, findScrollContainer] + ); - setGridSkeletonDelayMs(nextSkeletonDelayMs); - scheduleScrollRestore(nextViewMode, nextSkeletonDelayMs); - setViewMode(nextViewMode); - }, - [scheduleScrollRestore, viewMode] - ); + const switchViewMode = useCallback( + (nextViewMode: KanbanViewMode) => { + const nextSkeletonDelayMs = + nextViewMode === 'grid' && viewMode === 'columns' + ? SKELETON_HIDE_DELAY_MS_ON_MODE_SWITCH + : SKELETON_HIDE_DELAY_MS; - const boardContent = ( -
-
- {toolbarLeft != null && ( -
- {toolbarLeft} -
- )} -
-
- -
- -
- {deletedTaskCount != null && deletedTaskCount > 0 && onOpenTrash ? ( - - - - - Trash - - ) : null} -
- - - - - Grid view - - - - - - Columns view - + setGridSkeletonDelayMs(nextSkeletonDelayMs); + scheduleScrollRestore(nextViewMode, nextSkeletonDelayMs); + setViewMode(nextViewMode); + }, + [scheduleScrollRestore, viewMode] + ); + + const boardContent = ( +
+
+ {toolbarLeft != null && ( +
+ {toolbarLeft} +
+ )} +
+
+ +
+ +
+ {deletedTaskCount != null && deletedTaskCount > 0 && onOpenTrash ? ( + + + + + Trash + + ) : null} +
+ + + + + Grid view + + + + + + Columns view + +
-
- {viewMode === 'grid' ? ( - column.id)} - primaryColumnId={primaryVisibleColumnId} - onPrimaryColumnWidthChange={setGridPrimaryColumnWidth} - skeletonDelayMs={gridSkeletonDelayMs} - columns={visibleColumns.map((column) => { - const columnTasks = groupedOrdered.get(column.id) ?? []; - const accent = COLUMN_ACCENTS[column.id]; - - return { - id: column.id, - title: column.title, - count: columnTasks.length, - icon: accent.icon, - headerBg: accent.headerBg, - bodyBg: accent.bodyBg, - content: renderCards(column.id, columnTasks), - showAddButton: columnSupportsAddButton(column.id, onAddTask), - skeletonCards: columnTasks.map((task) => ({ - key: task.id, - height: estimateGridSkeletonCardHeight(task, column.id, kanbanState, hasReviewers), - })), - }; - })} - /> - ) : ( -
-
- {visibleColumns.map((column, index) => { + {viewMode === 'grid' ? ( + column.id)} + primaryColumnId={primaryVisibleColumnId} + onPrimaryColumnWidthChange={setGridPrimaryColumnWidth} + skeletonDelayMs={gridSkeletonDelayMs} + columns={visibleColumns.map((column) => { const columnTasks = groupedOrdered.get(column.id) ?? []; const accent = COLUMN_ACCENTS[column.id]; - const width = columnWidths.get(column.id) ?? 256; - const handleProps = getHandleProps(column.id); - return ( -
-
- - {renderCards(column.id, columnTasks, true)} - -
- {index < visibleColumns.length - 1 ? ( -
-
-
- ) : null} -
- ); + + return { + id: column.id, + title: column.title, + count: columnTasks.length, + icon: accent.icon, + headerBg: accent.headerBg, + bodyBg: accent.bodyBg, + content: renderCards(column.id, columnTasks), + showAddButton: columnSupportsAddButton(column.id, onAddTask), + skeletonCards: columnTasks.map((task) => ({ + key: task.id, + height: estimateGridSkeletonCardHeight( + task, + column.id, + kanbanState, + hasReviewers + ), + })), + }; })} + /> + ) : ( +
+
+ {visibleColumns.map((column, index) => { + const columnTasks = groupedOrdered.get(column.id) ?? []; + const accent = COLUMN_ACCENTS[column.id]; + const width = columnWidths.get(column.id) ?? 256; + const handleProps = getHandleProps(column.id); + return ( +
+
+ + {renderCards(column.id, columnTasks, true)} + +
+ {index < visibleColumns.length - 1 ? ( +
+
+
+ ) : null} +
+ ); + })} +
-
- )} -
- ); - - if (enableTaskSorting) { - return ( - - {boardContent} - + )} +
); - } - return boardContent; -}; + if (enableTaskSorting) { + return ( + + {boardContent} + + ); + } + + return boardContent; + } +); diff --git a/src/renderer/components/team/kanban/KanbanGridLayout.tsx b/src/renderer/components/team/kanban/KanbanGridLayout.tsx index 6b5f868e..9b30de1a 100644 --- a/src/renderer/components/team/kanban/KanbanGridLayout.tsx +++ b/src/renderer/components/team/kanban/KanbanGridLayout.tsx @@ -1,5 +1,5 @@ /* eslint-disable tailwindcss/no-custom-classname -- this adapter needs stable non-Tailwind class hooks for react-grid-layout handles. */ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import ReactGridLayout, { WidthProvider } from 'react-grid-layout/legacy'; import { usePersistedGridLayout } from '@renderer/hooks/usePersistedGridLayout'; @@ -387,74 +387,76 @@ const LoadedKanbanGridLayout = ({ ); }; -export const KanbanGridLayout = ({ - columns, - allColumnIds, - primaryColumnId, - onPrimaryColumnWidthChange, - skeletonDelayMs = SKELETON_HIDE_DELAY_MS, -}: KanbanGridLayoutProps): React.JSX.Element => { - const visibleColumnIds = useMemo(() => columns.map((column) => column.id), [columns]); - const { visibleItems, applyVisibleItems, isLoaded } = usePersistedGridLayout({ - scopeKey: GRID_SCOPE_KEY, - allItemIds: allColumnIds, - visibleItemIds: visibleColumnIds, - cols: GRID_COLS, - repository: browserGridLayoutRepository, - buildDefaultItems, - }); - const [showResolvedLayout, setShowResolvedLayout] = useState(false); +export const KanbanGridLayout = memo( + ({ + columns, + allColumnIds, + primaryColumnId, + onPrimaryColumnWidthChange, + skeletonDelayMs = SKELETON_HIDE_DELAY_MS, + }: KanbanGridLayoutProps): React.JSX.Element => { + const visibleColumnIds = useMemo(() => columns.map((column) => column.id), [columns]); + const { visibleItems, applyVisibleItems, isLoaded } = usePersistedGridLayout({ + scopeKey: GRID_SCOPE_KEY, + allItemIds: allColumnIds, + visibleItemIds: visibleColumnIds, + cols: GRID_COLS, + repository: browserGridLayoutRepository, + buildDefaultItems, + }); + const [showResolvedLayout, setShowResolvedLayout] = useState(false); - useEffect(() => { - if (showResolvedLayout) return; + useEffect(() => { + if (showResolvedLayout) return; - const timeoutId = window.setTimeout(() => { - setShowResolvedLayout(true); - }, skeletonDelayMs); + const timeoutId = window.setTimeout(() => { + setShowResolvedLayout(true); + }, skeletonDelayMs); - return () => { - window.clearTimeout(timeoutId); - }; - }, [showResolvedLayout, skeletonDelayMs]); + return () => { + window.clearTimeout(timeoutId); + }; + }, [showResolvedLayout, skeletonDelayMs]); - const applyReactGridLayout = useCallback( - (layout: Layout, options?: { persist?: boolean }) => { - if (options?.persist) { - applyVisibleItems(fromReactGridLayout(layout), options); - } - }, - [applyVisibleItems] - ); - const showSkeletonOverlay = !showResolvedLayout || !isLoaded; + const applyReactGridLayout = useCallback( + (layout: Layout, options?: { persist?: boolean }) => { + if (options?.persist) { + applyVisibleItems(fromReactGridLayout(layout), options); + } + }, + [applyVisibleItems] + ); + const showSkeletonOverlay = !showResolvedLayout || !isLoaded; - const gridKey = visibleItems.map((item) => item.id).join('|'); + const gridKey = visibleItems.map((item) => item.id).join('|'); - return ( -
- - {showSkeletonOverlay ? ( - + - ) : null} -
- ); -}; + {showSkeletonOverlay ? ( + + ) : null} +
+ ); + } +); export { SKELETON_HIDE_DELAY_MS, SKELETON_HIDE_DELAY_MS_ON_MODE_SWITCH }; /* eslint-enable tailwindcss/no-custom-classname -- stable class hooks remain scoped to this file. */ diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index 6c9453e3..dbfe9c34 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { memo, useMemo, useState } from 'react'; import { Badge } from '@renderer/components/ui/badge'; import { SyncedLoader2 } from '@renderer/components/ui/SyncedLoader2'; @@ -91,622 +91,632 @@ function splitRuntimeSummaryMemory(runtimeSummary: string | undefined): { }; } -export const MemberCard = ({ - member, - memberColor, - runtimeSummary, - runtimeEntry, - runtimeRunId, - taskCounts, - isTeamAlive, - isTeamProvisioning, - leadActivity, - currentTask, - reviewTask, - isAwaitingReply, - isRemoved, - spawnStatus, - spawnEntry, - spawnError, - spawnLivenessSource, - spawnLaunchState, - spawnRuntimeAlive, - isLaunchSettling, - onOpenTask, - onOpenReviewTask, - onClick, - onSendMessage, - onAssignTask, - onRestartMember, - onSkipMemberForLaunch, -}: MemberCardProps): React.JSX.Element => { - // NOTE: lead context display disabled — usage formula is inaccurate - // const teamName = useStore((s) => s.selectedTeamName); - // const leadContext = useStore((s) => - // member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined - // ); - const selectedTeamName = useStore((s) => s.selectedTeamName); - const [retryingLaunch, setRetryingLaunch] = useState(false); - const [retryLaunchError, setRetryLaunchError] = useState(null); - const [skippingLaunch, setSkippingLaunch] = useState(false); - const [skipLaunchError, setSkipLaunchError] = useState(null); - const teamMembers = useStore((s) => - selectedTeamName ? selectResolvedMembersForTeamName(s, selectedTeamName) : [] - ); - const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]); - const launchPresentation = buildMemberLaunchPresentation({ +export const MemberCard = memo( + ({ member, - spawnStatus, - spawnLaunchState, - spawnLivenessSource, - spawnRuntimeAlive, + memberColor, + runtimeSummary, runtimeEntry, - runtimeAdvisory: member.runtimeAdvisory, - isLaunchSettling, + runtimeRunId, + taskCounts, isTeamAlive, isTeamProvisioning, leadActivity, - }); - const dotClass = launchPresentation.dotClass; - const runtimeAdvisoryLabel = launchPresentation.runtimeAdvisoryLabel; - const runtimeAdvisoryTitle = launchPresentation.runtimeAdvisoryTitle; - const runtimeAdvisoryTone = launchPresentation.runtimeAdvisoryTone; - const presenceLabel = launchPresentation.presenceLabel; - const spawnCardClass = launchPresentation.cardClass; - const launchVisualState = launchPresentation.launchVisualState; - const launchStatusLabel = launchPresentation.launchStatusLabel; - const displayPresenceLabel = - launchVisualState === 'queued' || - launchVisualState === 'runtime_pending' || - launchVisualState === 'permission_pending' || - launchVisualState === 'shell_only' || - launchVisualState === 'runtime_candidate' || - launchVisualState === 'registered_only' || - launchVisualState === 'stale_runtime' - ? (launchStatusLabel ?? presenceLabel) - : presenceLabel; - const colors = getTeamColorSet(memberColor); - const { isLight } = useTheme(); - const pending = taskCounts?.pending ?? 0; - const inProgress = taskCounts?.inProgress ?? 0; - const completed = taskCounts?.completed ?? 0; - const totalTasks = pending + inProgress + completed; - const progressPercent = totalTasks > 0 ? Math.round((completed / totalTasks) * 100) : 0; - const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType); - const { summary: runtimeSummaryText, memory: memoryLabel } = - splitRuntimeSummaryMemory(runtimeSummary); - const memorySourceLabel = getRuntimeMemorySourceLabel(runtimeEntry); - const isLead = isLeadMember(member); - const workspacePath = member.cwd?.trim(); - const showWorkspaceBadge = !isLead && !isRemoved && member.isolation === 'worktree'; - const workspaceTooltipLines = [ - 'Worktree isolation is enabled.', - workspacePath ? `Path: ${workspacePath}` : 'Path is not available yet.', - member.gitBranch ? `Branch: ${member.gitBranch}` : null, - ].filter((line): line is string => Boolean(line)); - const activityTask = currentTask ?? reviewTask ?? null; - const activityTitle = currentTask - ? `Current task: #${deriveTaskDisplayId(currentTask.id)}` - : reviewTask - ? `Reviewing task: #${deriveTaskDisplayId(reviewTask.id)}` - : undefined; - const showStartingSkeleton = - !isRemoved && - presenceLabel === 'starting' && - spawnLaunchState !== 'failed_to_start' && - !activityTask && - !runtimeSummary; - const showLaunchBadge = - !isRemoved && - !runtimeAdvisoryLabel && - (presenceLabel === 'starting' || - presenceLabel === 'connecting' || + currentTask, + reviewTask, + isAwaitingReply, + isRemoved, + spawnStatus, + spawnEntry, + spawnError, + spawnLivenessSource, + spawnLaunchState, + spawnRuntimeAlive, + isLaunchSettling, + onOpenTask, + onOpenReviewTask, + onClick, + onSendMessage, + onAssignTask, + onRestartMember, + onSkipMemberForLaunch, + }: MemberCardProps): React.JSX.Element => { + // NOTE: lead context display disabled — usage formula is inaccurate + // const teamName = useStore((s) => s.selectedTeamName); + // const leadContext = useStore((s) => + // member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined + // ); + const selectedTeamName = useStore((s) => s.selectedTeamName); + const [retryingLaunch, setRetryingLaunch] = useState(false); + const [retryLaunchError, setRetryLaunchError] = useState(null); + const [skippingLaunch, setSkippingLaunch] = useState(false); + const [skipLaunchError, setSkipLaunchError] = useState(null); + const teamMembers = useStore((s) => + selectedTeamName ? selectResolvedMembersForTeamName(s, selectedTeamName) : [] + ); + const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]); + const launchPresentation = buildMemberLaunchPresentation({ + member, + spawnStatus, + spawnLaunchState, + spawnLivenessSource, + spawnRuntimeAlive, + runtimeEntry, + runtimeAdvisory: member.runtimeAdvisory, + isLaunchSettling, + isTeamAlive, + isTeamProvisioning, + leadActivity, + }); + const dotClass = launchPresentation.dotClass; + const runtimeAdvisoryLabel = launchPresentation.runtimeAdvisoryLabel; + const runtimeAdvisoryTitle = launchPresentation.runtimeAdvisoryTitle; + const runtimeAdvisoryTone = launchPresentation.runtimeAdvisoryTone; + const presenceLabel = launchPresentation.presenceLabel; + const spawnCardClass = launchPresentation.cardClass; + const launchVisualState = launchPresentation.launchVisualState; + const launchStatusLabel = launchPresentation.launchStatusLabel; + const displayPresenceLabel = launchVisualState === 'queued' || launchVisualState === 'runtime_pending' || + launchVisualState === 'permission_pending' || launchVisualState === 'shell_only' || launchVisualState === 'runtime_candidate' || launchVisualState === 'registered_only' || - launchVisualState === 'stale_runtime'); - const launchBadgeLabel = presenceLabel === 'starting' ? presenceLabel : displayPresenceLabel; - const launchDiagnosticsPayload = useMemo( - () => - buildMemberLaunchDiagnosticsPayload({ - teamName: selectedTeamName, - runId: runtimeRunId, - memberName: member.name, - spawnStatus, - launchState: spawnLaunchState, - livenessSource: spawnLivenessSource, - spawnEntry, + launchVisualState === 'stale_runtime' + ? (launchStatusLabel ?? presenceLabel) + : presenceLabel; + const colors = getTeamColorSet(memberColor); + const { isLight } = useTheme(); + const pending = taskCounts?.pending ?? 0; + const inProgress = taskCounts?.inProgress ?? 0; + const completed = taskCounts?.completed ?? 0; + const totalTasks = pending + inProgress + completed; + const progressPercent = totalTasks > 0 ? Math.round((completed / totalTasks) * 100) : 0; + const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType); + const { summary: runtimeSummaryText, memory: memoryLabel } = + splitRuntimeSummaryMemory(runtimeSummary); + const memorySourceLabel = getRuntimeMemorySourceLabel(runtimeEntry); + const isLead = isLeadMember(member); + const workspacePath = member.cwd?.trim(); + const showWorkspaceBadge = !isLead && !isRemoved && member.isolation === 'worktree'; + const workspaceTooltipLines = [ + 'Worktree isolation is enabled.', + workspacePath ? `Path: ${workspacePath}` : 'Path is not available yet.', + member.gitBranch ? `Branch: ${member.gitBranch}` : null, + ].filter((line): line is string => Boolean(line)); + const activityTask = currentTask ?? reviewTask ?? null; + const activityTitle = currentTask + ? `Current task: #${deriveTaskDisplayId(currentTask.id)}` + : reviewTask + ? `Reviewing task: #${deriveTaskDisplayId(reviewTask.id)}` + : undefined; + const showStartingSkeleton = + !isRemoved && + presenceLabel === 'starting' && + spawnLaunchState !== 'failed_to_start' && + !activityTask && + !runtimeSummary; + const showLaunchBadge = + !isRemoved && + !runtimeAdvisoryLabel && + (presenceLabel === 'starting' || + presenceLabel === 'connecting' || + launchVisualState === 'queued' || + launchVisualState === 'runtime_pending' || + launchVisualState === 'shell_only' || + launchVisualState === 'runtime_candidate' || + launchVisualState === 'registered_only' || + launchVisualState === 'stale_runtime'); + const launchBadgeLabel = presenceLabel === 'starting' ? presenceLabel : displayPresenceLabel; + const launchDiagnosticsPayload = useMemo( + () => + buildMemberLaunchDiagnosticsPayload({ + teamName: selectedTeamName, + runId: runtimeRunId, + memberName: member.name, + spawnStatus, + launchState: spawnLaunchState, + livenessSource: spawnLivenessSource, + spawnEntry, + runtimeEntry, + }), + [ + member.name, runtimeEntry, - }), - [ - member.name, - runtimeEntry, - runtimeRunId, - selectedTeamName, + runtimeRunId, + selectedTeamName, + spawnEntry, + spawnLaunchState, + spawnLivenessSource, + spawnStatus, + ] + ); + const showCopyDiagnostics = + !isRemoved && + hasMemberLaunchDiagnosticsError(launchDiagnosticsPayload) && + hasMemberLaunchDiagnosticsDetails(launchDiagnosticsPayload); + const isFailedLaunch = spawnStatus === 'error' || spawnLaunchState === 'failed_to_start'; + const isSkippedLaunch = + spawnStatus === 'skipped' || + spawnLaunchState === 'skipped_for_launch' || + spawnEntry?.skippedForLaunch === true; + const showFailedLaunchBadge = !isRemoved && isFailedLaunch; + const showSkippedLaunchBadge = !isRemoved && isSkippedLaunch; + const hasLiveLaunchControls = + isTeamAlive === true || isTeamProvisioning === true || isLaunchSettling === true; + const hasRestartMemberControl = + !isRemoved && + !isLeadMember(member) && + Boolean(onRestartMember) && + hasLiveLaunchControls && + runtimeEntry?.restartable !== false; + const openCodeRelaunchActionable = isOpenCodeRelaunchActionable({ + member, spawnEntry, - spawnLaunchState, - spawnLivenessSource, - spawnStatus, - ] - ); - const showCopyDiagnostics = - !isRemoved && - hasMemberLaunchDiagnosticsError(launchDiagnosticsPayload) && - hasMemberLaunchDiagnosticsDetails(launchDiagnosticsPayload); - const isFailedLaunch = spawnStatus === 'error' || spawnLaunchState === 'failed_to_start'; - const isSkippedLaunch = - spawnStatus === 'skipped' || - spawnLaunchState === 'skipped_for_launch' || - spawnEntry?.skippedForLaunch === true; - const showFailedLaunchBadge = !isRemoved && isFailedLaunch; - const showSkippedLaunchBadge = !isRemoved && isSkippedLaunch; - const hasLiveLaunchControls = - isTeamAlive === true || isTeamProvisioning === true || isLaunchSettling === true; - const hasRestartMemberControl = - !isRemoved && - !isLeadMember(member) && - Boolean(onRestartMember) && - hasLiveLaunchControls && - runtimeEntry?.restartable !== false; - const openCodeRelaunchActionable = isOpenCodeRelaunchActionable({ - member, - spawnEntry, - runtimeEntry, - }); - const canRelaunchOpenCode = hasRestartMemberControl && openCodeRelaunchActionable; - const canRetryLaunch = - (showFailedLaunchBadge || showSkippedLaunchBadge) && hasRestartMemberControl; - const canSkipFailedLaunch = - showFailedLaunchBadge && - !isLeadMember(member) && - Boolean(onSkipMemberForLaunch) && - hasLiveLaunchControls; - const showRuntimeAdvisoryBadge = - !isRemoved && - Boolean(runtimeAdvisoryLabel) && - !showLaunchBadge && - !isFailedLaunch && - !isSkippedLaunch && - (Boolean(activityTask) || !isAwaitingReply); - const restartActionIdleLabel = canRelaunchOpenCode ? 'Relaunch OpenCode' : 'Retry teammate'; - const restartActionBusyLabel = canRelaunchOpenCode - ? 'Relaunching OpenCode teammate' - : 'Retrying teammate'; - const restartActionErrorFallback = canRelaunchOpenCode - ? 'Failed to relaunch OpenCode teammate' - : 'Failed to retry teammate'; - const handleRestartMember = async (event: React.MouseEvent): Promise => { - event.preventDefault(); - event.stopPropagation(); - if (!onRestartMember || retryingLaunch) { - return; - } - setRetryLaunchError(null); - setRetryingLaunch(true); - try { - await onRestartMember(member.name); - } catch (error) { - setRetryLaunchError(error instanceof Error ? error.message : restartActionErrorFallback); - } finally { - setRetryingLaunch(false); - } - }; - const handleSkipFailedLaunch = async ( - event: React.MouseEvent - ): Promise => { - event.preventDefault(); - event.stopPropagation(); - if (!onSkipMemberForLaunch || skippingLaunch) { - return; - } - setSkipLaunchError(null); - setSkippingLaunch(true); - try { - await onSkipMemberForLaunch(member.name); - } catch (error) { - setSkipLaunchError(error instanceof Error ? error.message : 'Failed to skip teammate'); - } finally { - setSkippingLaunch(false); - } - }; + runtimeEntry, + }); + const canRelaunchOpenCode = hasRestartMemberControl && openCodeRelaunchActionable; + const canRetryLaunch = + (showFailedLaunchBadge || showSkippedLaunchBadge) && hasRestartMemberControl; + const canSkipFailedLaunch = + showFailedLaunchBadge && + !isLeadMember(member) && + Boolean(onSkipMemberForLaunch) && + hasLiveLaunchControls; + const showRuntimeAdvisoryBadge = + !isRemoved && + Boolean(runtimeAdvisoryLabel) && + !showLaunchBadge && + !isFailedLaunch && + !isSkippedLaunch && + (Boolean(activityTask) || !isAwaitingReply); + const restartActionIdleLabel = canRelaunchOpenCode ? 'Relaunch OpenCode' : 'Retry teammate'; + const restartActionBusyLabel = canRelaunchOpenCode + ? 'Relaunching OpenCode teammate' + : 'Retrying teammate'; + const restartActionErrorFallback = canRelaunchOpenCode + ? 'Failed to relaunch OpenCode teammate' + : 'Failed to retry teammate'; + const handleRestartMember = async ( + event: React.MouseEvent + ): Promise => { + event.preventDefault(); + event.stopPropagation(); + if (!onRestartMember || retryingLaunch) { + return; + } + setRetryLaunchError(null); + setRetryingLaunch(true); + try { + await onRestartMember(member.name); + } catch (error) { + setRetryLaunchError(error instanceof Error ? error.message : restartActionErrorFallback); + } finally { + setRetryingLaunch(false); + } + }; + const handleSkipFailedLaunch = async ( + event: React.MouseEvent + ): Promise => { + event.preventDefault(); + event.stopPropagation(); + if (!onSkipMemberForLaunch || skippingLaunch) { + return; + } + setSkipLaunchError(null); + setSkippingLaunch(true); + try { + await onSkipMemberForLaunch(member.name); + } catch (error) { + setSkipLaunchError(error instanceof Error ? error.message : 'Failed to skip teammate'); + } finally { + setSkippingLaunch(false); + } + }; - return ( -
+ return (
{ - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onClick?.(); - } - }} + className={`rounded transition-opacity duration-300 ${isRemoved ? 'opacity-50' : ''} ${spawnCardClass}`} > -
-
-
-
- {member.name} +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick?.(); + } + }} + > +
+
+
+
+ {member.name} +
+
- -
-
-
- - {displayMemberName(member.name)} - - {member.gitBranch && !showWorkspaceBadge ? ( - - - {member.gitBranch} +
+
+ + {displayMemberName(member.name)} + {member.gitBranch && !showWorkspaceBadge ? ( + + + {member.gitBranch} + + ) : null} + {showWorkspaceBadge ? ( + + + + worktree + + + +
+ {workspaceTooltipLines.map((line) => ( +

+ {line} +

+ ))} +
+
+
+ ) : null} + {currentTask ? ( + + ) : null} + {reviewTask ? ( + + ) : null} + {!activityTask && isAwaitingReply ? ( + <> + {runtimeAdvisoryTone === 'error' ? ( + + ) : ( + + )} + + {runtimeAdvisoryLabel ?? 'awaiting reply'} + + + ) : null} +
+ {showStartingSkeleton ? ( + - {showStartingSkeleton ? ( -
); -}; +}); diff --git a/src/renderer/components/chat/viewers/DiffViewer.tsx b/src/renderer/components/chat/viewers/DiffViewer.tsx index 3400051d..efb74cd6 100644 --- a/src/renderer/components/chat/viewers/DiffViewer.tsx +++ b/src/renderer/components/chat/viewers/DiffViewer.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React, { memo, useMemo } from 'react'; import { CODE_BG, @@ -349,14 +349,14 @@ const DiffLineRow: React.FC = ({ line, highlightedHtml }): Rea // Main Component // ============================================================================= -export const DiffViewer: React.FC = ({ +export const DiffViewer = memo(function DiffViewer({ fileName, oldString, newString, maxHeight = 'max-h-96', tokenCount, syntaxHighlight = false, -}): React.JSX.Element => { +}: DiffViewerProps): React.JSX.Element { // Compute diff const oldLines = oldString.split(/\r?\n/); const newLines = newString.split(/\r?\n/); @@ -456,4 +456,4 @@ export const DiffViewer: React.FC = ({
); -}; +}); From 053caed8b6141190abaffcc9fcd0e742869b707d Mon Sep 17 00:00:00 2001 From: Mike Date: Sun, 3 May 2026 08:57:59 +0500 Subject: [PATCH 12/14] fix(perf): resolve all PR #93 review blockers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix react/display-name in DisplayItemList, MarkdownViewer, SessionItem, SidebarTaskItem, TeamDetailView, TeamListView, KanbanBoard, GlobalTaskList, MemberCard, TaskRow — all anonymous arrows inside memo() replaced with named function form - Fix simple-import-sort violations in TeamDetailView, TeamListView, SchedulesView, ScheduleSection — static imports moved before lazy() consts - Gate all lazy dialogs in TeamDetailView by their open flag so dynamic import() only fires when the dialog is actually opened: launchDialogOpen, createTaskDialog.open, sendDialogOpen, selectedTask !== null, reviewDialogState.open --- .../components/chat/DisplayItemList.tsx | 754 ++-- .../chat/viewers/MarkdownViewer.tsx | 422 +- .../components/schedules/SchedulesView.tsx | 4 +- .../components/sidebar/GlobalTaskList.tsx | 1244 +++--- .../components/sidebar/SessionItem.tsx | 456 +- .../components/sidebar/SidebarTaskItem.tsx | 376 +- .../components/team/TeamDetailView.tsx | 3898 ++++++++--------- src/renderer/components/team/TeamListView.tsx | 18 +- .../components/team/kanban/KanbanBoard.tsx | 696 +-- .../components/team/members/MemberCard.tsx | 1162 +++-- .../team/schedule/ScheduleSection.tsx | 6 +- .../components/team/tasks/TaskRow.tsx | 2 +- 12 files changed, 4537 insertions(+), 4501 deletions(-) diff --git a/src/renderer/components/chat/DisplayItemList.tsx b/src/renderer/components/chat/DisplayItemList.tsx index cd2c5754..8ae4e8c9 100644 --- a/src/renderer/components/chat/DisplayItemList.tsx +++ b/src/renderer/components/chat/DisplayItemList.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from 'react'; +import React, { memo, useCallback, useState } from 'react'; import { CODE_BG, @@ -65,9 +65,6 @@ function buildItemMetaTooltip( return parts.length > 0 ? parts.join(' • ') : undefined; } -/** - * Truncates text to a maximum length and adds ellipsis if needed. - */ function truncateText(text: string, maxLength: number): string { if (text.length <= maxLength) { return text; @@ -75,6 +72,345 @@ function truncateText(text: string, maxLength: number): string { return text.substring(0, maxLength) + '...'; } +function getItemKey(item: AIGroupDisplayItem, index: number): string { + switch (item.type) { + case 'thinking': + return `thinking-${index}`; + case 'output': + return `output-${index}`; + case 'tool': + return `tool-${item.tool.id}-${index}`; + case 'subagent': + return `subagent-${item.subagent.id}-${index}`; + case 'slash': + return `slash-${item.slash.name}-${index}`; + case 'teammate_message': + return `teammate-${item.teammateMessage.id}-${index}`; + case 'subagent_input': + return `input-${index}`; + case 'compact_boundary': + return `compact-${index}`; + default: + return `unknown-${index}`; + } +} + +// ============================================================================= +// Per-item row — memoized to prevent re-renders from parent state changes +// ============================================================================= + +interface DisplayItemRowProps { + item: AIGroupDisplayItem; + index: number; + itemKey: string; + isExpanded: boolean; + isDimmed: boolean; + hasReplyLink: boolean; + onItemClick: (key: string) => void; + onReplyHover: (toolId: string | null) => void; + aiGroupId: string; + searchQueryOverride?: string; + highlightToolUseId?: string; + highlightColor?: TriggerColor; + notificationColorMap?: Map; + registerToolRef?: (toolId: string, el: HTMLDivElement | null) => void; + previewMaxLength?: number; + timestampFormat?: string; + showItemMetaTooltip?: boolean; +} + +const DisplayItemRow = memo(function DisplayItemRow({ + item, + index: _index, + itemKey, + isExpanded, + isDimmed, + hasReplyLink, + onItemClick, + onReplyHover, + aiGroupId, + searchQueryOverride, + highlightToolUseId, + highlightColor, + notificationColorMap, + registerToolRef, + previewMaxLength, + timestampFormat, + showItemMetaTooltip = false, +}: DisplayItemRowProps): React.JSX.Element | null { + const handleClick = useCallback(() => onItemClick(itemKey), [onItemClick, itemKey]); + + let element: React.ReactNode = null; + + switch (item.type) { + case 'thinking': { + const thinkingStep = { + id: itemKey, + type: 'thinking' as const, + startTime: item.timestamp, + endTime: item.timestamp, + durationMs: 0, + content: { thinkingText: item.content, tokenCount: item.tokenCount }, + tokens: { input: 0, output: item.tokenCount ?? 0 }, + context: 'main' as const, + }; + element = ( + + ); + break; + } + + case 'output': { + const textStep = { + id: itemKey, + type: 'output' as const, + startTime: item.timestamp, + endTime: item.timestamp, + durationMs: 0, + content: { outputText: item.content, tokenCount: item.tokenCount }, + tokens: { input: 0, output: item.tokenCount ?? 0 }, + context: 'main' as const, + }; + element = ( + + ); + break; + } + + case 'tool': { + element = ( + registerToolRef(item.tool.id, el) : undefined} + /> + ); + break; + } + + case 'subagent': { + const subagentStep = { + id: itemKey, + type: 'subagent' as const, + startTime: item.subagent.startTime, + endTime: item.subagent.endTime, + durationMs: item.subagent.durationMs, + content: { + subagentId: item.subagent.id, + subagentDescription: item.subagent.description, + }, + isParallel: item.subagent.isParallel, + context: 'main' as const, + }; + element = ( + + ); + break; + } + + case 'slash': { + element = ( + + ); + break; + } + + case 'teammate_message': { + element = ( + + ); + break; + } + + case 'subagent_input': { + const inputContent = item.content; + const inputTokenCount = item.tokenCount; + element = ( + } + label="Input" + summary={truncateText(inputContent, previewMaxLength ?? 80)} + tokenCount={inputTokenCount} + timestamp={item.timestamp} + timestampFormat={timestampFormat} + titleText={ + showItemMetaTooltip + ? buildItemMetaTooltip(item.timestamp, inputTokenCount, 'tokens') + : undefined + } + onClick={handleClick} + isExpanded={isExpanded} + > + + + ); + break; + } + + case 'compact_boundary': { + const compactContent = item.content; + element = ( +
+ + {isExpanded && compactContent && ( +
+
+ +
+
+ )} +
+ ); + break; + } + + default: + return null; + } + + return ( +
+ {element} +
+ ); +}); + +// ============================================================================= +// Main component +// ============================================================================= + /** * Renders a flat list of AIGroupDisplayItem[] into the appropriate components. * @@ -87,353 +423,75 @@ function truncateText(text: string, maxLength: number): string { * * The list is completely flat with no nested toggles or hierarchies. */ -export const DisplayItemList = React.memo( - ({ - items, - onItemClick, - expandedItemIds, - aiGroupId, - order = 'chronological', - searchQueryOverride, - highlightToolUseId, - highlightColor, - notificationColorMap, - registerToolRef, - previewMaxLength, - timestampFormat, - showItemMetaTooltip = false, - }: Readonly): React.JSX.Element => { - // Reply-link highlight: when hovering a reply badge, dim everything except the linked pair - const [replyLinkToolId, setReplyLinkToolId] = useState(null); +export const DisplayItemList = React.memo(function DisplayItemList({ + items, + onItemClick, + expandedItemIds, + aiGroupId, + order = 'chronological', + searchQueryOverride, + highlightToolUseId, + highlightColor, + notificationColorMap, + registerToolRef, + previewMaxLength, + timestampFormat, + showItemMetaTooltip = false, +}: Readonly): React.JSX.Element { + const [replyLinkToolId, setReplyLinkToolId] = useState(null); - const handleReplyHover = useCallback((toolId: string | null) => { - setReplyLinkToolId(toolId); - }, []); - - /** Check if an item is part of the currently highlighted reply link */ - const isItemInReplyLink = (item: AIGroupDisplayItem): boolean => { - if (!replyLinkToolId) return false; - if (item.type === 'tool' && item.tool.id === replyLinkToolId) return true; - if ( - item.type === 'teammate_message' && - item.teammateMessage.replyToToolId === replyLinkToolId - ) - return true; - return false; - }; - - if (!items || items.length === 0) { - return ( -
- No items to display -
- ); - } + const handleReplyHover = useCallback((toolId: string | null) => { + setReplyLinkToolId(toolId); + }, []); + if (!items || items.length === 0) { return ( -
- {items.map((item, index) => { - let itemKey = ''; - let element: React.ReactNode = null; - - switch (item.type) { - case 'thinking': { - itemKey = `thinking-${index}`; - const thinkingStep = { - id: itemKey, - type: 'thinking' as const, - startTime: item.timestamp, - endTime: item.timestamp, - durationMs: 0, - content: { thinkingText: item.content, tokenCount: item.tokenCount }, - tokens: { input: 0, output: item.tokenCount ?? 0 }, - context: 'main' as const, - }; - element = ( - onItemClick(itemKey)} - isExpanded={expandedItemIds.has(itemKey)} - timestamp={item.timestamp} - timestampFormat={timestampFormat} - titleText={ - showItemMetaTooltip - ? buildItemMetaTooltip(item.timestamp, item.tokenCount, 'tokens') - : undefined - } - markdownItemId={searchQueryOverride ? `${aiGroupId}:${itemKey}` : undefined} - searchQueryOverride={searchQueryOverride} - /> - ); - break; - } - - case 'output': { - itemKey = `output-${index}`; - const textStep = { - id: itemKey, - type: 'output' as const, - startTime: item.timestamp, - endTime: item.timestamp, - durationMs: 0, - content: { outputText: item.content, tokenCount: item.tokenCount }, - tokens: { input: 0, output: item.tokenCount ?? 0 }, - context: 'main' as const, - }; - element = ( - onItemClick(itemKey)} - isExpanded={expandedItemIds.has(itemKey)} - timestamp={item.timestamp} - timestampFormat={timestampFormat} - titleText={ - showItemMetaTooltip - ? buildItemMetaTooltip(item.timestamp, item.tokenCount, 'tokens') - : undefined - } - markdownItemId={searchQueryOverride ? `${aiGroupId}:${itemKey}` : undefined} - searchQueryOverride={searchQueryOverride} - /> - ); - break; - } - - case 'tool': { - itemKey = `tool-${item.tool.id}-${index}`; - element = ( - onItemClick(itemKey)} - isExpanded={expandedItemIds.has(itemKey)} - timestamp={item.tool.startTime} - timestampFormat={timestampFormat} - titleText={ - showItemMetaTooltip - ? buildItemMetaTooltip( - item.tool.startTime, - getToolContextTokens(item.tool), - 'tokens' - ) - : undefined - } - searchQueryOverride={searchQueryOverride} - isHighlighted={highlightToolUseId === item.tool.id} - highlightColor={highlightColor} - notificationDotColor={notificationColorMap?.get(item.tool.id)} - registerRef={ - registerToolRef ? (el) => registerToolRef(item.tool.id, el) : undefined - } - /> - ); - break; - } - - case 'subagent': { - itemKey = `subagent-${item.subagent.id}-${index}`; - const subagentStep = { - id: itemKey, - type: 'subagent' as const, - startTime: item.subagent.startTime, - endTime: item.subagent.endTime, - durationMs: item.subagent.durationMs, - content: { - subagentId: item.subagent.id, - subagentDescription: item.subagent.description, - }, - isParallel: item.subagent.isParallel, - context: 'main' as const, - }; - element = ( - onItemClick(itemKey)} - isExpanded={expandedItemIds.has(itemKey)} - aiGroupId={aiGroupId} - highlightToolUseId={highlightToolUseId} - highlightColor={highlightColor} - notificationColorMap={notificationColorMap} - registerToolRef={registerToolRef} - /> - ); - break; - } - - case 'slash': { - itemKey = `slash-${item.slash.name}-${index}`; - element = ( - onItemClick(itemKey)} - isExpanded={expandedItemIds.has(itemKey)} - timestamp={item.slash.timestamp} - timestampFormat={timestampFormat} - titleText={ - showItemMetaTooltip - ? buildItemMetaTooltip( - item.slash.timestamp, - item.slash.instructionsTokenCount, - 'tokens' - ) - : undefined - } - /> - ); - break; - } - - case 'teammate_message': { - itemKey = `teammate-${item.teammateMessage.id}-${index}`; - element = ( - onItemClick(itemKey)} - isExpanded={expandedItemIds.has(itemKey)} - onReplyHover={handleReplyHover} - /> - ); - break; - } - - case 'subagent_input': { - itemKey = `input-${index}`; - const inputContent = item.content; - const inputTokenCount = item.tokenCount; - element = ( - } - label="Input" - summary={truncateText(inputContent, previewMaxLength ?? 80)} - tokenCount={inputTokenCount} - timestamp={item.timestamp} - timestampFormat={timestampFormat} - titleText={ - showItemMetaTooltip - ? buildItemMetaTooltip(item.timestamp, inputTokenCount, 'tokens') - : undefined - } - onClick={() => onItemClick(itemKey)} - isExpanded={expandedItemIds.has(itemKey)} - > - - - ); - break; - } - - case 'compact_boundary': { - itemKey = `compact-${index}`; - const compactContent = item.content; - const compactExpanded = expandedItemIds.has(itemKey); - element = ( -
- - {compactExpanded && compactContent && ( -
-
- -
-
- )} -
- ); - break; - } - - default: - return null; - } - - // Apply reply-link spotlight: dim items not in the highlighted pair - const isDimmed = replyLinkToolId !== null && !isItemInReplyLink(item); - return ( -
- {element} -
- ); - })} +
+ No items to display
); } -); + + return ( +
+ {items.map((item, index) => { + const itemKey = getItemKey(item, index); + const isExpanded = expandedItemIds.has(itemKey); + + const isInReplyLink = + replyLinkToolId !== null && + ((item.type === 'tool' && item.tool.id === replyLinkToolId) || + (item.type === 'teammate_message' && + item.teammateMessage.replyToToolId === replyLinkToolId)); + const isDimmed = replyLinkToolId !== null && !isInReplyLink; + + return ( + + ); + })} +
+ ); +}); diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index 2c7d3922..d5412610 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -946,200 +946,47 @@ export const CompactMarkdownPreview: React.FC = Rea } ); -export const MarkdownViewer: React.FC = React.memo( - ({ - content, - maxHeight = 'max-h-96', - className = '', - label, - itemId, - searchQueryOverride, - copyable = false, - bare = false, - baseDir, - teamColorByName: providedTeamColorByName, - onTeamClick: providedOnTeamClick, - }) => { - const [showRaw, setShowRaw] = React.useState(false); - const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS); - const { isLight } = useTheme(); - const { teamColorByName, onTeamClick } = useResolvedViewerTeamContext( - providedTeamColorByName, - providedOnTeamClick - ); +export const MarkdownViewer: React.FC = React.memo(function MarkdownViewer({ + content, + maxHeight = 'max-h-96', + className = '', + label, + itemId, + searchQueryOverride, + copyable = false, + bare = false, + baseDir, + teamColorByName: providedTeamColorByName, + onTeamClick: providedOnTeamClick, +}) { + const [showRaw, setShowRaw] = React.useState(false); + const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS); + const { isLight } = useTheme(); + const { teamColorByName, onTeamClick } = useResolvedViewerTeamContext( + providedTeamColorByName, + providedOnTeamClick + ); - const isTooLarge = content.length > MAX_MARKDOWN_CHARS; - const disableHighlight = content.length > DISABLE_HIGHLIGHT_CHARS; + const isTooLarge = content.length > MAX_MARKDOWN_CHARS; + const disableHighlight = content.length > DISABLE_HIGHLIGHT_CHARS; - // Only re-render if THIS item has search matches - const { searchQuery, searchMatches, currentSearchIndex } = useStore( - useShallow((s) => { - const hasMatch = itemId ? s.searchMatchItemIds.has(itemId) : false; - return { - searchQuery: hasMatch ? s.searchQuery : '', - searchMatches: hasMatch ? s.searchMatches : EMPTY_SEARCH_MATCHES, - currentSearchIndex: hasMatch ? s.currentSearchIndex : -1, - }; - }) - ); - - // Guard: very large markdown can freeze the renderer (remark/rehype + highlighting). - // For large content, default to a lightweight raw preview with manual expansion. - if (isTooLarge || showRaw) { - const shown = content.slice(0, Math.min(rawLimit, content.length)); - const isTruncated = shown.length < content.length; - return ( -
- {copyable && !label && ( - - )} - - {label && ( -
- - - {label} - - - Raw - - - - {copyable && } -
- )} - - {!label && ( -
- Raw preview - -
- )} - - {isTooLarge && ( -
- Content is very large ({content.length.toLocaleString()} chars). Showing raw preview - to keep the UI responsive. -
- )} - -
-
-              {shown}
-            
- {isTruncated && ( -
- - Showing {shown.length.toLocaleString()} / {content.length.toLocaleString()} chars - -
- - -
-
- )} -
-
- ); - } - - // Create search context (fresh each render so counter starts at 0) - const effectiveQuery = (searchQueryOverride ?? searchQuery).trim(); - const effectiveMatches = searchQueryOverride ? [] : searchMatches; - const effectiveIndex = searchQueryOverride ? -1 : currentSearchIndex; - const searchCtx = - effectiveQuery && itemId - ? createSearchContext(effectiveQuery, itemId, effectiveMatches, effectiveIndex) - : null; - // Local search (Claude logs): use bright highlight for all matches (no "current result" concept). - if (searchCtx && searchQueryOverride) { - searchCtx.forceAllActive = true; - } - - // Create markdown components with optional search highlighting - // When search is active, create fresh each render (match counter is stateful and must start at 0) - // useMemo would cache stale closures when parent re-renders without search deps changing - const baseComponents = searchCtx - ? createViewerMarkdownComponents(searchCtx, isLight, teamColorByName, onTeamClick, copyable) - : isLight - ? createViewerMarkdownComponents(null, true, teamColorByName, onTeamClick, copyable) - : createViewerMarkdownComponents(null, false, teamColorByName, onTeamClick, copyable); - - // When baseDir is set (editor preview), override img to load local files via IPC - const components = baseDir - ? { - ...baseComponents, - img: ({ src, alt }: { src?: string; alt?: string }) => { - if (src && isRelativeUrl(src)) { - return ; - } - return {alt; - }, - } - : baseComponents; + // Only re-render if THIS item has search matches + const { searchQuery, searchMatches, currentSearchIndex } = useStore( + useShallow((s) => { + const hasMatch = itemId ? s.searchMatchItemIds.has(itemId) : false; + return { + searchQuery: hasMatch ? s.searchQuery : '', + searchMatches: hasMatch ? s.searchMatches : EMPTY_SEARCH_MATCHES, + currentSearchIndex: hasMatch ? s.currentSearchIndex : -1, + }; + }) + ); + // Guard: very large markdown can freeze the renderer (remark/rehype + highlighting). + // For large content, default to a lightweight raw preview with manual expansion. + if (isTooLarge || showRaw) { + const shown = content.slice(0, Math.min(rawLimit, content.length)); + const isTruncated = shown.length < content.length; return (
= React.memo( } } > - {/* Copy button overlay (when no label header) */} {copyable && !label && ( )} - {/* Optional header - matches CodeBlockViewer style */} {label && (
= React.memo( {label} - {copyable && ( - <> - - - - )} + + Raw + + + + {copyable && }
)} - {/* Markdown content with scroll */} -
-
- + Raw preview +
+ )} + + {isTooLarge && ( +
+ Content is very large ({content.length.toLocaleString()} chars). Showing raw preview to + keep the UI responsive. +
+ )} + +
+
+            {shown}
+          
+ {isTruncated && ( +
+ + Showing {shown.length.toLocaleString()} / {content.length.toLocaleString()} chars + +
+ + +
+
+ )}
); } -); + + // Create search context (fresh each render so counter starts at 0) + const effectiveQuery = (searchQueryOverride ?? searchQuery).trim(); + const effectiveMatches = searchQueryOverride ? [] : searchMatches; + const effectiveIndex = searchQueryOverride ? -1 : currentSearchIndex; + const searchCtx = + effectiveQuery && itemId + ? createSearchContext(effectiveQuery, itemId, effectiveMatches, effectiveIndex) + : null; + // Local search (Claude logs): use bright highlight for all matches (no "current result" concept). + if (searchCtx && searchQueryOverride) { + searchCtx.forceAllActive = true; + } + + // Create markdown components with optional search highlighting + // When search is active, create fresh each render (match counter is stateful and must start at 0) + // useMemo would cache stale closures when parent re-renders without search deps changing + const baseComponents = searchCtx + ? createViewerMarkdownComponents(searchCtx, isLight, teamColorByName, onTeamClick, copyable) + : isLight + ? createViewerMarkdownComponents(null, true, teamColorByName, onTeamClick, copyable) + : createViewerMarkdownComponents(null, false, teamColorByName, onTeamClick, copyable); + + // When baseDir is set (editor preview), override img to load local files via IPC + const components = baseDir + ? { + ...baseComponents, + img: ({ src, alt }: { src?: string; alt?: string }) => { + if (src && isRelativeUrl(src)) { + return ; + } + return {alt; + }, + } + : baseComponents; + + return ( +
+ {/* Copy button overlay (when no label header) */} + {copyable && !label && ( + + )} + + {/* Optional header - matches CodeBlockViewer style */} + {label && ( +
+ + + {label} + + {copyable && ( + <> + + + + )} +
+ )} + + {/* Markdown content with scroll */} +
+
+ + {content} + +
+
+
+ ); +}); diff --git a/src/renderer/components/schedules/SchedulesView.tsx b/src/renderer/components/schedules/SchedulesView.tsx index 3535cc28..96d72051 100644 --- a/src/renderer/components/schedules/SchedulesView.tsx +++ b/src/renderer/components/schedules/SchedulesView.tsx @@ -25,12 +25,12 @@ import { import { useShallow } from 'zustand/react/shallow'; import { ScheduleRunLogDialog } from '../team/schedule/ScheduleRunLogDialog'; +import { ScheduleRunRow } from '../team/schedule/ScheduleRunRow'; +import { ScheduleStatusBadge } from '../team/schedule/ScheduleStatusBadge'; const LaunchTeamDialog = lazy(() => import('../team/dialogs/LaunchTeamDialog').then((m) => ({ default: m.LaunchTeamDialog })) ); -import { ScheduleRunRow } from '../team/schedule/ScheduleRunRow'; -import { ScheduleStatusBadge } from '../team/schedule/ScheduleStatusBadge'; import type { Schedule, ScheduleRun, ScheduleStatus } from '@shared/types'; diff --git a/src/renderer/components/sidebar/GlobalTaskList.tsx b/src/renderer/components/sidebar/GlobalTaskList.tsx index d4a48051..f236f190 100644 --- a/src/renderer/components/sidebar/GlobalTaskList.tsx +++ b/src/renderer/components/sidebar/GlobalTaskList.tsx @@ -173,116 +173,118 @@ function applyProjectFilter(tasks: GlobalTask[], projectPath: string | null): Gl return tasks.filter((t) => t.projectPath && normalizePath(t.projectPath) === normalized); } -export const GlobalTaskList = memo( - ({ - hideHeader = false, - filters: externalFilters, - onFiltersChange: externalOnFiltersChange, - filtersPopoverOpen: externalFiltersPopoverOpen, - onFiltersPopoverOpenChange: externalOnFiltersPopoverOpenChange, - }: GlobalTaskListProps = {}): React.JSX.Element => { - const { - globalTasks, - globalTasksLoading, - globalTasksInitialized, - fetchAllTasks, - softDeleteTask, - projects, - viewMode, - repositoryGroups, - teams, - } = useStore( - useShallow((s) => ({ - globalTasks: s.globalTasks, - globalTasksLoading: s.globalTasksLoading, - globalTasksInitialized: s.globalTasksInitialized, - fetchAllTasks: s.fetchAllTasks, - softDeleteTask: s.softDeleteTask, - projects: s.projects, - viewMode: s.viewMode, - repositoryGroups: s.repositoryGroups, - teams: s.teams, - })) - ); +export const GlobalTaskList = memo(function GlobalTaskList({ + hideHeader = false, + filters: externalFilters, + onFiltersChange: externalOnFiltersChange, + filtersPopoverOpen: externalFiltersPopoverOpen, + onFiltersPopoverOpenChange: externalOnFiltersPopoverOpenChange, +}: GlobalTaskListProps = {}): React.JSX.Element { + const { + globalTasks, + globalTasksLoading, + globalTasksInitialized, + fetchAllTasks, + softDeleteTask, + projects, + viewMode, + repositoryGroups, + teams, + } = useStore( + useShallow((s) => ({ + globalTasks: s.globalTasks, + globalTasksLoading: s.globalTasksLoading, + globalTasksInitialized: s.globalTasksInitialized, + fetchAllTasks: s.fetchAllTasks, + softDeleteTask: s.softDeleteTask, + projects: s.projects, + viewMode: s.viewMode, + repositoryGroups: s.repositoryGroups, + teams: s.teams, + })) + ); - const [internalFilters, setInternalFilters] = useState(defaultTaskFiltersState); - const [internalFiltersPopoverOpen, setInternalFiltersPopoverOpen] = useState(false); - const filters = externalFilters ?? internalFilters; - const setFilters = externalOnFiltersChange ?? setInternalFilters; - const filtersPopoverOpen = externalFiltersPopoverOpen ?? internalFiltersPopoverOpen; - const setFiltersPopoverOpen = - externalOnFiltersPopoverOpenChange ?? setInternalFiltersPopoverOpen; - const [searchQuery, setSearchQuery] = useState(''); - const [groupingMode, setGroupingModeState] = useState(loadGroupingMode); - const [sortMode, setSortModeState] = useState(loadSortMode); - const [sortPopoverOpen, setSortPopoverOpen] = useState(false); - const [showArchived, setShowArchived] = useState(false); - const [renamingTaskKey, setRenamingTaskKey] = useState(null); - const [projectRequestedVisibleCountByKey, setProjectRequestedVisibleCountByKey] = useState< - Record - >({}); - const searchInputRef = useRef(null); - const hasFetchedRef = useRef(false); - const readState = useReadStateSnapshot(); - const taskLocalState = useTaskLocalState(); + const [internalFilters, setInternalFilters] = useState(defaultTaskFiltersState); + const [internalFiltersPopoverOpen, setInternalFiltersPopoverOpen] = useState(false); + const filters = externalFilters ?? internalFilters; + const setFilters = externalOnFiltersChange ?? setInternalFilters; + const filtersPopoverOpen = externalFiltersPopoverOpen ?? internalFiltersPopoverOpen; + const setFiltersPopoverOpen = externalOnFiltersPopoverOpenChange ?? setInternalFiltersPopoverOpen; + const [searchQuery, setSearchQuery] = useState(''); + const [groupingMode, setGroupingModeState] = useState(loadGroupingMode); + const [sortMode, setSortModeState] = useState(loadSortMode); + const [sortPopoverOpen, setSortPopoverOpen] = useState(false); + const [showArchived, setShowArchived] = useState(false); + const [renamingTaskKey, setRenamingTaskKey] = useState(null); + const [projectRequestedVisibleCountByKey, setProjectRequestedVisibleCountByKey] = useState< + Record + >({}); + const searchInputRef = useRef(null); + const hasFetchedRef = useRef(false); + const readState = useReadStateSnapshot(); + const taskLocalState = useTaskLocalState(); - // --- New-task animation tracking (same pattern as ChatHistory) --- - const knownTaskIdsRef = useRef>(new Set()); - const isInitialTaskLoadRef = useRef(true); + // --- New-task animation tracking (same pattern as ChatHistory) --- + const knownTaskIdsRef = useRef>(new Set()); + const isInitialTaskLoadRef = useRef(true); - const newTaskIds = useMemo(() => { - if (!globalTasksInitialized || globalTasks.length === 0) { - return new Set(); - } + const newTaskIds = useMemo(() => { + if (!globalTasksInitialized || globalTasks.length === 0) { + return new Set(); + } - // eslint-disable-next-line react-hooks/refs -- Synchronous diff is required so new rows mount with animate=true. - if (isInitialTaskLoadRef.current) { - isInitialTaskLoadRef.current = false; - for (const t of globalTasks) { - // eslint-disable-next-line react-hooks/refs -- Synchronous diff is required so new rows mount with animate=true. - knownTaskIdsRef.current.add(`${t.teamName}:${t.id}`); - } - return new Set(); - } - - const newIds = new Set(); + // eslint-disable-next-line react-hooks/refs -- Synchronous diff is required so new rows mount with animate=true. + if (isInitialTaskLoadRef.current) { + isInitialTaskLoadRef.current = false; for (const t of globalTasks) { - const key = `${t.teamName}:${t.id}`; // eslint-disable-next-line react-hooks/refs -- Synchronous diff is required so new rows mount with animate=true. - if (!knownTaskIdsRef.current.has(key)) { - newIds.add(key); - // eslint-disable-next-line react-hooks/refs -- Synchronous diff is required so new rows mount with animate=true. - knownTaskIdsRef.current.add(key); - } + knownTaskIdsRef.current.add(`${t.teamName}:${t.id}`); } - return newIds; - }, [globalTasks, globalTasksInitialized]); + return new Set(); + } - const isNewTask = useCallback( - (task: GlobalTask): boolean => newTaskIds.has(`${task.teamName}:${task.id}`), - [newTaskIds] - ); + const newIds = new Set(); + for (const t of globalTasks) { + const key = `${t.teamName}:${t.id}`; + // eslint-disable-next-line react-hooks/refs -- Synchronous diff is required so new rows mount with animate=true. + if (!knownTaskIdsRef.current.has(key)) { + newIds.add(key); + // eslint-disable-next-line react-hooks/refs -- Synchronous diff is required so new rows mount with animate=true. + knownTaskIdsRef.current.add(key); + } + } + return newIds; + }, [globalTasks, globalTasksInitialized]); - const setGroupingMode = (mode: TaskGroupingMode): void => { - setGroupingModeState(mode); - saveGroupingMode(mode); - }; + const isNewTask = useCallback( + (task: GlobalTask): boolean => newTaskIds.has(`${task.teamName}:${task.id}`), + [newTaskIds] + ); - const setSortMode = (mode: TaskSortMode): void => { - setSortModeState(mode); - saveSortMode(mode); - }; + const setGroupingMode = (mode: TaskGroupingMode): void => { + setGroupingModeState(mode); + saveGroupingMode(mode); + }; - const handleRenameComplete = (teamName: string, taskId: string, newSubject: string): void => { + const setSortMode = (mode: TaskSortMode): void => { + setSortModeState(mode); + saveSortMode(mode); + }; + + const handleRenameComplete = useCallback( + (teamName: string, taskId: string, newSubject: string): void => { taskLocalState.renameTask(teamName, taskId, newSubject); setRenamingTaskKey(null); - }; + }, + [taskLocalState] + ); - const handleRenameCancel = (): void => { - setRenamingTaskKey(null); - }; + const handleRenameCancel = useCallback((): void => { + setRenamingTaskKey(null); + }, []); - const handleDeleteTask = async (teamName: string, taskId: string): Promise => { + const handleDeleteTask = useCallback( + async (teamName: string, taskId: string): Promise => { const confirmed = await confirm({ title: 'Delete task', message: `Move task #${deriveTaskDisplayId(taskId)} to trash?`, @@ -303,559 +305,555 @@ export const GlobalTaskList = memo( }); } } - }; + }, + [fetchAllTasks, softDeleteTask] + ); - // Fetch tasks on mount — loading guard in the store action prevents - // duplicate IPC calls when the centralized init chain is already fetching. - useEffect(() => { - if (!hasFetchedRef.current && !globalTasksLoading) { - hasFetchedRef.current = true; - void fetchAllTasks(); - } - }, [fetchAllTasks, globalTasksLoading]); + // Fetch tasks on mount — loading guard in the store action prevents + // duplicate IPC calls when the centralized init chain is already fetching. + useEffect(() => { + if (!hasFetchedRef.current && !globalTasksLoading) { + hasFetchedRef.current = true; + void fetchAllTasks(); + } + }, [fetchAllTasks, globalTasksLoading]); - // Build project combobox options from available projects/repos - const projectFilterOptions = useMemo((): ComboboxOption[] => { - const items = - viewMode === 'grouped' - ? repositoryGroups - .filter((r) => r.totalSessions > 0) - .map((r) => ({ - value: r.worktrees[0]?.path ?? r.id, - label: r.name, - path: r.worktrees[0]?.path, - })) - : projects - .filter((p) => (p.totalSessions ?? p.sessions.length) > 0) - .map((p) => ({ - value: p.path, - label: p.name, - path: p.path, - })); + // Build project combobox options from available projects/repos + const projectFilterOptions = useMemo((): ComboboxOption[] => { + const items = + viewMode === 'grouped' + ? repositoryGroups + .filter((r) => r.totalSessions > 0) + .map((r) => ({ + value: r.worktrees[0]?.path ?? r.id, + label: r.name, + path: r.worktrees[0]?.path, + })) + : projects + .filter((p) => (p.totalSessions ?? p.sessions.length) > 0) + .map((p) => ({ + value: p.path, + label: p.name, + path: p.path, + })); - return items.map((item) => ({ - value: item.value, - label: item.label, - description: item.path, - })); - }, [viewMode, repositoryGroups, projects]); + return items.map((item) => ({ + value: item.value, + label: item.label, + description: item.path, + })); + }, [viewMode, repositoryGroups, projects]); - // Resolve project filter from filters state - const selectedProjectPath = filters.projectPath; - const hasArchivedTasks = useMemo( - () => globalTasks.some((t) => taskLocalState.isArchived(t.teamName, t.id)), - [globalTasks, taskLocalState] - ); - const effectiveShowArchived = showArchived && hasArchivedTasks; + // Resolve project filter from filters state + const selectedProjectPath = filters.projectPath; + const hasArchivedTasks = useMemo( + () => globalTasks.some((t) => taskLocalState.isArchived(t.teamName, t.id)), + [globalTasks, taskLocalState] + ); + const effectiveShowArchived = showArchived && hasArchivedTasks; - const filtered = useMemo(() => { - let result = globalTasks; - result = applyProjectFilter(result, selectedProjectPath); - result = result.filter((t) => taskMatchesStatus(t, filters.statusIds)); - if (filters.teamName) { - result = result.filter((t) => t.teamName === filters.teamName); - } - if (filters.readFilter === 'unread') { - result = result.filter( - (t) => getTaskUnreadCount(readState, t.teamName, t.id, t.comments) > 0 - ); - } else if (filters.readFilter === 'read') { - result = result.filter( - (t) => getTaskUnreadCount(readState, t.teamName, t.id, t.comments) === 0 - ); - } - result = applySearch(result, searchQuery); - // Archive filtering - if (effectiveShowArchived) { - result = result.filter((t) => taskLocalState.isArchived(t.teamName, t.id)); - } else { - result = result.filter((t) => !taskLocalState.isArchived(t.teamName, t.id)); - } - return result; - }, [ - globalTasks, - selectedProjectPath, - filters.statusIds, - filters.teamName, - filters.readFilter, - searchQuery, - readState, - effectiveShowArchived, - taskLocalState, - ]); + const filtered = useMemo(() => { + let result = globalTasks; + result = applyProjectFilter(result, selectedProjectPath); + result = result.filter((t) => taskMatchesStatus(t, filters.statusIds)); + if (filters.teamName) { + result = result.filter((t) => t.teamName === filters.teamName); + } + if (filters.readFilter === 'unread') { + result = result.filter( + (t) => getTaskUnreadCount(readState, t.teamName, t.id, t.comments) > 0 + ); + } else if (filters.readFilter === 'read') { + result = result.filter( + (t) => getTaskUnreadCount(readState, t.teamName, t.id, t.comments) === 0 + ); + } + result = applySearch(result, searchQuery); + // Archive filtering + if (effectiveShowArchived) { + result = result.filter((t) => taskLocalState.isArchived(t.teamName, t.id)); + } else { + result = result.filter((t) => !taskLocalState.isArchived(t.teamName, t.id)); + } + return result; + }, [ + globalTasks, + selectedProjectPath, + filters.statusIds, + filters.teamName, + filters.readFilter, + searchQuery, + readState, + effectiveShowArchived, + taskLocalState, + ]); - // Split into pinned and normal (non-pinned) tasks - const pinnedTasks = useMemo( - () => filtered.filter((t) => taskLocalState.isPinned(t.teamName, t.id)), - [filtered, taskLocalState] - ); - const normalTasks = useMemo( - () => filtered.filter((t) => !taskLocalState.isPinned(t.teamName, t.id)), - [filtered, taskLocalState] - ); + // Split into pinned and normal (non-pinned) tasks + const pinnedTasks = useMemo( + () => filtered.filter((t) => taskLocalState.isPinned(t.teamName, t.id)), + [filtered, taskLocalState] + ); + const normalTasks = useMemo( + () => filtered.filter((t) => !taskLocalState.isPinned(t.teamName, t.id)), + [filtered, taskLocalState] + ); - const sortedFlat = useMemo( - () => applySortMode(normalTasks, sortMode, readState), - [normalTasks, sortMode, readState] - ); - const grouped = useMemo(() => groupTasksByDate(normalTasks), [normalTasks]); - const categories = useMemo(() => getNonEmptyTaskCategories(grouped), [grouped]); - const projectGroups = useMemo(() => groupTasksByProject(normalTasks), [normalTasks]); + const sortedFlat = useMemo( + () => applySortMode(normalTasks, sortMode, readState), + [normalTasks, sortMode, readState] + ); + const grouped = useMemo(() => groupTasksByDate(normalTasks), [normalTasks]); + const categories = useMemo(() => getNonEmptyTaskCategories(grouped), [grouped]); + const projectGroups = useMemo(() => groupTasksByProject(normalTasks), [normalTasks]); - // Collapsed group keys for each grouping mode - const projectGroupKeys = useMemo( - () => projectGroups.filter((g) => g.tasks.length > 0).map((g) => g.projectKey), - [projectGroups] - ); - const timeGroupKeys = useMemo(() => categories.map((c) => c), [categories]); - const projectGroupVisibility = useMemo( - () => - projectGroups.map((group) => ({ - projectKey: group.projectKey, - taskCount: group.tasks.length, - })), - [projectGroups] - ); - const projectVisibleCountByKey = useMemo( - () => - syncProjectGroupVisibleCountByKey( - projectRequestedVisibleCountByKey, - projectGroupVisibility - ), - [projectRequestedVisibleCountByKey, projectGroupVisibility] - ); + // Collapsed group keys for each grouping mode + const projectGroupKeys = useMemo( + () => projectGroups.filter((g) => g.tasks.length > 0).map((g) => g.projectKey), + [projectGroups] + ); + const timeGroupKeys = useMemo(() => categories.map((c) => c), [categories]); + const projectGroupVisibility = useMemo( + () => + projectGroups.map((group) => ({ + projectKey: group.projectKey, + taskCount: group.tasks.length, + })), + [projectGroups] + ); + const projectVisibleCountByKey = useMemo( + () => + syncProjectGroupVisibleCountByKey(projectRequestedVisibleCountByKey, projectGroupVisibility), + [projectRequestedVisibleCountByKey, projectGroupVisibility] + ); - const projectCollapsed = useCollapsedGroups('project', projectGroupKeys); - const timeCollapsed = useCollapsedGroups('time', timeGroupKeys); + const projectCollapsed = useCollapsedGroups('project', projectGroupKeys); + const timeCollapsed = useCollapsedGroups('time', timeGroupKeys); - const hasContent = - pinnedTasks.length > 0 || - (groupingMode === 'none' - ? sortedFlat.length > 0 - : groupingMode === 'time' - ? categories.length > 0 - : projectGroups.some((g) => g.tasks.length > 0)); + const hasContent = + pinnedTasks.length > 0 || + (groupingMode === 'none' + ? sortedFlat.length > 0 + : groupingMode === 'time' + ? categories.length > 0 + : projectGroups.some((g) => g.tasks.length > 0)); - const noProjectGroupColor = useMemo( - () => ({ - border: 'var(--color-border)', - glow: 'transparent', - icon: 'var(--color-text-muted)', - text: 'var(--color-text-secondary)', - }), - [] - ); + const noProjectGroupColor = useMemo( + () => ({ + border: 'var(--color-border)', + glow: 'transparent', + icon: 'var(--color-text-muted)', + text: 'var(--color-text-secondary)', + }), + [] + ); - return ( -
- {!hideHeader && ( -
- Tasks -
- )} - - {/* Search bar */} + return ( +
+ {!hideHeader && (
- - setSearchQuery(e.target.value)} - className="min-w-0 flex-1 bg-transparent text-[12px] text-text placeholder:text-text-muted focus:outline-none" - /> - {searchQuery && ( + Tasks +
+ )} + + {/* Search bar */} +
+ + setSearchQuery(e.target.value)} + className="min-w-0 flex-1 bg-transparent text-[12px] text-text placeholder:text-text-muted focus:outline-none" + /> + {searchQuery && ( + + )} + + - )} - - - - - -
- {SORT_OPTIONS.map((opt) => ( - - ))} -
-
-
- ({ teamName: t.teamName, displayName: t.displayName }))} - projectOptions={projectFilterOptions} - filters={filters} - onFiltersChange={setFilters} - onApply={() => {}} - /> -
- - {/* Pinned tasks section */} - {pinnedTasks.length > 0 && !effectiveShowArchived && ( -
-
- - Pinned -
- {sortTasksByFreshness(pinnedTasks).map((task) => ( - taskLocalState.togglePin(task.teamName, task.id)} - onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)} - onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} - onDelete={() => handleDeleteTask(task.teamName, task.id)} - > - - taskLocalState.getRenamedSubject(t.teamName, t.id)} - /> - - - ))} -
- )} - - {/* Grouping mode — compact text toggle */} -
- Group by: -
- {(['none', 'project', 'time'] as const).map((mode) => { - const label = mode === 'none' ? 'None' : mode === 'project' ? 'Project' : 'Time'; - return ( + + +
+ {SORT_OPTIONS.map((opt) => ( + ))} +
+
+ + ({ teamName: t.teamName, displayName: t.displayName }))} + projectOptions={projectFilterOptions} + filters={filters} + onFiltersChange={setFilters} + onApply={() => {}} + /> +
+ + {/* Pinned tasks section */} + {pinnedTasks.length > 0 && !effectiveShowArchived && ( +
+
+ + Pinned +
+ {sortTasksByFreshness(pinnedTasks).map((task) => ( + taskLocalState.togglePin(task.teamName, task.id)} + onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)} + onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} + onDelete={() => handleDeleteTask(task.teamName, task.id)} + > + + taskLocalState.getRenamedSubject(t.teamName, t.id)} + /> + + + ))} +
+ )} + + {/* Grouping mode — compact text toggle */} +
+ Group by: +
+ {(['none', 'project', 'time'] as const).map((mode) => { + const label = mode === 'none' ? 'None' : mode === 'project' ? 'Project' : 'Time'; + return ( + + ); + })} +
+ {/* Archive toggle — only visible when archived tasks exist */} + {hasArchivedTasks && ( +
+ + + - ); - })} + + + {effectiveShowArchived ? 'Hide archived' : 'Show archived'} + +
- {/* Archive toggle — only visible when archived tasks exist */} - {hasArchivedTasks && ( -
- - - - - - {effectiveShowArchived ? 'Hide archived' : 'Show archived'} - - -
- )} -
- - {/* Content */} -
- {globalTasksLoading && !globalTasksInitialized && ( -
- {[1, 2, 3].map((i) => ( -
- ))} -
- )} - - {globalTasksInitialized && !hasContent && ( -
- - - {searchQuery || selectedProjectPath ? 'No matching tasks' : 'No tasks found'} - -
- )} - - {groupingMode === 'none' && - sortedFlat.map((task) => ( - taskLocalState.togglePin(task.teamName, task.id)} - onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)} - onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} - onDelete={() => handleDeleteTask(task.teamName, task.id)} - > - - taskLocalState.getRenamedSubject(t.teamName, t.id)} - /> - - - ))} - - {groupingMode === 'project' && - projectGroups.map((group) => { - if (group.tasks.length === 0) return null; - const isGroupCollapsed = projectCollapsed.isCollapsed(group.projectKey); - const isNoProjectGroup = group.projectKey === NO_PROJECT_KEY; - const groupColor = isNoProjectGroup - ? noProjectGroupColor - : projectColor(group.projectLabel); - const visibleCount = getProjectGroupVisibleCount( - projectVisibleCountByKey[group.projectKey], - group.tasks.length - ); - const visibleTasks = group.tasks.slice(0, visibleCount); - const showMoreVisible = canProjectGroupShowMore(visibleCount, group.tasks.length); - const showLessVisible = canProjectGroupShowLess(visibleCount, group.tasks.length); - let lastTeam: string | null = null; - return ( -
- - {!isGroupCollapsed && - visibleTasks.map((task) => { - const showTeamHeader = task.teamName !== lastTeam; - lastTeam = task.teamName; - return ( -
- {showTeamHeader && ( -
- Team: {task.teamDisplayName} -
- )} - taskLocalState.togglePin(task.teamName, task.id)} - onToggleArchive={() => - taskLocalState.toggleArchive(task.teamName, task.id) - } - onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} - onDelete={() => handleDeleteTask(task.teamName, task.id)} - > - - - taskLocalState.getRenamedSubject(t.teamName, t.id) - } - /> - - -
- ); - })} - {!isGroupCollapsed && (showMoreVisible || showLessVisible) && ( -
- {showMoreVisible && ( - - )} - {showLessVisible && ( - - )} -
- )} -
- ); - })} - - {groupingMode === 'time' && - categories.map((category) => { - const tasks = grouped[category]; - const isGroupCollapsed = timeCollapsed.isCollapsed(category); - let lastTeam: string | null = null; - - return ( -
- - - {!isGroupCollapsed && - tasks.map((task) => { - const showTeamHeader = task.teamName !== lastTeam; - lastTeam = task.teamName; - - return ( -
- {showTeamHeader && ( -
- Team: {task.teamDisplayName} -
- )} - taskLocalState.togglePin(task.teamName, task.id)} - onToggleArchive={() => - taskLocalState.toggleArchive(task.teamName, task.id) - } - onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} - onDelete={() => handleDeleteTask(task.teamName, task.id)} - > - - - taskLocalState.getRenamedSubject(t.teamName, t.id) - } - /> - - -
- ); - })} -
- ); - })} -
+ )}
- ); - } -); + + {/* Content */} +
+ {globalTasksLoading && !globalTasksInitialized && ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ )} + + {globalTasksInitialized && !hasContent && ( +
+ + + {searchQuery || selectedProjectPath ? 'No matching tasks' : 'No tasks found'} + +
+ )} + + {groupingMode === 'none' && + sortedFlat.map((task) => ( + taskLocalState.togglePin(task.teamName, task.id)} + onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)} + onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} + onDelete={() => handleDeleteTask(task.teamName, task.id)} + > + + taskLocalState.getRenamedSubject(t.teamName, t.id)} + /> + + + ))} + + {groupingMode === 'project' && + projectGroups.map((group) => { + if (group.tasks.length === 0) return null; + const isGroupCollapsed = projectCollapsed.isCollapsed(group.projectKey); + const isNoProjectGroup = group.projectKey === NO_PROJECT_KEY; + const groupColor = isNoProjectGroup + ? noProjectGroupColor + : projectColor(group.projectLabel); + const visibleCount = getProjectGroupVisibleCount( + projectVisibleCountByKey[group.projectKey], + group.tasks.length + ); + const visibleTasks = group.tasks.slice(0, visibleCount); + const showMoreVisible = canProjectGroupShowMore(visibleCount, group.tasks.length); + const showLessVisible = canProjectGroupShowLess(visibleCount, group.tasks.length); + let lastTeam: string | null = null; + return ( +
+ + {!isGroupCollapsed && + visibleTasks.map((task) => { + const showTeamHeader = task.teamName !== lastTeam; + lastTeam = task.teamName; + return ( +
+ {showTeamHeader && ( +
+ Team: {task.teamDisplayName} +
+ )} + taskLocalState.togglePin(task.teamName, task.id)} + onToggleArchive={() => + taskLocalState.toggleArchive(task.teamName, task.id) + } + onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} + onDelete={() => handleDeleteTask(task.teamName, task.id)} + > + + + taskLocalState.getRenamedSubject(t.teamName, t.id) + } + /> + + +
+ ); + })} + {!isGroupCollapsed && (showMoreVisible || showLessVisible) && ( +
+ {showMoreVisible && ( + + )} + {showLessVisible && ( + + )} +
+ )} +
+ ); + })} + + {groupingMode === 'time' && + categories.map((category) => { + const tasks = grouped[category]; + const isGroupCollapsed = timeCollapsed.isCollapsed(category); + let lastTeam: string | null = null; + + return ( +
+ + + {!isGroupCollapsed && + tasks.map((task) => { + const showTeamHeader = task.teamName !== lastTeam; + lastTeam = task.teamName; + + return ( +
+ {showTeamHeader && ( +
+ Team: {task.teamDisplayName} +
+ )} + taskLocalState.togglePin(task.teamName, task.id)} + onToggleArchive={() => + taskLocalState.toggleArchive(task.teamName, task.id) + } + onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} + onDelete={() => handleDeleteTask(task.teamName, task.id)} + > + + + taskLocalState.getRenamedSubject(t.teamName, t.id) + } + /> + + +
+ ); + })} +
+ ); + })} +
+
+ ); +}); diff --git a/src/renderer/components/sidebar/SessionItem.tsx b/src/renderer/components/sidebar/SessionItem.tsx index dbf534c4..f9726a75 100644 --- a/src/renderer/components/sidebar/SessionItem.tsx +++ b/src/renderer/components/sidebar/SessionItem.tsx @@ -155,243 +155,239 @@ const SessionRuntimeBadge = ({ ); }; -export const SessionItem = memo( - ({ - session, - isActive, - isPinned, - isHidden, - multiSelectActive, - isSelected, - }: Readonly): React.JSX.Element => { - const { - openTab, - activeProjectId, - selectSession, - paneCount, - splitPane, - togglePinSession, - toggleHideSession, - toggleSidebarSessionSelection, - } = useStore( - useShallow((s) => ({ - openTab: s.openTab, - activeProjectId: s.activeProjectId, - selectSession: s.selectSession, - paneCount: s.paneLayout.panes.length, - splitPane: s.splitPane, - togglePinSession: s.togglePinSession, - toggleHideSession: s.toggleHideSession, - toggleSidebarSessionSelection: s.toggleSidebarSessionSelection, - })) +export const SessionItem = memo(function SessionItem({ + session, + isActive, + isPinned, + isHidden, + multiSelectActive, + isSelected, +}: Readonly): React.JSX.Element { + const { + openTab, + activeProjectId, + selectSession, + paneCount, + splitPane, + togglePinSession, + toggleHideSession, + toggleSidebarSessionSelection, + } = useStore( + useShallow((s) => ({ + openTab: s.openTab, + activeProjectId: s.activeProjectId, + selectSession: s.selectSession, + paneCount: s.paneLayout.panes.length, + splitPane: s.splitPane, + togglePinSession: s.togglePinSession, + toggleHideSession: s.toggleHideSession, + toggleSidebarSessionSelection: s.toggleSidebarSessionSelection, + })) + ); + + const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); + + const handleClick = (event: React.MouseEvent): void => { + if (!activeProjectId) return; + + // In multi-select mode, clicks toggle selection + if (multiSelectActive) { + toggleSidebarSessionSelection(session.id); + return; + } + + // Cmd/Ctrl+click: open in new tab; plain click: replace current tab + const forceNewTab = event.ctrlKey || event.metaKey; + + openTab( + { + type: 'session', + sessionId: session.id, + projectId: activeProjectId, + label: formatSessionLabel(session.firstMessage), + }, + forceNewTab ? { forceNewTab } : { replaceActiveTab: true } ); - const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); + selectSession(session.id); + }; - const handleClick = (event: React.MouseEvent): void => { - if (!activeProjectId) return; + const handleContextMenu = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + setContextMenu({ x: e.clientX, y: e.clientY }); + }, []); - // In multi-select mode, clicks toggle selection - if (multiSelectActive) { - toggleSidebarSessionSelection(session.id); - return; - } + const sessionLabel = formatSessionLabel(session.firstMessage); - // Cmd/Ctrl+click: open in new tab; plain click: replace current tab - const forceNewTab = event.ctrlKey || event.metaKey; - - openTab( - { - type: 'session', - sessionId: session.id, - projectId: activeProjectId, - label: formatSessionLabel(session.firstMessage), - }, - forceNewTab ? { forceNewTab } : { replaceActiveTab: true } - ); - - selectSession(session.id); - }; - - const handleContextMenu = useCallback((e: React.MouseEvent) => { - e.preventDefault(); - setContextMenu({ x: e.clientX, y: e.clientY }); - }, []); - - const sessionLabel = formatSessionLabel(session.firstMessage); - - const handleOpenInCurrentPane = useCallback(() => { - if (!activeProjectId) return; - openTab( - { - type: 'session', - sessionId: session.id, - projectId: activeProjectId, - label: sessionLabel, - }, - { replaceActiveTab: true } - ); - selectSession(session.id); - }, [activeProjectId, openTab, selectSession, session.id, sessionLabel]); - - const handleOpenInNewTab = useCallback(() => { - if (!activeProjectId) return; - openTab( - { - type: 'session', - sessionId: session.id, - projectId: activeProjectId, - label: sessionLabel, - }, - { forceNewTab: true } - ); - selectSession(session.id); - }, [activeProjectId, openTab, selectSession, session.id, sessionLabel]); - - const handleSplitRightAndOpen = useCallback(() => { - if (!activeProjectId) return; - // First open the tab in the focused pane - openTab({ + const handleOpenInCurrentPane = useCallback(() => { + if (!activeProjectId) return; + openTab( + { type: 'session', sessionId: session.id, projectId: activeProjectId, label: sessionLabel, - }); - selectSession(session.id); - // Then split it to the right - const state = useStore.getState(); - const focusedPaneId = state.paneLayout.focusedPaneId; - const activeTabId = state.activeTabId; - if (activeTabId) { - splitPane(focusedPaneId, activeTabId, 'right'); - } - }, [activeProjectId, openTab, selectSession, session.id, sessionLabel, splitPane]); - - // Height must match SESSION_HEIGHT (54px) in DateGroupedSessions.tsx for virtual scroll - return ( - <> - - - {contextMenu && - activeProjectId && - createPortal( - setContextMenu(null)} - onOpenInCurrentPane={handleOpenInCurrentPane} - onOpenInNewTab={handleOpenInNewTab} - onSplitRightAndOpen={handleSplitRightAndOpen} - onTogglePin={() => void togglePinSession(session.id)} - onToggleHide={() => void toggleHideSession(session.id)} - />, - document.body - )} - + }, + { replaceActiveTab: true } ); - } -); + selectSession(session.id); + }, [activeProjectId, openTab, selectSession, session.id, sessionLabel]); + + const handleOpenInNewTab = useCallback(() => { + if (!activeProjectId) return; + openTab( + { + type: 'session', + sessionId: session.id, + projectId: activeProjectId, + label: sessionLabel, + }, + { forceNewTab: true } + ); + selectSession(session.id); + }, [activeProjectId, openTab, selectSession, session.id, sessionLabel]); + + const handleSplitRightAndOpen = useCallback(() => { + if (!activeProjectId) return; + // First open the tab in the focused pane + openTab({ + type: 'session', + sessionId: session.id, + projectId: activeProjectId, + label: sessionLabel, + }); + selectSession(session.id); + // Then split it to the right + const state = useStore.getState(); + const focusedPaneId = state.paneLayout.focusedPaneId; + const activeTabId = state.activeTabId; + if (activeTabId) { + splitPane(focusedPaneId, activeTabId, 'right'); + } + }, [activeProjectId, openTab, selectSession, session.id, sessionLabel, splitPane]); + + // Height must match SESSION_HEIGHT (54px) in DateGroupedSessions.tsx for virtual scroll + return ( + <> + + + {contextMenu && + activeProjectId && + createPortal( + setContextMenu(null)} + onOpenInCurrentPane={handleOpenInCurrentPane} + onOpenInNewTab={handleOpenInNewTab} + onSplitRightAndOpen={handleSplitRightAndOpen} + onTogglePin={() => void togglePinSession(session.id)} + onToggleHide={() => void toggleHideSession(session.id)} + />, + document.body + )} + + ); +}); diff --git a/src/renderer/components/sidebar/SidebarTaskItem.tsx b/src/renderer/components/sidebar/SidebarTaskItem.tsx index 5469b20f..ef6d959b 100644 --- a/src/renderer/components/sidebar/SidebarTaskItem.tsx +++ b/src/renderer/components/sidebar/SidebarTaskItem.tsx @@ -69,220 +69,218 @@ interface SidebarTaskItemProps { getDisplaySubject?: (task: GlobalTask) => string | undefined; } -export const SidebarTaskItem = memo( - ({ - task, - hideTeamName, - showTeamName, - renamingKey, - onRenameComplete, - onRenameCancel, - getDisplaySubject, - }: SidebarTaskItemProps): React.JSX.Element => { - const openGlobalTaskDetail = useStore((s) => s.openGlobalTaskDetail); - const teamMembers = useStore(useShallow((s) => s.teamByName[task.teamName]?.members)); - const unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments); - const { isLight } = useTheme(); +export const SidebarTaskItem = memo(function SidebarTaskItem({ + task, + hideTeamName, + showTeamName, + renamingKey, + onRenameComplete, + onRenameCancel, + getDisplaySubject, +}: SidebarTaskItemProps): React.JSX.Element { + const openGlobalTaskDetail = useStore((s) => s.openGlobalTaskDetail); + const teamMembers = useStore(useShallow((s) => s.teamByName[task.teamName]?.members)); + const unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments); + const { isLight } = useTheme(); - const isRenaming = renamingKey === `${task.teamName}:${task.id}`; - const displaySubject = getDisplaySubject?.(task) ?? task.subject; - const [editValue, setEditValue] = useState(displaySubject); - const inputRef = useRef(null); - // Focus input when rename starts - useEffect(() => { - if (!isRenaming) return; - const raf = requestAnimationFrame(() => { - inputRef.current?.focus(); - inputRef.current?.select(); - }); - return () => cancelAnimationFrame(raf); - }, [isRenaming]); + const isRenaming = renamingKey === `${task.teamName}:${task.id}`; + const displaySubject = getDisplaySubject?.(task) ?? task.subject; + const [editValue, setEditValue] = useState(displaySubject); + const inputRef = useRef(null); + // Focus input when rename starts + useEffect(() => { + if (!isRenaming) return; + const raf = requestAnimationFrame(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }); + return () => cancelAnimationFrame(raf); + }, [isRenaming]); - // Reset edit value when renaming starts - useEffect(() => { - if (isRenaming) { - // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change - setEditValue(displaySubject); - } - }, [isRenaming, displaySubject]); + // Reset edit value when renaming starts + useEffect(() => { + if (isRenaming) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change + setEditValue(displaySubject); + } + }, [isRenaming, displaySubject]); - const reviewColumn = getTaskKanbanColumn(task); - const cfg = - reviewColumn === 'approved' - ? ({ icon: ShieldCheck, color: 'text-teal-400', label: 'approved' } as const) - : reviewColumn === 'review' - ? ({ icon: Eye, color: 'text-orange-400', label: 'in review' } as const) - : (statusConfig[task.status] ?? statusConfig.pending); - const StatusIcon = cfg.icon; - const updatedLabel = formatUpdatedLabel(task); - const dateLabel = updatedLabel ?? formatTaskDate(task.createdAt); + const reviewColumn = getTaskKanbanColumn(task); + const cfg = + reviewColumn === 'approved' + ? ({ icon: ShieldCheck, color: 'text-teal-400', label: 'approved' } as const) + : reviewColumn === 'review' + ? ({ icon: Eye, color: 'text-orange-400', label: 'in review' } as const) + : (statusConfig[task.status] ?? statusConfig.pending); + const StatusIcon = cfg.icon; + const updatedLabel = formatUpdatedLabel(task); + const dateLabel = updatedLabel ?? formatTaskDate(task.createdAt); - const ownerColorSet = useMemo(() => { - if (!teamMembers || !task.owner) return null; - const colorMap = buildMemberColorMap(teamMembers); - const colorName = colorMap.get(task.owner); - return colorName ? getTeamColorSet(colorName) : null; - }, [teamMembers, task.owner]); + const ownerColorSet = useMemo(() => { + if (!teamMembers || !task.owner) return null; + const colorMap = buildMemberColorMap(teamMembers); + const colorName = colorMap.get(task.owner); + return colorName ? getTeamColorSet(colorName) : null; + }, [teamMembers, task.owner]); - const ownerTextColor = useMemo(() => { - if (!ownerColorSet) return undefined; - return isLight && ownerColorSet.textLight ? ownerColorSet.textLight : ownerColorSet.text; - }, [ownerColorSet, isLight]); + const ownerTextColor = useMemo(() => { + if (!ownerColorSet) return undefined; + return isLight && ownerColorSet.textLight ? ownerColorSet.textLight : ownerColorSet.text; + }, [ownerColorSet, isLight]); - const projectLabel = useMemo(() => { - if (!task.projectPath?.trim()) return null; - return projectLabelFromPath(task.projectPath); - }, [task.projectPath]); + const projectLabel = useMemo(() => { + if (!task.projectPath?.trim()) return null; + return projectLabelFromPath(task.projectPath); + }, [task.projectPath]); - const projectColorSet = useMemo( - () => (projectLabel ? projectColor(projectLabel, isLight) : null), - [projectLabel, isLight] - ); + const projectColorSet = useMemo( + () => (projectLabel ? projectColor(projectLabel, isLight) : null), + [projectLabel, isLight] + ); - const teamColor = useMemo( - () => (showTeamName ? nameColorSet(task.teamDisplayName, isLight) : null), - [showTeamName, task.teamDisplayName, isLight] - ); + const teamColor = useMemo( + () => (showTeamName ? nameColorSet(task.teamDisplayName, isLight) : null), + [showTeamName, task.teamDisplayName, isLight] + ); - const showTeamRow = showTeamName && !hideTeamName; - const unreadBackgroundClass = - unreadCount > 0 ? (isLight ? 'bg-blue-500/[0.03]' : 'bg-blue-500/[0.05]') : ''; + const showTeamRow = showTeamName && !hideTeamName; + const unreadBackgroundClass = + unreadCount > 0 ? (isLight ? 'bg-blue-500/[0.03]' : 'bg-blue-500/[0.05]') : ''; - return ( -
+ + + {displaySubject} + + + )} +
- {/* Row 3: Team: name · owner */} - {showTeamRow && ( -
+ {task.teamDeleted && } + {projectLabel && ( + - Team: - - {task.teamDisplayName} - - · + {projectLabel} + + )} + {!showTeamRow && ( + <> + {projectLabel && ·} {task.owner ?? 'unassigned'} -
+ )} - - ); - } -); + {dateLabel && ( + + {dateLabel} + + )} +
+ + {/* Row 3: Team: name · owner */} + {showTeamRow && ( +
+ Team: + + {task.teamDisplayName} + + · + + {task.owner ?? 'unassigned'} + +
+ )} + + ); +}); diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index fdd68f1c..011bb0df 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -79,7 +79,6 @@ import { useShallow } from 'zustand/react/shallow'; import { AddMemberDialog } from './dialogs/AddMemberDialog'; import { EditTeamDialog } from './dialogs/EditTeamDialog'; -import type { TeamLaunchDialogMode } from './dialogs/LaunchTeamDialog'; import { ReviewDialog } from './dialogs/ReviewDialog'; import { executeTeamRelaunch } from './dialogs/teamRelaunchFlow'; import { KanbanBoard } from './kanban/KanbanBoard'; @@ -90,6 +89,7 @@ import { MemberDetailDialog } from './members/MemberDetailDialog'; import { type MemberActivityFilter, type MemberDetailTab } from './members/memberDetailTypes'; import type { AddMemberEntry } from './dialogs/AddMemberDialog'; +import type { TeamLaunchDialogMode } from './dialogs/LaunchTeamDialog'; import type { TeamMessagesPanelMode } from '@renderer/types/teamMessagesPanelMode'; import type { ComponentProps, CSSProperties } from 'react'; @@ -958,1235 +958,1223 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge( ); }); -export const TeamDetailView = memo( - ({ teamName, isPaneFocused = false }: TeamDetailViewProps): React.JSX.Element => { - const { isLight } = useTheme(); - const [requestChangesTaskId, setRequestChangesTaskId] = useState(null); - const [selectedTask, setSelectedTask] = useState(null); - const [selectedMember, setSelectedMember] = useState(null); - const [selectedMemberView, setSelectedMemberView] = useState<{ - initialTab?: MemberDetailTab; - initialActivityFilter?: MemberActivityFilter; - } | null>(null); - const [pendingRepliesByMember, setPendingRepliesByMember] = useState>( - () => getTeamPendingRepliesState(teamName) +export const TeamDetailView = memo(function TeamDetailView({ + teamName, + isPaneFocused = false, +}: TeamDetailViewProps): React.JSX.Element { + const { isLight } = useTheme(); + const [requestChangesTaskId, setRequestChangesTaskId] = useState(null); + const [selectedTask, setSelectedTask] = useState(null); + const [selectedMember, setSelectedMember] = useState(null); + const [selectedMemberView, setSelectedMemberView] = useState<{ + initialTab?: MemberDetailTab; + initialActivityFilter?: MemberActivityFilter; + } | null>(null); + const [pendingRepliesByMember, setPendingRepliesByMember] = useState>(() => + getTeamPendingRepliesState(teamName) + ); + const [createTaskDialog, setCreateTaskDialog] = useState({ + open: false, + defaultSubject: '', + defaultDescription: '', + defaultOwner: '', + }); + const [creatingTask, setCreatingTask] = useState(false); + const [addMemberDialogOpen, setAddMemberDialogOpen] = useState(false); + const [addingMemberLoading, setAddingMemberLoading] = useState(false); + const [removeMemberConfirm, setRemoveMemberConfirm] = useState(null); + const [updatingRoleLoading, setUpdatingRoleLoading] = useState(false); + const [editDialogOpen, setEditDialogOpen] = useState(false); + const [launchDialogState, setLaunchDialogState] = useState<{ + open: boolean; + mode: TeamLaunchDialogMode; + }>({ + open: false, + mode: 'launch', + }); + const [editorOpen, setEditorOpen] = useState(false); + const [graphOpen, setGraphOpen] = useState(false); + const contentRef = useRef(null); + const [messagesPanelMountPoint, setMessagesPanelMountPoint] = useState( + null + ); + const provisioningBannerRef = useRef(null); + const wasProvisioningRef = useRef(false); + const handleOpenGraphTab = useCallback(() => { + const state = useStore.getState(); + const displayName = state.teamByName[teamName]?.displayName ?? teamName; + state.openTab({ + type: 'graph', + label: `${displayName} Graph`, + teamName, + }); + }, [teamName]); + const visualizeButtonStyle = useMemo( + () => + isLight + ? { + background: + 'linear-gradient(135deg, rgba(59,130,246,0.14) 0%, rgba(34,197,94,0.16) 100%)', + borderColor: 'rgba(59,130,246,0.30)', + color: '#0f172a', + boxShadow: '0 10px 24px rgba(59,130,246,0.12)', + } + : { + background: + 'linear-gradient(135deg, rgba(56,189,248,0.18) 0%, rgba(16,185,129,0.16) 100%)', + borderColor: 'rgba(56,189,248,0.34)', + color: 'rgba(236,253,255,0.96)', + boxShadow: '0 12px 28px rgba(8,145,178,0.22)', + }, + [isLight] + ); + + // Set inert on background content when editor/graph overlay is open (a11y focus trap) + useEffect(() => { + const el = contentRef.current; + if (!el) return; + if (editorOpen || graphOpen) { + el.setAttribute('inert', ''); + } else { + el.removeAttribute('inert'); + } + }, [editorOpen, graphOpen]); + + // Listen for Cmd+Shift+G keyboard shortcut — opens graph tab + useEffect(() => { + const handler = (e: Event) => { + const detail = (e as CustomEvent).detail; + if (detail?.teamName === teamName) { + handleOpenGraphTab(); + } + }; + window.addEventListener('toggle-team-graph', handler); + return () => window.removeEventListener('toggle-team-graph', handler); + }, [handleOpenGraphTab, teamName]); + + // Listen for graph tab actions (open task, send message) + useEffect(() => { + const onOpenTask = (e: Event) => { + const { teamName: tn, taskId } = (e as CustomEvent).detail ?? {}; + if (tn !== teamName || !data) return; + const task = data.tasks.find((t: { id: string }) => t.id === taskId); + if (task) setSelectedTask(task); + }; + const onSendMsg = (e: Event) => { + const { teamName: tn, memberName } = (e as CustomEvent).detail ?? {}; + if (tn !== teamName) return; + setSendDialogRecipient(memberName); + setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); + setSendDialogOpen(true); + }; + const onOpenProfile = (e: Event) => { + const { + teamName: tn, + memberName, + initialTab, + initialActivityFilter, + } = (e as CustomEvent).detail ?? {}; + if (tn !== teamName || !data) return; + const member = members.find((m: { name: string }) => m.name === memberName); + if (member) { + setSelectedMember(member); + setSelectedMemberView({ + initialTab, + initialActivityFilter, + }); + } + }; + const onCreateTask = (e: Event) => { + const { teamName: tn, owner } = (e as CustomEvent).detail ?? {}; + if (tn !== teamName) return; + openCreateTaskDialog('', '', owner ?? ''); + }; + window.addEventListener('graph:open-task', onOpenTask); + window.addEventListener('graph:send-message', onSendMsg); + window.addEventListener('graph:open-profile', onOpenProfile); + window.addEventListener('graph:create-task', onCreateTask); + + // Task action events from graph + const taskAction = (handler: (taskId: string) => void) => (e: Event) => { + const { teamName: tn, taskId } = (e as CustomEvent).detail ?? {}; + if (tn !== teamName || !taskId) return; + handler(taskId); + }; + const onStartTask = taskAction((taskId) => { + void (async () => { + try { + const result = await startTaskByUser(teamName, taskId); + if (data?.isAlive) { + const task = data.tasks.find((t: { id: string }) => t.id === taskId); + try { + if (result.notifiedOwner && task?.owner) { + await api.teams.processSend( + teamName, + `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has started. Please begin working on it.` + ); + } + } catch { + /* best-effort */ + } + } + } catch { + /* error via store */ + } + })(); + }); + const onCompleteTask = taskAction((taskId) => { + void (async () => { + try { + await updateTaskStatus(teamName, taskId, 'completed'); + } catch { + /* */ + } + })(); + }); + const onApproveTask = taskAction((taskId) => { + void (async () => { + try { + await updateKanban(teamName, taskId, { op: 'set_column', column: 'approved' }); + } catch { + /* */ + } + })(); + }); + const onRequestReviewTask = taskAction((taskId) => { + void (async () => { + try { + await requestReview(teamName, taskId); + } catch { + /* */ + } + })(); + }); + const onRequestChangesTask = taskAction((taskId) => { + setRequestChangesTaskId(taskId); + }); + const onCancelTask = taskAction((taskId) => { + void (async () => { + try { + await updateTaskStatus(teamName, taskId, 'pending'); + } catch { + /* */ + } + })(); + }); + const onMoveBackToDoneTask = taskAction((taskId) => { + void (async () => { + try { + await updateKanban(teamName, taskId, { op: 'remove' }); + await updateTaskStatus(teamName, taskId, 'completed'); + } catch { + /* */ + } + })(); + }); + const onDeleteTaskGraph = taskAction((taskId) => handleDeleteTask(taskId)); + + window.addEventListener('graph:start-task', onStartTask); + window.addEventListener('graph:complete-task', onCompleteTask); + window.addEventListener('graph:approve-task', onApproveTask); + window.addEventListener('graph:request-review', onRequestReviewTask); + window.addEventListener('graph:request-changes', onRequestChangesTask); + window.addEventListener('graph:cancel-task', onCancelTask); + window.addEventListener('graph:move-back-to-done', onMoveBackToDoneTask); + window.addEventListener('graph:delete-task', onDeleteTaskGraph); + return () => { + window.removeEventListener('graph:open-task', onOpenTask); + window.removeEventListener('graph:send-message', onSendMsg); + window.removeEventListener('graph:open-profile', onOpenProfile); + window.removeEventListener('graph:create-task', onCreateTask); + window.removeEventListener('graph:start-task', onStartTask); + window.removeEventListener('graph:complete-task', onCompleteTask); + window.removeEventListener('graph:approve-task', onApproveTask); + window.removeEventListener('graph:request-review', onRequestReviewTask); + window.removeEventListener('graph:request-changes', onRequestChangesTask); + window.removeEventListener('graph:cancel-task', onCancelTask); + window.removeEventListener('graph:move-back-to-done', onMoveBackToDoneTask); + window.removeEventListener('graph:delete-task', onDeleteTaskGraph); + }; + }); + + const [sendDialogOpen, setSendDialogOpen] = useState(false); + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [stoppingTeam, setStoppingTeam] = useState(false); + const [trashOpen, setTrashOpen] = useState(false); + const [sendDialogRecipient, setSendDialogRecipient] = useState(undefined); + const [sendDialogDefaultText, setSendDialogDefaultText] = useState(undefined); + const [sendDialogDefaultChip, setSendDialogDefaultChip] = useState( + undefined + ); + const [replyQuote, setReplyQuote] = useState<{ from: string; text: string } | undefined>( + undefined + ); + const [reviewDialogState, setReviewDialogState] = useState<{ + open: boolean; + mode: 'agent' | 'task'; + memberName?: string; + taskId?: string; + initialFilePath?: string; + taskChangeRequestOptions?: TaskChangeRequestOptions; + }>({ open: false, mode: 'task' }); + + // Active teams for conflict warning in LaunchTeamDialog + const [activeTeamsForLaunch, setActiveTeamsForLaunch] = useState< + { teamName: string; displayName: string; projectPath: string }[] + >([]); + const launchDialogOpen = launchDialogState.open; + + // Session loading and filtering state + const [sessions, setSessions] = useState([]); + const [sessionsLoading, setSessionsLoading] = useState(false); + const [sessionsError, setSessionsError] = useState(null); + const [kanbanFilter, setKanbanFilter] = useState({ + sessionId: null, + selectedOwners: new Set(), + columns: new Set(), + }); + const [kanbanSort, setKanbanSort] = useState({ field: 'updatedAt' }); + + const { + data, + members, + loading, + error, + projects, + repositoryGroups, + initTabUIState, + selectTeam, + updateKanban, + updateKanbanColumnOrder, + updateTaskStatus, + updateTaskOwner, + sendTeamMessage, + requestReview, + createTeamTask, + startTaskByUser, + deleteTeam, + openTeamsTab, + closeTab, + sendingMessage, + sendMessageError, + sendMessageWarning, + sendMessageDebugDetails, + lastSendMessageResult, + reviewActionError, + addMember, + restartMember, + skipMemberForLaunch, + removeMember, + updateMemberRole, + launchTeam, + provisioningError, + clearProvisioningError, + isTeamProvisioning, + refreshTeamData, + refreshTeamMessagesHead, + refreshMemberActivityMeta, + syncTeamPendingReplyRefresh, + kanbanFilterQuery, + clearKanbanFilter, + softDeleteTask, + restoreTask, + fetchDeletedTasks, + deletedTasks, + launchParams, + messagesPanelMode, + messagesPanelWidth, + sidebarLogsHeight, + setMessagesPanelMode, + setMessagesPanelWidth, + setSidebarLogsHeight, + selectReviewFile, + pendingReviewRequest, + setPendingReviewRequest, + } = useStore( + useShallow((s) => ({ + projects: s.projects, + repositoryGroups: s.repositoryGroups, + initTabUIState: s.initTabUIState, + selectTeam: s.selectTeam, + updateKanban: s.updateKanban, + updateKanbanColumnOrder: s.updateKanbanColumnOrder, + updateTaskStatus: s.updateTaskStatus, + updateTaskOwner: s.updateTaskOwner, + sendTeamMessage: s.sendTeamMessage, + requestReview: s.requestReview, + createTeamTask: s.createTeamTask, + startTaskByUser: s.startTaskByUser, + deleteTeam: s.deleteTeam, + openTeamsTab: s.openTeamsTab, + closeTab: s.closeTab, + sendingMessage: s.sendingMessage, + sendMessageError: s.sendMessageError, + sendMessageWarning: s.sendMessageWarning, + sendMessageDebugDetails: s.sendMessageDebugDetails, + lastSendMessageResult: s.lastSendMessageResult, + reviewActionError: s.reviewActionError, + addMember: s.addMember, + restartMember: s.restartMember, + skipMemberForLaunch: s.skipMemberForLaunch, + removeMember: s.removeMember, + updateMemberRole: s.updateMemberRole, + launchTeam: s.launchTeam, + provisioningError: teamName ? (s.provisioningErrorByTeam[teamName] ?? null) : null, + clearProvisioningError: s.clearProvisioningError, + isTeamProvisioning: teamName ? isTeamProvisioningActive(s, teamName) : false, + data: s.selectedTeamName === teamName ? s.selectedTeamData : null, + members: selectResolvedMembersForTeamName(s, teamName), + loading: s.selectedTeamName === teamName ? s.selectedTeamLoading : false, + error: s.selectedTeamName === teamName ? s.selectedTeamError : null, + refreshTeamData: s.refreshTeamData, + refreshTeamMessagesHead: s.refreshTeamMessagesHead, + refreshMemberActivityMeta: s.refreshMemberActivityMeta, + syncTeamPendingReplyRefresh: s.syncTeamPendingReplyRefresh, + kanbanFilterQuery: s.kanbanFilterQuery, + clearKanbanFilter: s.clearKanbanFilter, + softDeleteTask: s.softDeleteTask, + restoreTask: s.restoreTask, + fetchDeletedTasks: s.fetchDeletedTasks, + deletedTasks: s.deletedTasks, + launchParams: teamName ? s.launchParamsByTeam[teamName] : undefined, + messagesPanelMode: s.messagesPanelMode, + messagesPanelWidth: s.messagesPanelWidth, + sidebarLogsHeight: s.sidebarLogsHeight, + setMessagesPanelMode: s.setMessagesPanelMode, + setMessagesPanelWidth: s.setMessagesPanelWidth, + setSidebarLogsHeight: s.setSidebarLogsHeight, + selectReviewFile: s.selectReviewFile, + pendingReviewRequest: s.pendingReviewRequest, + setPendingReviewRequest: s.setPendingReviewRequest, + })) + ); + + const tabId = useTabIdOptional(); + const activeTabId = useStore((s) => s.activeTabId); + const isThisTabActive = tabId ? activeTabId === tabId : false; + const wasInteractiveRef = useRef(false); + + // Messages panel resize + const { isResizing: isMessagesPanelResizing, handleProps: messagesPanelHandleProps } = + useResizablePanel({ + width: messagesPanelWidth, + onWidthChange: setMessagesPanelWidth, + minWidth: 280, + maxWidth: 600, + side: 'left', + }); + const { isResizing: isLogsPanelResizing, handleProps: logsPanelHandleProps } = useResizablePanel({ + height: sidebarLogsHeight, + onHeightChange: setSidebarLogsHeight, + minHeight: 120, + maxHeight: 520, + side: 'top', + }); + + const changeMessagesPanelMode = useCallback( + (mode: TeamMessagesPanelMode) => { + setMessagesPanelMode(mode); + }, + [setMessagesPanelMode] + ); + + useEffect(() => { + if (tabId) { + initTabUIState(tabId); + } + }, [tabId, initTabUIState]); + + useEffect(() => { + setPendingRepliesByMember(getTeamPendingRepliesState(teamName)); + }, [teamName]); + + useEffect(() => { + setTeamPendingRepliesState(teamName, pendingRepliesByMember); + }, [pendingRepliesByMember, teamName]); + + useEffect(() => { + const wasProvisioning = wasProvisioningRef.current; + wasProvisioningRef.current = isTeamProvisioning; + if (!wasProvisioning && isTeamProvisioning) { + provisioningBannerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }, [isTeamProvisioning]); + + const [kanbanSearch, setKanbanSearch] = useState(''); + + // Open editor overlay when a file reveal is requested (e.g. from chip click) + const pendingRevealFile = useStore((s) => s.editorPendingRevealFile); + useEffect(() => { + if (pendingRevealFile && data?.config.projectPath) { + setEditorOpen(true); + } + }, [pendingRevealFile, data?.config.projectPath]); + + useEffect(() => { + if (!teamName) { + return; + } + void selectTeam(teamName); + void fetchDeletedTasks(teamName); + }, [teamName, selectTeam, fetchDeletedTasks]); + + // Recovery: after HMR, all mounted TeamDetailView effects re-run simultaneously. + // With CSS display-toggle (all tabs stay mounted), the last selectTeam() call wins + // and other tabs get stuck with mismatched data (permanent skeleton). + // Re-trigger selectTeam when this tab becomes active and store data is stale. + const storedTeamName = data?.teamName; + useEffect(() => { + if (!isThisTabActive || !teamName || loading) return; + if (storedTeamName != null && storedTeamName !== teamName) { + void selectTeam(teamName); + } + }, [isThisTabActive, teamName, storedTeamName, loading, selectTeam]); + + useEffect(() => { + const isInteractive = isThisTabActive && isPaneFocused; + const justBecameInteractive = isInteractive && !wasInteractiveRef.current; + wasInteractiveRef.current = isInteractive; + if (!justBecameInteractive || !teamName) { + return; + } + + void (async () => { + try { + const headResult = await refreshTeamMessagesHead(teamName); + if (headResult.feedChanged) { + await refreshMemberActivityMeta(teamName); + } + } catch { + // Best-effort refresh on tab focus. + } + })(); + }, [ + isPaneFocused, + isThisTabActive, + refreshMemberActivityMeta, + refreshTeamMessagesHead, + teamName, + ]); + + // Fetch active teams when launch dialog opens (for conflict warning) + useEffect(() => { + if (!launchDialogOpen) return; + let cancelled = false; + const teamsSnapshot = useStore.getState().teams; + void (async () => { + try { + const aliveList = await api.teams.aliveList(); + if (cancelled) return; + const aliveSet = new Set(aliveList); + const refs = teamsSnapshot + .filter((t) => aliveSet.has(t.teamName) && t.projectPath) + .map((t) => ({ + teamName: t.teamName, + displayName: t.displayName, + projectPath: t.projectPath!, + })); + setActiveTeamsForLaunch(refs); + } catch { + // best-effort + } + })(); + return () => { + cancelled = true; + }; + }, [launchDialogOpen]); + + useEffect(() => { + if (kanbanFilterQuery) { + setKanbanSearch(kanbanFilterQuery); + clearKanbanFilter(); + } + }, [kanbanFilterQuery, clearKanbanFilter]); + + // Load sessions for the team's project + const projectId = useMemo( + () => resolveProjectIdByPath(data?.config.projectPath, projects, repositoryGroups), + [projects, repositoryGroups, data?.config.projectPath] + ); + + const leadSessionId = data?.config.leadSessionId ?? null; + const pendingReplyRefreshSourceId = useId(); + const sessionHistoryKey = useMemo( + () => (data?.config.sessionHistory ?? []).join('|'), + [data?.config.sessionHistory] + ); + + // Keep team message state fresh while we are explicitly waiting for a reply. + // This stays enabled even for hidden mounted tabs, because the waiting state + // is renderer-local and should keep its lightweight polling until resolved. + useEffect(() => { + const hasPendingReplies = Object.keys(pendingRepliesByMember).length > 0; + syncTeamPendingReplyRefresh( + teamName, + pendingReplyRefreshSourceId, + Boolean(data?.isAlive) && hasPendingReplies, + TEAM_PENDING_REPLY_REFRESH_DELAY_MS ); - const [createTaskDialog, setCreateTaskDialog] = useState({ + + return () => { + syncTeamPendingReplyRefresh(teamName, pendingReplyRefreshSourceId, false); + }; + }, [ + data?.isAlive, + pendingRepliesByMember, + pendingReplyRefreshSourceId, + syncTeamPendingReplyRefresh, + teamName, + ]); + + useEffect(() => { + if (!projectId) return; + + let cancelled = false; + setSessionsLoading(true); + setSessionsError(null); + + void (async () => { + try { + const result = await api.getSessions(projectId); + if (!cancelled) { + setSessions(result); + } + } catch (e) { + if (!cancelled) { + setSessionsError(e instanceof Error ? e.message : 'Failed to load sessions'); + } + } finally { + if (!cancelled) { + setSessionsLoading(false); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [projectId]); + + // Live git branch tracking for the lead project and member worktrees + const teamProjectPath = data?.config.projectPath?.trim() ?? null; + const leadProjectPath = useMemo(() => { + const explicitLeadPath = members.find((member) => isLeadMember(member))?.cwd?.trim(); + return explicitLeadPath && explicitLeadPath.length > 0 ? explicitLeadPath : teamProjectPath; + }, [members, teamProjectPath]); + const branchSyncPaths = useMemo(() => { + const uniquePaths = new Map(); + const addPath = (candidate: string | null | undefined): void => { + const trimmed = candidate?.trim(); + if (!trimmed) return; + const key = normalizePath(trimmed); + if (!key || uniquePaths.has(key)) return; + uniquePaths.set(key, trimmed); + }; + + addPath(leadProjectPath); + for (const member of members) { + addPath(member.cwd); + } + + return Array.from(uniquePaths.values()); + }, [members, leadProjectPath]); + useBranchSync(branchSyncPaths, { live: true }); + const trackedBranches = useStore( + useShallow((s) => + Object.fromEntries( + branchSyncPaths.map((projectPath) => { + const normalizedPath = normalizePath(projectPath); + return [normalizedPath, s.branchByPath[normalizedPath] ?? null] as const; + }) + ) + ) + ); + const leadBranch = leadProjectPath + ? (trackedBranches[normalizePath(leadProjectPath)] ?? null) + : null; + const membersWithLiveBranches = useMemo(() => { + if (!data) return []; + + return members.map((member) => { + const memberPath = member.cwd?.trim(); + const nextGitBranch = + memberPath && !isLeadMember(member) && leadBranch !== null + ? (() => { + const branch = trackedBranches[normalizePath(memberPath)] ?? null; + return branch && branch !== leadBranch ? branch : undefined; + })() + : undefined; + + if (member.gitBranch === nextGitBranch) { + return member; + } + + const nextMember: ResolvedTeamMember = { ...member }; + if (nextGitBranch) { + nextMember.gitBranch = nextGitBranch; + } else { + delete nextMember.gitBranch; + } + return nextMember; + }); + }, [leadBranch, members, trackedBranches]); + const resolvedMemberColorMap = useMemo( + () => buildMemberColorMap(membersWithLiveBranches), + [membersWithLiveBranches] + ); + + // Filter sessions to team-only using sessionHistory + leadSessionId + const teamSessionIds = useMemo(() => { + const sessionIds = new Set(); + if (data?.config.leadSessionId) { + sessionIds.add(data.config.leadSessionId); + } + if (data?.config.sessionHistory) { + for (const id of data.config.sessionHistory) { + sessionIds.add(id); + } + } + return sessionIds; + }, [data?.config.leadSessionId, data?.config.sessionHistory]); + + const teamSessions = useMemo(() => { + // If no session IDs known (backward compat), show all sessions + if (teamSessionIds.size === 0) return sessions; + return sessions.filter((s) => teamSessionIds.has(s.id)); + }, [sessions, teamSessionIds]); + + // Auto-reset session filter if the selected session is no longer in teamSessions + useEffect(() => { + if ( + kanbanFilter.sessionId !== null && + !teamSessions.some((s) => s.id === kanbanFilter.sessionId) + ) { + setKanbanFilter((prev) => ({ ...prev, sessionId: null })); + } + }, [kanbanFilter.sessionId, teamSessions]); + + // Compute time-window for session filtering + const timeWindow = useMemo(() => { + if (kanbanFilter.sessionId === null) return null; + + const sorted = [...teamSessions].sort((a, b) => a.createdAt - b.createdAt); + const idx = sorted.findIndex((s) => s.id === kanbanFilter.sessionId); + if (idx === -1) return null; + + const start = sorted[idx].createdAt; + const end = idx + 1 < sorted.length ? sorted[idx + 1].createdAt : Infinity; + return { start, end }; + }, [kanbanFilter.sessionId, teamSessions]); + + // Filter tasks by time-window and owner + const filteredTasks = useMemo(() => { + if (!data) return []; + let result = data.tasks; + + // Session time-window filter + if (timeWindow) { + result = result.filter((t) => { + if (!t.createdAt) return true; // legacy tasks always included + const ts = new Date(t.createdAt).getTime(); + return ts >= timeWindow.start && ts < timeWindow.end; + }); + } + + // Owner filter + if (kanbanFilter.selectedOwners.size > 0) { + result = result.filter((t) => + t.owner + ? kanbanFilter.selectedOwners.has(t.owner) + : kanbanFilter.selectedOwners.has(UNASSIGNED_OWNER) + ); + } + + return result; + }, [data, timeWindow, kanbanFilter.selectedOwners]); + + const activeMembers = useStableActiveMembers(membersWithLiveBranches); + + const kanbanDisplayTasks = useMemo(() => { + const query = kanbanSearch.trim(); + if (!query) return filteredTasks; + return filterKanbanTasks(filteredTasks, query); + }, [filteredTasks, kanbanSearch]); + + const activeTeammateCount = useMemo( + () => activeMembers.filter((m) => !isLeadMember(m)).length, + [activeMembers] + ); + const leadProviderId = useMemo(() => { + const activeLeadProviderId = activeMembers.find(isLeadMember)?.providerId; + if (activeLeadProviderId) return activeLeadProviderId; + const configuredLeadProviderId = data?.config.members?.find(isLeadMember)?.providerId; + if (configuredLeadProviderId) return configuredLeadProviderId; + return launchParams?.providerId; + }, [activeMembers, data?.config.members, launchParams?.providerId]); + const shouldShowLeadContextUi = canShowLeadContextUi(leadProviderId); + + const taskMap = useMemo(() => new Map((data?.tasks ?? []).map((t) => [t.id, t])), [data?.tasks]); + const taskMapRef = useRef(taskMap); + taskMapRef.current = taskMap; + + const memberTaskCounts = useMemo(() => buildTaskCountsByOwner(data?.tasks ?? []), [data?.tasks]); + + const openCreateTaskDialog = useCallback( + (subject = '', description = '', owner = '', startImmediately?: boolean): void => { + setCreateTaskDialog({ + open: true, + defaultSubject: subject, + defaultDescription: description, + defaultOwner: owner, + defaultStartImmediately: startImmediately, + }); + }, + [] + ); + + const closeCreateTaskDialog = useCallback((): void => { + setCreateTaskDialog({ open: false, defaultSubject: '', defaultDescription: '', defaultOwner: '', + defaultStartImmediately: undefined, }); - const [creatingTask, setCreatingTask] = useState(false); - const [addMemberDialogOpen, setAddMemberDialogOpen] = useState(false); - const [addingMemberLoading, setAddingMemberLoading] = useState(false); - const [removeMemberConfirm, setRemoveMemberConfirm] = useState(null); - const [updatingRoleLoading, setUpdatingRoleLoading] = useState(false); - const [editDialogOpen, setEditDialogOpen] = useState(false); - const [launchDialogState, setLaunchDialogState] = useState<{ - open: boolean; - mode: TeamLaunchDialogMode; - }>({ - open: false, - mode: 'launch', - }); - const [editorOpen, setEditorOpen] = useState(false); - const [graphOpen, setGraphOpen] = useState(false); - const contentRef = useRef(null); - const [messagesPanelMountPoint, setMessagesPanelMountPoint] = useState( - null - ); - const provisioningBannerRef = useRef(null); - const wasProvisioningRef = useRef(false); - const handleOpenGraphTab = useCallback(() => { - const state = useStore.getState(); - const displayName = state.teamByName[teamName]?.displayName ?? teamName; - state.openTab({ - type: 'graph', - label: `${displayName} Graph`, + }, []); + + const handleCreateTaskFromMessage = useCallback((subject: string, description: string) => { + openCreateTaskDialog(subject, description); + }, []); + + const handleReplyToMessage = useCallback((message: { from: string; text: string }) => { + setSendDialogRecipient(message.from); + setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); + setReplyQuote({ from: message.from, text: stripAgentBlocks(message.text) }); + setSendDialogOpen(true); + }, []); + + const openLaunchDialog = useCallback((mode: TeamLaunchDialogMode) => { + setLaunchDialogState({ open: true, mode }); + }, []); + + const closeLaunchDialog = useCallback(() => { + setLaunchDialogState((prev) => ({ ...prev, open: false })); + }, []); + + const handleRestartTeam = useCallback(() => { + openLaunchDialog('relaunch'); + }, [openLaunchDialog]); + + const handleLaunchDialogSubmit = useCallback( + async (request: TeamLaunchRequest): Promise => { + await launchTeam(request); + }, + [launchTeam] + ); + + const handleRelaunchDialogSubmit = useCallback( + async ( + request: TeamLaunchRequest, + nextMembers: TeamCreateRequest['members'] + ): Promise => { + await executeTeamRelaunch({ teamName, + isTeamAlive: data?.isAlive === true, + request, + members: nextMembers, + stopTeam: (nextTeamName) => api.teams.stop(nextTeamName), + replaceMembers: (nextTeamName, nextRequest) => + api.teams.replaceMembers(nextTeamName, nextRequest), + launchTeam, }); - }, [teamName]); - const visualizeButtonStyle = useMemo( - () => - isLight - ? { - background: - 'linear-gradient(135deg, rgba(59,130,246,0.14) 0%, rgba(34,197,94,0.16) 100%)', - borderColor: 'rgba(59,130,246,0.30)', - color: '#0f172a', - boxShadow: '0 10px 24px rgba(59,130,246,0.12)', - } - : { - background: - 'linear-gradient(135deg, rgba(56,189,248,0.18) 0%, rgba(16,185,129,0.16) 100%)', - borderColor: 'rgba(56,189,248,0.34)', - color: 'rgba(236,253,255,0.96)', - boxShadow: '0 12px 28px rgba(8,145,178,0.22)', - }, - [isLight] - ); + }, + [data?.isAlive, launchTeam, teamName] + ); - // Set inert on background content when editor/graph overlay is open (a11y focus trap) - useEffect(() => { - const el = contentRef.current; - if (!el) return; - if (editorOpen || graphOpen) { - el.setAttribute('inert', ''); - } else { - el.removeAttribute('inert'); - } - }, [editorOpen, graphOpen]); + const handleChangeLeadRuntime = useCallback(() => { + setEditDialogOpen(false); + openLaunchDialog(data?.isAlive && !isTeamProvisioning ? 'relaunch' : 'launch'); + }, [data?.isAlive, isTeamProvisioning, openLaunchDialog]); - // Listen for Cmd+Shift+G keyboard shortcut — opens graph tab - useEffect(() => { - const handler = (e: Event) => { - const detail = (e as CustomEvent).detail; - if (detail?.teamName === teamName) { - handleOpenGraphTab(); - } - }; - window.addEventListener('toggle-team-graph', handler); - return () => window.removeEventListener('toggle-team-graph', handler); - }, [handleOpenGraphTab, teamName]); + const handleRestartMember = useCallback( + async (memberName: string): Promise => { + await restartMember(teamName, memberName); + }, + [restartMember, teamName] + ); - // Listen for graph tab actions (open task, send message) - useEffect(() => { - const onOpenTask = (e: Event) => { - const { teamName: tn, taskId } = (e as CustomEvent).detail ?? {}; - if (tn !== teamName || !data) return; - const task = data.tasks.find((t: { id: string }) => t.id === taskId); - if (task) setSelectedTask(task); - }; - const onSendMsg = (e: Event) => { - const { teamName: tn, memberName } = (e as CustomEvent).detail ?? {}; - if (tn !== teamName) return; - setSendDialogRecipient(memberName); - setSendDialogDefaultText(undefined); - setSendDialogDefaultChip(undefined); + const handleSkipMemberForLaunch = useCallback( + async (memberName: string): Promise => { + await skipMemberForLaunch(teamName, memberName); + }, + [skipMemberForLaunch, teamName] + ); + + const handleSelectMember = useCallback((member: ResolvedTeamMember) => { + setSelectedMember(member); + setSelectedMemberView(null); + }, []); + + const closeSelectedMemberDialog = useCallback(() => { + setSelectedMember(null); + setSelectedMemberView(null); + }, []); + + const handleSendMessageToMember = useCallback((member: ResolvedTeamMember) => { + setSendDialogRecipient(member.name); + setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); + setReplyQuote(undefined); + setSendDialogOpen(true); + }, []); + + const handleAssignTaskToMember = useCallback( + (member: ResolvedTeamMember) => { + openCreateTaskDialog('', '', member.name); + }, + [openCreateTaskDialog] + ); + + const handleOpenTaskById = useCallback((taskId: string) => { + const task = taskMapRef.current.get(taskId); + if (task) { + setSelectedTask(task); + } + }, []); + + const handleOpenTask = useCallback((task: TeamTaskWithKanban) => { + setSelectedTask(task); + }, []); + + const handleTaskIdClick = useCallback( + (taskId: string) => { + const task = + taskMap.get(taskId) ?? data?.tasks.find((candidate) => candidate.displayId === taskId); + if (task) setSelectedTask(task); + }, + [taskMap, data?.tasks] + ); + + const handleEditorAction = useCallback( + (action: EditorSelectionAction) => { + const chip = createChipFromSelection(action, []) ?? undefined; + if (action.type === 'sendMessage') { + setSendDialogDefaultText(chip ? undefined : action.formattedContext); + setSendDialogDefaultChip(chip); + setSendDialogRecipient(undefined); + setReplyQuote(undefined); setSendDialogOpen(true); - }; - const onOpenProfile = (e: Event) => { - const { - teamName: tn, - memberName, - initialTab, - initialActivityFilter, - } = (e as CustomEvent).detail ?? {}; - if (tn !== teamName || !data) return; - const member = members.find((m: { name: string }) => m.name === memberName); - if (member) { - setSelectedMember(member); - setSelectedMemberView({ - initialTab, - initialActivityFilter, + } else if (action.type === 'createTask') { + if (chip) { + setCreateTaskDialog({ + open: true, + defaultSubject: '', + defaultDescription: '', + defaultOwner: '', + defaultStartImmediately: undefined, + defaultChip: chip, }); - } - }; - const onCreateTask = (e: Event) => { - const { teamName: tn, owner } = (e as CustomEvent).detail ?? {}; - if (tn !== teamName) return; - openCreateTaskDialog('', '', owner ?? ''); - }; - window.addEventListener('graph:open-task', onOpenTask); - window.addEventListener('graph:send-message', onSendMsg); - window.addEventListener('graph:open-profile', onOpenProfile); - window.addEventListener('graph:create-task', onCreateTask); - - // Task action events from graph - const taskAction = (handler: (taskId: string) => void) => (e: Event) => { - const { teamName: tn, taskId } = (e as CustomEvent).detail ?? {}; - if (tn !== teamName || !taskId) return; - handler(taskId); - }; - const onStartTask = taskAction((taskId) => { - void (async () => { - try { - const result = await startTaskByUser(teamName, taskId); - if (data?.isAlive) { - const task = data.tasks.find((t: { id: string }) => t.id === taskId); - try { - if (result.notifiedOwner && task?.owner) { - await api.teams.processSend( - teamName, - `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has started. Please begin working on it.` - ); - } - } catch { - /* best-effort */ - } - } - } catch { - /* error via store */ - } - })(); - }); - const onCompleteTask = taskAction((taskId) => { - void (async () => { - try { - await updateTaskStatus(teamName, taskId, 'completed'); - } catch { - /* */ - } - })(); - }); - const onApproveTask = taskAction((taskId) => { - void (async () => { - try { - await updateKanban(teamName, taskId, { op: 'set_column', column: 'approved' }); - } catch { - /* */ - } - })(); - }); - const onRequestReviewTask = taskAction((taskId) => { - void (async () => { - try { - await requestReview(teamName, taskId); - } catch { - /* */ - } - })(); - }); - const onRequestChangesTask = taskAction((taskId) => { - setRequestChangesTaskId(taskId); - }); - const onCancelTask = taskAction((taskId) => { - void (async () => { - try { - await updateTaskStatus(teamName, taskId, 'pending'); - } catch { - /* */ - } - })(); - }); - const onMoveBackToDoneTask = taskAction((taskId) => { - void (async () => { - try { - await updateKanban(teamName, taskId, { op: 'remove' }); - await updateTaskStatus(teamName, taskId, 'completed'); - } catch { - /* */ - } - })(); - }); - const onDeleteTaskGraph = taskAction((taskId) => handleDeleteTask(taskId)); - - window.addEventListener('graph:start-task', onStartTask); - window.addEventListener('graph:complete-task', onCompleteTask); - window.addEventListener('graph:approve-task', onApproveTask); - window.addEventListener('graph:request-review', onRequestReviewTask); - window.addEventListener('graph:request-changes', onRequestChangesTask); - window.addEventListener('graph:cancel-task', onCancelTask); - window.addEventListener('graph:move-back-to-done', onMoveBackToDoneTask); - window.addEventListener('graph:delete-task', onDeleteTaskGraph); - return () => { - window.removeEventListener('graph:open-task', onOpenTask); - window.removeEventListener('graph:send-message', onSendMsg); - window.removeEventListener('graph:open-profile', onOpenProfile); - window.removeEventListener('graph:create-task', onCreateTask); - window.removeEventListener('graph:start-task', onStartTask); - window.removeEventListener('graph:complete-task', onCompleteTask); - window.removeEventListener('graph:approve-task', onApproveTask); - window.removeEventListener('graph:request-review', onRequestReviewTask); - window.removeEventListener('graph:request-changes', onRequestChangesTask); - window.removeEventListener('graph:cancel-task', onCancelTask); - window.removeEventListener('graph:move-back-to-done', onMoveBackToDoneTask); - window.removeEventListener('graph:delete-task', onDeleteTaskGraph); - }; - }); - - const [sendDialogOpen, setSendDialogOpen] = useState(false); - const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); - const [stoppingTeam, setStoppingTeam] = useState(false); - const [trashOpen, setTrashOpen] = useState(false); - const [sendDialogRecipient, setSendDialogRecipient] = useState(undefined); - const [sendDialogDefaultText, setSendDialogDefaultText] = useState( - undefined - ); - const [sendDialogDefaultChip, setSendDialogDefaultChip] = useState( - undefined - ); - const [replyQuote, setReplyQuote] = useState<{ from: string; text: string } | undefined>( - undefined - ); - const [reviewDialogState, setReviewDialogState] = useState<{ - open: boolean; - mode: 'agent' | 'task'; - memberName?: string; - taskId?: string; - initialFilePath?: string; - taskChangeRequestOptions?: TaskChangeRequestOptions; - }>({ open: false, mode: 'task' }); - - // Active teams for conflict warning in LaunchTeamDialog - const [activeTeamsForLaunch, setActiveTeamsForLaunch] = useState< - { teamName: string; displayName: string; projectPath: string }[] - >([]); - const launchDialogOpen = launchDialogState.open; - - // Session loading and filtering state - const [sessions, setSessions] = useState([]); - const [sessionsLoading, setSessionsLoading] = useState(false); - const [sessionsError, setSessionsError] = useState(null); - const [kanbanFilter, setKanbanFilter] = useState({ - sessionId: null, - selectedOwners: new Set(), - columns: new Set(), - }); - const [kanbanSort, setKanbanSort] = useState({ field: 'updatedAt' }); - - const { - data, - members, - loading, - error, - projects, - repositoryGroups, - initTabUIState, - selectTeam, - updateKanban, - updateKanbanColumnOrder, - updateTaskStatus, - updateTaskOwner, - sendTeamMessage, - requestReview, - createTeamTask, - startTaskByUser, - deleteTeam, - openTeamsTab, - closeTab, - sendingMessage, - sendMessageError, - sendMessageWarning, - sendMessageDebugDetails, - lastSendMessageResult, - reviewActionError, - addMember, - restartMember, - skipMemberForLaunch, - removeMember, - updateMemberRole, - launchTeam, - provisioningError, - clearProvisioningError, - isTeamProvisioning, - refreshTeamData, - refreshTeamMessagesHead, - refreshMemberActivityMeta, - syncTeamPendingReplyRefresh, - kanbanFilterQuery, - clearKanbanFilter, - softDeleteTask, - restoreTask, - fetchDeletedTasks, - deletedTasks, - launchParams, - messagesPanelMode, - messagesPanelWidth, - sidebarLogsHeight, - setMessagesPanelMode, - setMessagesPanelWidth, - setSidebarLogsHeight, - selectReviewFile, - pendingReviewRequest, - setPendingReviewRequest, - } = useStore( - useShallow((s) => ({ - projects: s.projects, - repositoryGroups: s.repositoryGroups, - initTabUIState: s.initTabUIState, - selectTeam: s.selectTeam, - updateKanban: s.updateKanban, - updateKanbanColumnOrder: s.updateKanbanColumnOrder, - updateTaskStatus: s.updateTaskStatus, - updateTaskOwner: s.updateTaskOwner, - sendTeamMessage: s.sendTeamMessage, - requestReview: s.requestReview, - createTeamTask: s.createTeamTask, - startTaskByUser: s.startTaskByUser, - deleteTeam: s.deleteTeam, - openTeamsTab: s.openTeamsTab, - closeTab: s.closeTab, - sendingMessage: s.sendingMessage, - sendMessageError: s.sendMessageError, - sendMessageWarning: s.sendMessageWarning, - sendMessageDebugDetails: s.sendMessageDebugDetails, - lastSendMessageResult: s.lastSendMessageResult, - reviewActionError: s.reviewActionError, - addMember: s.addMember, - restartMember: s.restartMember, - skipMemberForLaunch: s.skipMemberForLaunch, - removeMember: s.removeMember, - updateMemberRole: s.updateMemberRole, - launchTeam: s.launchTeam, - provisioningError: teamName ? (s.provisioningErrorByTeam[teamName] ?? null) : null, - clearProvisioningError: s.clearProvisioningError, - isTeamProvisioning: teamName ? isTeamProvisioningActive(s, teamName) : false, - data: s.selectedTeamName === teamName ? s.selectedTeamData : null, - members: selectResolvedMembersForTeamName(s, teamName), - loading: s.selectedTeamName === teamName ? s.selectedTeamLoading : false, - error: s.selectedTeamName === teamName ? s.selectedTeamError : null, - refreshTeamData: s.refreshTeamData, - refreshTeamMessagesHead: s.refreshTeamMessagesHead, - refreshMemberActivityMeta: s.refreshMemberActivityMeta, - syncTeamPendingReplyRefresh: s.syncTeamPendingReplyRefresh, - kanbanFilterQuery: s.kanbanFilterQuery, - clearKanbanFilter: s.clearKanbanFilter, - softDeleteTask: s.softDeleteTask, - restoreTask: s.restoreTask, - fetchDeletedTasks: s.fetchDeletedTasks, - deletedTasks: s.deletedTasks, - launchParams: teamName ? s.launchParamsByTeam[teamName] : undefined, - messagesPanelMode: s.messagesPanelMode, - messagesPanelWidth: s.messagesPanelWidth, - sidebarLogsHeight: s.sidebarLogsHeight, - setMessagesPanelMode: s.setMessagesPanelMode, - setMessagesPanelWidth: s.setMessagesPanelWidth, - setSidebarLogsHeight: s.setSidebarLogsHeight, - selectReviewFile: s.selectReviewFile, - pendingReviewRequest: s.pendingReviewRequest, - setPendingReviewRequest: s.setPendingReviewRequest, - })) - ); - - const tabId = useTabIdOptional(); - const activeTabId = useStore((s) => s.activeTabId); - const isThisTabActive = tabId ? activeTabId === tabId : false; - const wasInteractiveRef = useRef(false); - - // Messages panel resize - const { isResizing: isMessagesPanelResizing, handleProps: messagesPanelHandleProps } = - useResizablePanel({ - width: messagesPanelWidth, - onWidthChange: setMessagesPanelWidth, - minWidth: 280, - maxWidth: 600, - side: 'left', - }); - const { isResizing: isLogsPanelResizing, handleProps: logsPanelHandleProps } = - useResizablePanel({ - height: sidebarLogsHeight, - onHeightChange: setSidebarLogsHeight, - minHeight: 120, - maxHeight: 520, - side: 'top', - }); - - const changeMessagesPanelMode = useCallback( - (mode: TeamMessagesPanelMode) => { - setMessagesPanelMode(mode); - }, - [setMessagesPanelMode] - ); - - useEffect(() => { - if (tabId) { - initTabUIState(tabId); - } - }, [tabId, initTabUIState]); - - useEffect(() => { - setPendingRepliesByMember(getTeamPendingRepliesState(teamName)); - }, [teamName]); - - useEffect(() => { - setTeamPendingRepliesState(teamName, pendingRepliesByMember); - }, [pendingRepliesByMember, teamName]); - - useEffect(() => { - const wasProvisioning = wasProvisioningRef.current; - wasProvisioningRef.current = isTeamProvisioning; - if (!wasProvisioning && isTeamProvisioning) { - provisioningBannerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); - } - }, [isTeamProvisioning]); - - const [kanbanSearch, setKanbanSearch] = useState(''); - - // Open editor overlay when a file reveal is requested (e.g. from chip click) - const pendingRevealFile = useStore((s) => s.editorPendingRevealFile); - useEffect(() => { - if (pendingRevealFile && data?.config.projectPath) { - setEditorOpen(true); - } - }, [pendingRevealFile, data?.config.projectPath]); - - useEffect(() => { - if (!teamName) { - return; - } - void selectTeam(teamName); - void fetchDeletedTasks(teamName); - }, [teamName, selectTeam, fetchDeletedTasks]); - - // Recovery: after HMR, all mounted TeamDetailView effects re-run simultaneously. - // With CSS display-toggle (all tabs stay mounted), the last selectTeam() call wins - // and other tabs get stuck with mismatched data (permanent skeleton). - // Re-trigger selectTeam when this tab becomes active and store data is stale. - const storedTeamName = data?.teamName; - useEffect(() => { - if (!isThisTabActive || !teamName || loading) return; - if (storedTeamName != null && storedTeamName !== teamName) { - void selectTeam(teamName); - } - }, [isThisTabActive, teamName, storedTeamName, loading, selectTeam]); - - useEffect(() => { - const isInteractive = isThisTabActive && isPaneFocused; - const justBecameInteractive = isInteractive && !wasInteractiveRef.current; - wasInteractiveRef.current = isInteractive; - if (!justBecameInteractive || !teamName) { - return; - } - - void (async () => { - try { - const headResult = await refreshTeamMessagesHead(teamName); - if (headResult.feedChanged) { - await refreshMemberActivityMeta(teamName); - } - } catch { - // Best-effort refresh on tab focus. - } - })(); - }, [ - isPaneFocused, - isThisTabActive, - refreshMemberActivityMeta, - refreshTeamMessagesHead, - teamName, - ]); - - // Fetch active teams when launch dialog opens (for conflict warning) - useEffect(() => { - if (!launchDialogOpen) return; - let cancelled = false; - const teamsSnapshot = useStore.getState().teams; - void (async () => { - try { - const aliveList = await api.teams.aliveList(); - if (cancelled) return; - const aliveSet = new Set(aliveList); - const refs = teamsSnapshot - .filter((t) => aliveSet.has(t.teamName) && t.projectPath) - .map((t) => ({ - teamName: t.teamName, - displayName: t.displayName, - projectPath: t.projectPath!, - })); - setActiveTeamsForLaunch(refs); - } catch { - // best-effort - } - })(); - return () => { - cancelled = true; - }; - }, [launchDialogOpen]); - - useEffect(() => { - if (kanbanFilterQuery) { - setKanbanSearch(kanbanFilterQuery); - clearKanbanFilter(); - } - }, [kanbanFilterQuery, clearKanbanFilter]); - - // Load sessions for the team's project - const projectId = useMemo( - () => resolveProjectIdByPath(data?.config.projectPath, projects, repositoryGroups), - [projects, repositoryGroups, data?.config.projectPath] - ); - - const leadSessionId = data?.config.leadSessionId ?? null; - const pendingReplyRefreshSourceId = useId(); - const sessionHistoryKey = useMemo( - () => (data?.config.sessionHistory ?? []).join('|'), - [data?.config.sessionHistory] - ); - - // Keep team message state fresh while we are explicitly waiting for a reply. - // This stays enabled even for hidden mounted tabs, because the waiting state - // is renderer-local and should keep its lightweight polling until resolved. - useEffect(() => { - const hasPendingReplies = Object.keys(pendingRepliesByMember).length > 0; - syncTeamPendingReplyRefresh( - teamName, - pendingReplyRefreshSourceId, - Boolean(data?.isAlive) && hasPendingReplies, - TEAM_PENDING_REPLY_REFRESH_DELAY_MS - ); - - return () => { - syncTeamPendingReplyRefresh(teamName, pendingReplyRefreshSourceId, false); - }; - }, [ - data?.isAlive, - pendingRepliesByMember, - pendingReplyRefreshSourceId, - syncTeamPendingReplyRefresh, - teamName, - ]); - - useEffect(() => { - if (!projectId) return; - - let cancelled = false; - setSessionsLoading(true); - setSessionsError(null); - - void (async () => { - try { - const result = await api.getSessions(projectId); - if (!cancelled) { - setSessions(result); - } - } catch (e) { - if (!cancelled) { - setSessionsError(e instanceof Error ? e.message : 'Failed to load sessions'); - } - } finally { - if (!cancelled) { - setSessionsLoading(false); - } - } - })(); - - return () => { - cancelled = true; - }; - }, [projectId]); - - // Live git branch tracking for the lead project and member worktrees - const teamProjectPath = data?.config.projectPath?.trim() ?? null; - const leadProjectPath = useMemo(() => { - const explicitLeadPath = members.find((member) => isLeadMember(member))?.cwd?.trim(); - return explicitLeadPath && explicitLeadPath.length > 0 ? explicitLeadPath : teamProjectPath; - }, [members, teamProjectPath]); - const branchSyncPaths = useMemo(() => { - const uniquePaths = new Map(); - const addPath = (candidate: string | null | undefined): void => { - const trimmed = candidate?.trim(); - if (!trimmed) return; - const key = normalizePath(trimmed); - if (!key || uniquePaths.has(key)) return; - uniquePaths.set(key, trimmed); - }; - - addPath(leadProjectPath); - for (const member of members) { - addPath(member.cwd); - } - - return Array.from(uniquePaths.values()); - }, [members, leadProjectPath]); - useBranchSync(branchSyncPaths, { live: true }); - const trackedBranches = useStore( - useShallow((s) => - Object.fromEntries( - branchSyncPaths.map((projectPath) => { - const normalizedPath = normalizePath(projectPath); - return [normalizedPath, s.branchByPath[normalizedPath] ?? null] as const; - }) - ) - ) - ); - const leadBranch = leadProjectPath - ? (trackedBranches[normalizePath(leadProjectPath)] ?? null) - : null; - const membersWithLiveBranches = useMemo(() => { - if (!data) return []; - - return members.map((member) => { - const memberPath = member.cwd?.trim(); - const nextGitBranch = - memberPath && !isLeadMember(member) && leadBranch !== null - ? (() => { - const branch = trackedBranches[normalizePath(memberPath)] ?? null; - return branch && branch !== leadBranch ? branch : undefined; - })() - : undefined; - - if (member.gitBranch === nextGitBranch) { - return member; - } - - const nextMember: ResolvedTeamMember = { ...member }; - if (nextGitBranch) { - nextMember.gitBranch = nextGitBranch; } else { - delete nextMember.gitBranch; - } - return nextMember; - }); - }, [leadBranch, members, trackedBranches]); - const resolvedMemberColorMap = useMemo( - () => buildMemberColorMap(membersWithLiveBranches), - [membersWithLiveBranches] - ); - - // Filter sessions to team-only using sessionHistory + leadSessionId - const teamSessionIds = useMemo(() => { - const sessionIds = new Set(); - if (data?.config.leadSessionId) { - sessionIds.add(data.config.leadSessionId); - } - if (data?.config.sessionHistory) { - for (const id of data.config.sessionHistory) { - sessionIds.add(id); + openCreateTaskDialog('', action.formattedContext); } } - return sessionIds; - }, [data?.config.leadSessionId, data?.config.sessionHistory]); + }, - const teamSessions = useMemo(() => { - // If no session IDs known (backward compat), show all sessions - if (teamSessionIds.size === 0) return sessions; - return sessions.filter((s) => teamSessionIds.has(s.id)); - }, [sessions, teamSessionIds]); + [] + ); - // Auto-reset session filter if the selected session is no longer in teamSessions - useEffect(() => { - if ( - kanbanFilter.sessionId !== null && - !teamSessions.some((s) => s.id === kanbanFilter.sessionId) - ) { - setKanbanFilter((prev) => ({ ...prev, sessionId: null })); - } - }, [kanbanFilter.sessionId, teamSessions]); + const handleStopTeam = useCallback(async (): Promise => { + setStoppingTeam(true); + try { + await api.teams.stop(teamName); + // Backend sends 'disconnected' progress which triggers store refresh, + // but refresh here too as a safety net (e.g. if progress event is missed). + await refreshTeamData(teamName); + } catch (err) { + console.error('Failed to stop team:', err); + } finally { + setStoppingTeam(false); + } + }, [teamName, refreshTeamData]); - // Compute time-window for session filtering - const timeWindow = useMemo(() => { - if (kanbanFilter.sessionId === null) return null; + // Pick up pending review request from GlobalTaskDetailDialog + useEffect(() => { + if (!pendingReviewRequest) return; + setReviewDialogState({ + open: true, + mode: 'task', + taskId: pendingReviewRequest.taskId, + initialFilePath: pendingReviewRequest.filePath, + taskChangeRequestOptions: pendingReviewRequest.requestOptions, + }); + if (pendingReviewRequest.filePath) { + selectReviewFile(pendingReviewRequest.filePath); + } + setPendingReviewRequest(null); + }, [pendingReviewRequest, selectReviewFile, setPendingReviewRequest]); - const sorted = [...teamSessions].sort((a, b) => a.createdAt - b.createdAt); - const idx = sorted.findIndex((s) => s.id === kanbanFilter.sessionId); - if (idx === -1) return null; - - const start = sorted[idx].createdAt; - const end = idx + 1 < sorted.length ? sorted[idx + 1].createdAt : Infinity; - return { start, end }; - }, [kanbanFilter.sessionId, teamSessions]); - - // Filter tasks by time-window and owner - const filteredTasks = useMemo(() => { - if (!data) return []; - let result = data.tasks; - - // Session time-window filter - if (timeWindow) { - result = result.filter((t) => { - if (!t.createdAt) return true; // legacy tasks always included - const ts = new Date(t.createdAt).getTime(); - return ts >= timeWindow.start && ts < timeWindow.end; - }); - } - - // Owner filter - if (kanbanFilter.selectedOwners.size > 0) { - result = result.filter((t) => - t.owner - ? kanbanFilter.selectedOwners.has(t.owner) - : kanbanFilter.selectedOwners.has(UNASSIGNED_OWNER) - ); - } - - return result; - }, [data, timeWindow, kanbanFilter.selectedOwners]); - - const activeMembers = useStableActiveMembers(membersWithLiveBranches); - - const kanbanDisplayTasks = useMemo(() => { - const query = kanbanSearch.trim(); - if (!query) return filteredTasks; - return filterKanbanTasks(filteredTasks, query); - }, [filteredTasks, kanbanSearch]); - - const activeTeammateCount = useMemo( - () => activeMembers.filter((m) => !isLeadMember(m)).length, - [activeMembers] - ); - const leadProviderId = useMemo(() => { - const activeLeadProviderId = activeMembers.find(isLeadMember)?.providerId; - if (activeLeadProviderId) return activeLeadProviderId; - const configuredLeadProviderId = data?.config.members?.find(isLeadMember)?.providerId; - if (configuredLeadProviderId) return configuredLeadProviderId; - return launchParams?.providerId; - }, [activeMembers, data?.config.members, launchParams?.providerId]); - const shouldShowLeadContextUi = canShowLeadContextUi(leadProviderId); - - const taskMap = useMemo( - () => new Map((data?.tasks ?? []).map((t) => [t.id, t])), - [data?.tasks] - ); - const taskMapRef = useRef(taskMap); - taskMapRef.current = taskMap; - - const memberTaskCounts = useMemo( - () => buildTaskCountsByOwner(data?.tasks ?? []), - [data?.tasks] - ); - - const openCreateTaskDialog = useCallback( - (subject = '', description = '', owner = '', startImmediately?: boolean): void => { - setCreateTaskDialog({ - open: true, - defaultSubject: subject, - defaultDescription: description, - defaultOwner: owner, - defaultStartImmediately: startImmediately, - }); - }, - [] - ); - - const closeCreateTaskDialog = useCallback((): void => { - setCreateTaskDialog({ - open: false, - defaultSubject: '', - defaultDescription: '', - defaultOwner: '', - defaultStartImmediately: undefined, - }); - }, []); - - const handleCreateTaskFromMessage = useCallback((subject: string, description: string) => { - openCreateTaskDialog(subject, description); - }, []); - - const handleReplyToMessage = useCallback((message: { from: string; text: string }) => { - setSendDialogRecipient(message.from); - setSendDialogDefaultText(undefined); - setSendDialogDefaultChip(undefined); - setReplyQuote({ from: message.from, text: stripAgentBlocks(message.text) }); - setSendDialogOpen(true); - }, []); - - const openLaunchDialog = useCallback((mode: TeamLaunchDialogMode) => { - setLaunchDialogState({ open: true, mode }); - }, []); - - const closeLaunchDialog = useCallback(() => { - setLaunchDialogState((prev) => ({ ...prev, open: false })); - }, []); - - const handleRestartTeam = useCallback(() => { - openLaunchDialog('relaunch'); - }, [openLaunchDialog]); - - const handleLaunchDialogSubmit = useCallback( - async (request: TeamLaunchRequest): Promise => { - await launchTeam(request); - }, - [launchTeam] - ); - - const handleRelaunchDialogSubmit = useCallback( - async ( - request: TeamLaunchRequest, - nextMembers: TeamCreateRequest['members'] - ): Promise => { - await executeTeamRelaunch({ - teamName, - isTeamAlive: data?.isAlive === true, - request, - members: nextMembers, - stopTeam: (nextTeamName) => api.teams.stop(nextTeamName), - replaceMembers: (nextTeamName, nextRequest) => - api.teams.replaceMembers(nextTeamName, nextRequest), - launchTeam, - }); - }, - [data?.isAlive, launchTeam, teamName] - ); - - const handleChangeLeadRuntime = useCallback(() => { - setEditDialogOpen(false); - openLaunchDialog(data?.isAlive && !isTeamProvisioning ? 'relaunch' : 'launch'); - }, [data?.isAlive, isTeamProvisioning, openLaunchDialog]); - - const handleRestartMember = useCallback( - async (memberName: string): Promise => { - await restartMember(teamName, memberName); - }, - [restartMember, teamName] - ); - - const handleSkipMemberForLaunch = useCallback( - async (memberName: string): Promise => { - await skipMemberForLaunch(teamName, memberName); - }, - [skipMemberForLaunch, teamName] - ); - - const handleSelectMember = useCallback((member: ResolvedTeamMember) => { + // Pick up pending member profile request from MemberHoverCard + const pendingMemberProfile = useStore((s) => s.pendingMemberProfile); + useEffect(() => { + if (!pendingMemberProfile || !data) return; + const member = membersWithLiveBranches.find((m) => m.name === pendingMemberProfile); + if (member) { setSelectedMember(member); setSelectedMemberView(null); - }, []); + } + useStore.getState().closeMemberProfile(); + }, [pendingMemberProfile, membersWithLiveBranches]); - const closeSelectedMemberDialog = useCallback(() => { - setSelectedMember(null); - setSelectedMemberView(null); - }, []); - - const handleSendMessageToMember = useCallback((member: ResolvedTeamMember) => { - setSendDialogRecipient(member.name); - setSendDialogDefaultText(undefined); - setSendDialogDefaultChip(undefined); - setReplyQuote(undefined); - setSendDialogOpen(true); - }, []); - - const handleAssignTaskToMember = useCallback( - (member: ResolvedTeamMember) => { - openCreateTaskDialog('', '', member.name); - }, - [openCreateTaskDialog] - ); - - const handleOpenTaskById = useCallback((taskId: string) => { - const task = taskMapRef.current.get(taskId); - if (task) { - setSelectedTask(task); - } - }, []); - - const handleOpenTask = useCallback((task: TeamTaskWithKanban) => { - setSelectedTask(task); - }, []); - - const handleTaskIdClick = useCallback( - (taskId: string) => { - const task = - taskMap.get(taskId) ?? data?.tasks.find((candidate) => candidate.displayId === taskId); - if (task) setSelectedTask(task); - }, - [taskMap, data?.tasks] - ); - - const handleEditorAction = useCallback( - (action: EditorSelectionAction) => { - const chip = createChipFromSelection(action, []) ?? undefined; - if (action.type === 'sendMessage') { - setSendDialogDefaultText(chip ? undefined : action.formattedContext); - setSendDialogDefaultChip(chip); - setSendDialogRecipient(undefined); - setReplyQuote(undefined); - setSendDialogOpen(true); - } else if (action.type === 'createTask') { - if (chip) { - setCreateTaskDialog({ - open: true, - defaultSubject: '', - defaultDescription: '', - defaultOwner: '', - defaultStartImmediately: undefined, - defaultChip: chip, - }); - } else { - openCreateTaskDialog('', action.formattedContext); + const handleDeleteTask = useCallback( + (taskId: string) => { + void (async () => { + const confirmed = await confirm({ + title: 'Delete task', + message: `Move task #${deriveTaskDisplayId(taskId)} to trash?`, + confirmLabel: 'Delete', + cancelLabel: 'Cancel', + variant: 'danger', + }); + if (confirmed) { + try { + await softDeleteTask(teamName, taskId); + } catch { + // error via store } } - }, + })(); + }, + [teamName, softDeleteTask] + ); - [] - ); - - const handleStopTeam = useCallback(async (): Promise => { - setStoppingTeam(true); - try { - await api.teams.stop(teamName); - // Backend sends 'disconnected' progress which triggers store refresh, - // but refresh here too as a safety net (e.g. if progress event is missed). - await refreshTeamData(teamName); - } catch (err) { - console.error('Failed to stop team:', err); - } finally { - setStoppingTeam(false); - } - }, [teamName, refreshTeamData]); - - // Pick up pending review request from GlobalTaskDetailDialog - useEffect(() => { - if (!pendingReviewRequest) return; + const handleViewChanges = useCallback( + (taskId: string) => { + const task = taskMap.get(taskId); setReviewDialogState({ open: true, mode: 'task', - taskId: pendingReviewRequest.taskId, - initialFilePath: pendingReviewRequest.filePath, - taskChangeRequestOptions: pendingReviewRequest.requestOptions, + taskId, + taskChangeRequestOptions: task ? buildTaskChangeRequestOptions(task) : {}, }); - if (pendingReviewRequest.filePath) { - selectReviewFile(pendingReviewRequest.filePath); + }, + [taskMap] + ); + + const handleViewChangesForFile = useCallback( + (taskId: string, filePath?: string) => { + const task = taskMap.get(taskId); + setReviewDialogState({ + open: true, + mode: 'task', + taskId, + initialFilePath: filePath, + taskChangeRequestOptions: task ? buildTaskChangeRequestOptions(task) : {}, + }); + if (filePath) { + selectReviewFile(filePath); } - setPendingReviewRequest(null); - }, [pendingReviewRequest, selectReviewFile, setPendingReviewRequest]); + }, + [selectReviewFile, taskMap] + ); - // Pick up pending member profile request from MemberHoverCard - const pendingMemberProfile = useStore((s) => s.pendingMemberProfile); - useEffect(() => { - if (!pendingMemberProfile || !data) return; - const member = membersWithLiveBranches.find((m) => m.name === pendingMemberProfile); - if (member) { - setSelectedMember(member); - setSelectedMemberView(null); + const handleDeleteTeam = useCallback((): void => { + setDeleteConfirmOpen(true); + }, []); + + const confirmDeleteTeam = useCallback((): void => { + setDeleteConfirmOpen(false); + void (async () => { + try { + await deleteTeam(teamName); + if (tabId) closeTab(tabId); + openTeamsTab(); + } catch { + // error is shown via store } - useStore.getState().closeMemberProfile(); - }, [pendingMemberProfile, membersWithLiveBranches]); + })(); + }, [teamName, deleteTeam, openTeamsTab, closeTab, tabId]); - const handleDeleteTask = useCallback( - (taskId: string) => { - void (async () => { - const confirmed = await confirm({ - title: 'Delete task', - message: `Move task #${deriveTaskDisplayId(taskId)} to trash?`, - confirmLabel: 'Delete', - cancelLabel: 'Cancel', - variant: 'danger', - }); - if (confirmed) { - try { - await softDeleteTask(teamName, taskId); - } catch { - // error via store - } - } - })(); - }, - [teamName, softDeleteTask] - ); - - const handleViewChanges = useCallback( - (taskId: string) => { - const task = taskMap.get(taskId); - setReviewDialogState({ - open: true, - mode: 'task', - taskId, - taskChangeRequestOptions: task ? buildTaskChangeRequestOptions(task) : {}, + const handleCreateTask = ( + subject: string, + description: string, + owner?: string, + blockedBy?: string[], + related?: string[], + prompt?: string, + startImmediately?: boolean, + descriptionTaskRefs?: TaskRef[], + promptTaskRefs?: TaskRef[] + ): void => { + setCreatingTask(true); + void (async () => { + try { + await createTeamTask(teamName, { + subject, + description: description || undefined, + owner, + blockedBy, + related, + prompt, + descriptionTaskRefs, + promptTaskRefs, + startImmediately, }); - }, - [taskMap] - ); - const handleViewChangesForFile = useCallback( - (taskId: string, filePath?: string) => { - const task = taskMap.get(taskId); - setReviewDialogState({ - open: true, - mode: 'task', - taskId, - initialFilePath: filePath, - taskChangeRequestOptions: task ? buildTaskChangeRequestOptions(task) : {}, - }); - if (filePath) { - selectReviewFile(filePath); - } - }, - [selectReviewFile, taskMap] - ); - - const handleDeleteTeam = useCallback((): void => { - setDeleteConfirmOpen(true); - }, []); - - const confirmDeleteTeam = useCallback((): void => { - setDeleteConfirmOpen(false); - void (async () => { - try { - await deleteTeam(teamName); - if (tabId) closeTab(tabId); - openTeamsTab(); - } catch { - // error is shown via store - } - })(); - }, [teamName, deleteTeam, openTeamsTab, closeTab, tabId]); - - const handleCreateTask = ( - subject: string, - description: string, - owner?: string, - blockedBy?: string[], - related?: string[], - prompt?: string, - startImmediately?: boolean, - descriptionTaskRefs?: TaskRef[], - promptTaskRefs?: TaskRef[] - ): void => { - setCreatingTask(true); - void (async () => { - try { - await createTeamTask(teamName, { - subject, - description: description || undefined, - owner, - blockedBy, - related, - prompt, - descriptionTaskRefs, - promptTaskRefs, - startImmediately, - }); - - if ( - prompt && - owner && - data?.isAlive && - !isTeamProvisioning && - startImmediately !== false - ) { - const msg = `New task assigned to ${owner}: "${subject}". Instructions:\n${prompt}`; - try { - await api.teams.processSend(teamName, msg); - } catch { - // best-effort - } + if (prompt && owner && data?.isAlive && !isTeamProvisioning && startImmediately !== false) { + const msg = `New task assigned to ${owner}: "${subject}". Instructions:\n${prompt}`; + try { + await api.teams.processSend(teamName, msg); + } catch { + // best-effort } - - closeCreateTaskDialog(); - } catch { - // error shown via store - } finally { - setCreatingTask(false); } - })(); - }; - const sharedMessagesPanelProps = useMemo( - () => ({ - teamName, - onPositionChange: changeMessagesPanelMode, - mountPoint: messagesPanelMountPoint, - members: activeMembers, - tasks: data?.tasks ?? [], - isTeamAlive: data?.isAlive, - timeWindow, - teamSessionIds, - currentLeadSessionId: data?.config.leadSessionId, - pendingRepliesByMember, - onPendingReplyChange: setPendingRepliesByMember, - onMemberClick: handleSelectMember, - onTaskClick: handleOpenTask, - onCreateTaskFromMessage: handleCreateTaskFromMessage, - onReplyToMessage: handleReplyToMessage, - onRestartTeam: handleRestartTeam, - onTaskIdClick: handleTaskIdClick, - inlineScrollContainerRef: contentRef, - }), - [ - activeMembers, - data?.config.leadSessionId, - data?.isAlive, - data?.tasks, - handleCreateTaskFromMessage, - handleOpenTask, - handleReplyToMessage, - handleRestartTeam, - handleSelectMember, - handleTaskIdClick, - messagesPanelMountPoint, - pendingRepliesByMember, - teamName, - teamSessionIds, - timeWindow, - changeMessagesPanelMode, - ] + closeCreateTaskDialog(); + } catch { + // error shown via store + } finally { + setCreatingTask(false); + } + })(); + }; + + const sharedMessagesPanelProps = useMemo( + () => ({ + teamName, + onPositionChange: changeMessagesPanelMode, + mountPoint: messagesPanelMountPoint, + members: activeMembers, + tasks: data?.tasks ?? [], + isTeamAlive: data?.isAlive, + timeWindow, + teamSessionIds, + currentLeadSessionId: data?.config.leadSessionId, + pendingRepliesByMember, + onPendingReplyChange: setPendingRepliesByMember, + onMemberClick: handleSelectMember, + onTaskClick: handleOpenTask, + onCreateTaskFromMessage: handleCreateTaskFromMessage, + onReplyToMessage: handleReplyToMessage, + onRestartTeam: handleRestartTeam, + onTaskIdClick: handleTaskIdClick, + inlineScrollContainerRef: contentRef, + }), + [ + activeMembers, + data?.config.leadSessionId, + data?.isAlive, + data?.tasks, + handleCreateTaskFromMessage, + handleOpenTask, + handleReplyToMessage, + handleRestartTeam, + handleSelectMember, + handleTaskIdClick, + messagesPanelMountPoint, + pendingRepliesByMember, + teamName, + teamSessionIds, + timeWindow, + changeMessagesPanelMode, + ] + ); + + if (!teamName) { + return ( +
+ Invalid team tab +
); + } - if (!teamName) { + const spawnStatusWatcher = ( + + ); + const teamAgentRuntimeWatcher = ( + + ); + const leadContextWatcher = shouldShowLeadContextUi ? ( + + ) : null; + + const renderBody = (): React.JSX.Element => { + if ((loading && !data) || (data && data.teamName !== teamName)) { return ( -
- Invalid team tab +
+
+
+ +
+
+
+
+
+
); } - const spawnStatusWatcher = ( - - ); - const teamAgentRuntimeWatcher = ( - - ); - const leadContextWatcher = shouldShowLeadContextUi ? ( - - ) : null; + if (error === 'TEAM_DRAFT') { + const draftTeamSummary = useStore.getState().teamByName[teamName]; + const draftDisplayName = draftTeamSummary?.displayName || teamName; + const draftMemberCount = draftTeamSummary?.memberCount ?? 0; - const renderBody = (): React.JSX.Element => { - if ((loading && !data) || (data && data.teamName !== teamName)) { - return ( -
-
+ return ( + <> +
-
-
-
-
-
-
- ); - } - - if (error === 'TEAM_DRAFT') { - const draftTeamSummary = useStore.getState().teamByName[teamName]; - const draftDisplayName = draftTeamSummary?.displayName || teamName; - const draftMemberCount = draftTeamSummary?.memberCount ?? 0; - - return ( - <> -
-
- -
-
-
-

Team not launched yet

-

- This is a draft team - {draftDisplayName} has been configured - with {draftMemberCount} member - {draftMemberCount === 1 ? '' : 's'} but hasn't been provisioned by CLI yet. - Click Launch to select a model and start the team. -

-
- - -
+
+
+

Team not launched yet

+

+ This is a draft team - {draftDisplayName} has been configured + with {draftMemberCount} member + {draftMemberCount === 1 ? '' : 's'} but hasn't been provisioned by CLI yet. + Click Launch to select a model and start the team. +

+
+ +
+
+ {launchDialogOpen && ( - - ); - } - - if (error) { - return ( -
-
-

Failed to load team

-

{error}

-
-
- ); - } - - if (!data) { - return ( -
-
- -
-
- Team data will appear once provisioning completes -
-
- ); - } - - const headerColorSet = data.config.color - ? getTeamColorSet(data.config.color) - : nameColorSet(data.config.name); + )} + + ); + } + if (error) { return ( - <> -
- +
+
+

Failed to load team

+

{error}

+
+
+ ); + } - {/* Messages sidebar (left, after context panel) */} - +
+ +
+
+ Team data will appear once provisioning completes +
+
+ ); + } + + const headerColorSet = data.config.color + ? getTeamColorSet(data.config.color) + : nameColorSet(data.config.name); + + return ( + <> +
+ + + {/* Messages sidebar (left, after context panel) */} + + - - - - + messagesPanelProps={sharedMessagesPanelProps} + isResizing={isMessagesPanelResizing} + onResizeMouseDown={messagesPanelHandleProps.onMouseDown} + logsHeight={sidebarLogsHeight} + isLogsResizing={isLogsPanelResizing} + onLogsResizeMouseDown={logsPanelHandleProps.onMouseDown} + /> + + -
-
-
- {headerColorSet ? ( -
- ) : null} +
+
+
+ {headerColorSet ? (
-
-
-

- {data.config.name} -

- {data.isAlive && ( - - - Running - - )} - {!data.isAlive && isTeamProvisioning && ( - - - Launching... - - )} -
-
-
+ className="pointer-events-none absolute inset-0 z-0" + style={{ backgroundColor: getThemedBadge(headerColorSet, isLight) }} + /> + ) : null} +
+
+
+

+ {data.config.name} +

{data.isAlive && ( - - - - - Stop team - + + + Running + )} - - - - - - {isTeamProvisioning - ? 'Edit team is unavailable while provisioning is still in progress' - : 'Edit team'} - - + {!data.isAlive && isTeamProvisioning && ( + + + Launching... + + )} +
+
+
+ {data.isAlive && ( - Delete team + Stop team -
-
- {data.config.description && ( -

- {data.config.description} -

- )} -
-
- {data.config.projectPath && ( - - - - - - {data.config.projectPath - .replace(/\\/g, '/') - .split('/') - .filter(Boolean) - .pop() ?? data.config.projectPath} - - - - - {formatProjectPath(data.config.projectPath)} - - - - - - - - Open project in built-in editor - - - )} - {leadBranch && ( - - - {leadBranch} - - )} -
- Open team graph + + {isTeamProvisioning + ? 'Edit team is unavailable while provisioning is still in progress' + : 'Edit team'} + + + + + + + Delete team
- {(() => { - const currentPath = data.config.projectPath; - const history = data.config.projectPathHistory?.filter( - (p) => p !== currentPath - ); - if (!history || history.length === 0) return null; - return ( -
+ {data.config.description && ( +

+ {data.config.description} +

+ )} +
+
+ {data.config.projectPath && ( + + + + + + {data.config.projectPath + .replace(/\\/g, '/') + .split('/') + .filter(Boolean) + .pop() ?? data.config.projectPath} + + + + + {formatProjectPath(data.config.projectPath)} + + + + + + + + Open project in built-in editor + + + )} + {leadBranch && ( + - - - Previous: {history.map((p) => formatProjectPath(p)).join(', ')} - -
- ); - })()} -
- - {!data.isAlive && !isTeamProvisioning ? ( - openLaunchDialog('launch')} - /> - ) : null} - -
- -
- - {data.warnings?.some((warning) => warning.toLowerCase().includes('kanban')) ? ( -
- Failed to fully load kanban. Displaying safe data. + + {leadBranch} + + )}
- ) : null} - {reviewActionError ? ( -
- {reviewActionError} -
- ) : null} - - } - badge={activeTeammateCount === 0 ? 'Solo' : activeTeammateCount} - defaultOpen - action={ -
+ + + + Open team graph + +
+ {(() => { + const currentPath = data.config.projectPath; + const history = data.config.projectPathHistory?.filter((p) => p !== currentPath); + if (!history || history.length === 0) return null; + return ( +
+ + + Previous: {history.map((p) => formatProjectPath(p)).join(', ')} +
- } - > - -
+ ); + })()} +
- } - defaultOpen={false} - > - - setKanbanFilter((prev) => ({ ...prev, sessionId: id })) - } - projectPath={data.config.projectPath} - /> - + {!data.isAlive && !isTeamProvisioning ? ( + openLaunchDialog('launch')} + /> + ) : null} - } - badge={filteredTasks.length} - defaultOpen - forceOpen={kanbanSearch.trim().length > 0} - action={ +
+ +
+ + {data.warnings?.some((warning) => warning.toLowerCase().includes('kanban')) ? ( +
+ Failed to fully load kanban. Displaying safe data. +
+ ) : null} + {reviewActionError ? ( +
+ {reviewActionError} +
+ ) : null} + + } + badge={activeTeammateCount === 0 ? 'Solo' : activeTeammateCount} + defaultOpen + action={ +
- } - > - - } - onRequestReview={(taskId) => { - void (async () => { - try { - await requestReview(teamName, taskId); - } catch { - // error via store - } - })(); - }} - onApprove={(taskId) => { - void (async () => { - try { - await updateKanban(teamName, taskId, { - op: 'set_column', - column: 'approved', - }); - } catch { - // error via store - } - })(); - }} - onRequestChanges={(taskId) => { - setRequestChangesTaskId(taskId); - }} - onMoveBackToDone={(taskId) => { - void (async () => { - try { - await updateKanban(teamName, taskId, { op: 'remove' }); - await updateTaskStatus(teamName, taskId, 'completed'); - } catch { - // error via store - } - })(); - }} - onStartTask={(taskId) => { - void (async () => { - try { - const result = await startTaskByUser(teamName, taskId); - if (data?.isAlive) { - const task = data.tasks.find((t) => t.id === taskId); - try { - if (result.notifiedOwner && task?.owner) { - await api.teams.processSend( - teamName, - `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has started. Please begin working on it.` - ); - } else if (!result.notifiedOwner) { - const desc = task?.description?.trim() - ? `\nDescription: ${task.description.trim()}` - : ''; - await api.teams.processSend( - teamName, - `Task #${deriveTaskDisplayId(taskId)} "${task?.subject ?? ''}" has been moved to IN PROGRESS but has no assignee.${desc}\nPlease assign it to an available team member, or take it yourself if everyone is busy.` - ); - } - } catch { - // best-effort - } - } - } catch { - // error via store - } - })(); - }} - onCompleteTask={(taskId) => { - void (async () => { - try { - await updateTaskStatus(teamName, taskId, 'completed'); - } catch { - // error via store - } - })(); - }} - onCancelTask={(taskId) => { - void (async () => { - try { - const task = data?.tasks.find((t) => t.id === taskId); - await updateTaskStatus(teamName, taskId, 'pending'); - - // Notify assignee directly via inbox — they'll see it immediately - if (task?.owner) { - try { - await api.teams.sendMessage(teamName, { - member: task.owner, - text: `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has been CANCELLED by the user and moved back to TODO. Stop working on it immediately.`, - summary: `Task ${formatTaskDisplayLabel(task)} cancelled`, - }); - } catch { - // best-effort - } - } - - // Also notify team lead so they can reassign/coordinate - if (data?.isAlive) { - try { - const ownerSuffix = task?.owner - ? ` ${task.owner} has been notified to stop.` - : ''; - await api.teams.processSend( - teamName, - `Task #${deriveTaskDisplayId(taskId)} "${task?.subject ?? ''}" has been cancelled and moved back to TODO.${ownerSuffix}` - ); - } catch { - // best-effort - } - } - } catch { - // error via store - } - })(); - }} - onColumnOrderChange={(columnId, orderedTaskIds) => { - void (async () => { - try { - await updateKanbanColumnOrder(teamName, columnId, orderedTaskIds); - } catch { - // error via store - } - })(); - }} - onScrollToTask={(taskId) => { - const el = document.querySelector(`[data-task-id="${taskId}"]`); - if (el) { - el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - el.classList.remove('kanban-card-focus-pulse'); - void (el as HTMLElement).offsetWidth; - el.classList.add('kanban-card-focus-pulse'); - el.addEventListener( - 'animationend', - () => el.classList.remove('kanban-card-focus-pulse'), - { once: true } - ); - } - }} - onTaskClick={(task) => setSelectedTask(task)} - onViewChanges={handleViewChanges} - onAddTask={(startImmediately) => - openCreateTaskDialog('', '', '', startImmediately) - } - onDeleteTask={handleDeleteTask} - deletedTaskCount={deletedTasks.length} - onOpenTrash={() => setTrashOpen(true)} - /> - - - } - defaultOpen={false} - > - - - - {(data.processes?.length ?? 0) > 0 && ( - } - badge={data.processes.filter((p) => !p.stoppedAt).length} - headerExtra={ - data.processes.some((p) => !p.stoppedAt) ? ( - - - - - ) : null - } - defaultOpen - > - - - )} - - {messagesPanelMode !== 'sidebar' && } - - {messagesPanelMode === 'inline' && ( - - )} - - setRequestChangesTaskId(null)} - onSubmit={(comment, taskRefs) => { - if (!requestChangesTaskId) { - return; - } - void (async () => { - try { - await updateKanban(teamName, requestChangesTaskId, { - op: 'request_changes', - comment, - taskRefs, - }); - setRequestChangesTaskId(null); - } catch { - // error state is handled in the store and shown in the view - } - })(); - }} - /> - - + } + > + { - const name = selectedMember?.name ?? ''; - closeSelectedMemberDialog(); - setSendDialogRecipient(name || undefined); - setSendDialogDefaultText(undefined); - setSendDialogDefaultChip(undefined); - setReplyQuote(undefined); - setSendDialogOpen(true); - }} - onAssignTask={() => { - const name = selectedMember?.name ?? ''; - closeSelectedMemberDialog(); - openCreateTaskDialog('', '', name); - }} + onMemberClick={handleSelectMember} + onSendMessage={handleSendMessageToMember} + onAssignTask={handleAssignTaskToMember} + onOpenTask={handleOpenTaskById} onRestartMember={handleRestartMember} - onTaskClick={(task) => { - closeSelectedMemberDialog(); - setSelectedTask(task); + onSkipMemberForLaunch={handleSkipMemberForLaunch} + /> + + + } + defaultOpen={false} + > + setKanbanFilter((prev) => ({ ...prev, sessionId: id }))} + projectPath={data.config.projectPath} + /> + + + } + badge={filteredTasks.length} + defaultOpen + forceOpen={kanbanSearch.trim().length > 0} + action={ + + } + > + + } + onRequestReview={(taskId) => { + void (async () => { + try { + await requestReview(teamName, taskId); + } catch { + // error via store + } + })(); }} - onUpdateRole={async (memberName, role) => { - setUpdatingRoleLoading(true); - try { - await updateMemberRole(teamName, memberName, role); - // Optimistically update local selectedMember to reflect new role - setSelectedMember((prev) => { - if (prev?.name !== memberName) return prev; - const normalized = - typeof role === 'string' && role.trim() ? role.trim() : undefined; - return { ...prev, role: normalized }; - }); - } finally { - setUpdatingRoleLoading(false); + onApprove={(taskId) => { + void (async () => { + try { + await updateKanban(teamName, taskId, { + op: 'set_column', + column: 'approved', + }); + } catch { + // error via store + } + })(); + }} + onRequestChanges={(taskId) => { + setRequestChangesTaskId(taskId); + }} + onMoveBackToDone={(taskId) => { + void (async () => { + try { + await updateKanban(teamName, taskId, { op: 'remove' }); + await updateTaskStatus(teamName, taskId, 'completed'); + } catch { + // error via store + } + })(); + }} + onStartTask={(taskId) => { + void (async () => { + try { + const result = await startTaskByUser(teamName, taskId); + if (data?.isAlive) { + const task = data.tasks.find((t) => t.id === taskId); + try { + if (result.notifiedOwner && task?.owner) { + await api.teams.processSend( + teamName, + `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has started. Please begin working on it.` + ); + } else if (!result.notifiedOwner) { + const desc = task?.description?.trim() + ? `\nDescription: ${task.description.trim()}` + : ''; + await api.teams.processSend( + teamName, + `Task #${deriveTaskDisplayId(taskId)} "${task?.subject ?? ''}" has been moved to IN PROGRESS but has no assignee.${desc}\nPlease assign it to an available team member, or take it yourself if everyone is busy.` + ); + } + } catch { + // best-effort + } + } + } catch { + // error via store + } + })(); + }} + onCompleteTask={(taskId) => { + void (async () => { + try { + await updateTaskStatus(teamName, taskId, 'completed'); + } catch { + // error via store + } + })(); + }} + onCancelTask={(taskId) => { + void (async () => { + try { + const task = data?.tasks.find((t) => t.id === taskId); + await updateTaskStatus(teamName, taskId, 'pending'); + + // Notify assignee directly via inbox — they'll see it immediately + if (task?.owner) { + try { + await api.teams.sendMessage(teamName, { + member: task.owner, + text: `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has been CANCELLED by the user and moved back to TODO. Stop working on it immediately.`, + summary: `Task ${formatTaskDisplayLabel(task)} cancelled`, + }); + } catch { + // best-effort + } + } + + // Also notify team lead so they can reassign/coordinate + if (data?.isAlive) { + try { + const ownerSuffix = task?.owner + ? ` ${task.owner} has been notified to stop.` + : ''; + await api.teams.processSend( + teamName, + `Task #${deriveTaskDisplayId(taskId)} "${task?.subject ?? ''}" has been cancelled and moved back to TODO.${ownerSuffix}` + ); + } catch { + // best-effort + } + } + } catch { + // error via store + } + })(); + }} + onColumnOrderChange={(columnId, orderedTaskIds) => { + void (async () => { + try { + await updateKanbanColumnOrder(teamName, columnId, orderedTaskIds); + } catch { + // error via store + } + })(); + }} + onScrollToTask={(taskId) => { + const el = document.querySelector(`[data-task-id="${taskId}"]`); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + el.classList.remove('kanban-card-focus-pulse'); + void (el as HTMLElement).offsetWidth; + el.classList.add('kanban-card-focus-pulse'); + el.addEventListener( + 'animationend', + () => el.classList.remove('kanban-card-focus-pulse'), + { once: true } + ); } }} - updatingRole={updatingRoleLoading} - onRemoveMember={() => { - const name = selectedMember?.name; - if (!name) return; - setRemoveMemberConfirm(name); - }} - onViewMemberChanges={(memberName, filePath) => { - closeSelectedMemberDialog(); - setReviewDialogState({ - open: true, - mode: 'agent', - memberName, - initialFilePath: filePath, - }); - }} + onTaskClick={(task) => setSelectedTask(task)} + onViewChanges={handleViewChanges} + onAddTask={(startImmediately) => + openCreateTaskDialog('', '', '', startImmediately) + } + onDeleteTask={handleDeleteTask} + deletedTaskCount={deletedTasks.length} + onOpenTrash={() => setTrashOpen(true)} /> + + } + defaultOpen={false} + > + + + + {(data.processes?.length ?? 0) > 0 && ( + } + badge={data.processes.filter((p) => !p.stoppedAt).length} + headerExtra={ + data.processes.some((p) => !p.stoppedAt) ? ( + + + + + ) : null + } + defaultOpen + > + + + )} + + {messagesPanelMode !== 'sidebar' && } + + {messagesPanelMode === 'inline' && ( + + )} + + setRequestChangesTaskId(null)} + onSubmit={(comment, taskRefs) => { + if (!requestChangesTaskId) { + return; + } + void (async () => { + try { + await updateKanban(teamName, requestChangesTaskId, { + op: 'request_changes', + comment, + taskRefs, + }); + setRequestChangesTaskId(null); + } catch { + // error state is handled in the store and shown in the view + } + })(); + }} + /> + + { + const name = selectedMember?.name ?? ''; + closeSelectedMemberDialog(); + setSendDialogRecipient(name || undefined); + setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); + setReplyQuote(undefined); + setSendDialogOpen(true); + }} + onAssignTask={() => { + const name = selectedMember?.name ?? ''; + closeSelectedMemberDialog(); + openCreateTaskDialog('', '', name); + }} + onRestartMember={handleRestartMember} + onTaskClick={(task) => { + closeSelectedMemberDialog(); + setSelectedTask(task); + }} + onUpdateRole={async (memberName, role) => { + setUpdatingRoleLoading(true); + try { + await updateMemberRole(teamName, memberName, role); + // Optimistically update local selectedMember to reflect new role + setSelectedMember((prev) => { + if (prev?.name !== memberName) return prev; + const normalized = + typeof role === 'string' && role.trim() ? role.trim() : undefined; + return { ...prev, role: normalized }; + }); + } finally { + setUpdatingRoleLoading(false); + } + }} + updatingRole={updatingRoleLoading} + onRemoveMember={() => { + const name = selectedMember?.name; + if (!name) return; + setRemoveMemberConfirm(name); + }} + onViewMemberChanges={(memberName, filePath) => { + closeSelectedMemberDialog(); + setReviewDialogState({ + open: true, + mode: 'agent', + memberName, + initialFilePath: filePath, + }); + }} + /> + + {createTaskDialog.open && ( + )} - !isLeadMember(m))} - leadMember={membersWithLiveBranches.find((m) => isLeadMember(m)) ?? null} - resolvedMemberColorMap={resolvedMemberColorMap} - isTeamAlive={data.isAlive && !isTeamProvisioning} - isTeamProvisioning={isTeamProvisioning} - projectPath={data.config.projectPath} - onClose={() => setEditDialogOpen(false)} - onChangeLeadRuntime={handleChangeLeadRuntime} - onSaved={() => void selectTeam(teamName)} - /> + !isLeadMember(m))} + leadMember={membersWithLiveBranches.find((m) => isLeadMember(m)) ?? null} + resolvedMemberColorMap={resolvedMemberColorMap} + isTeamAlive={data.isAlive && !isTeamProvisioning} + isTeamProvisioning={isTeamProvisioning} + projectPath={data.config.projectPath} + onClose={() => setEditDialogOpen(false)} + onChangeLeadRuntime={handleChangeLeadRuntime} + onSaved={() => void selectTeam(teamName)} + /> - m.name)} - existingMembers={membersWithLiveBranches} - projectPath={data.config.projectPath} - adding={addingMemberLoading} - onClose={() => setAddMemberDialogOpen(false)} - onAdd={(entries: AddMemberEntry[]) => { - setAddingMemberLoading(true); - void (async () => { - try { - for (const entry of entries) { - await addMember(teamName, { - name: entry.name, - role: entry.role, - workflow: entry.workflow, - isolation: entry.isolation, - providerId: entry.providerId, - model: entry.model, - effort: entry.effort, - }); - } - setAddMemberDialogOpen(false); - } catch { - // error shown via store - } finally { - setAddingMemberLoading(false); + m.name)} + existingMembers={membersWithLiveBranches} + projectPath={data.config.projectPath} + adding={addingMemberLoading} + onClose={() => setAddMemberDialogOpen(false)} + onAdd={(entries: AddMemberEntry[]) => { + setAddingMemberLoading(true); + void (async () => { + try { + for (const entry of entries) { + await addMember(teamName, { + name: entry.name, + role: entry.role, + workflow: entry.workflow, + isolation: entry.isolation, + providerId: entry.providerId, + model: entry.model, + effort: entry.effort, + }); } - })(); - }} - /> + setAddMemberDialogOpen(false); + } catch { + // error shown via store + } finally { + setAddingMemberLoading(false); + } + })(); + }} + /> - { - if (!open) setRemoveMemberConfirm(null); - }} - > - - - Remove member - - Remove “{removeMemberConfirm}” from the team? Tasks and messages - will be preserved, but this name cannot be reused. - - - - - - - - + { + if (!open) setRemoveMemberConfirm(null); + }} + > + + + Remove member + + Remove “{removeMemberConfirm}” from the team? Tasks and messages + will be preserved, but this name cannot be reused. + + + + + + + + - - - - Delete team - - Delete team “{data.config.name}”? This action is irreversible. - All team data and tasks will be deleted. - - - - - - - - + + + + Delete team + + Delete team “{data.config.name}”? This action is irreversible. All + team data and tasks will be deleted. + + + + + + + + + {launchDialogOpen && ( + )} + {sendDialogOpen && ( + )} + {selectedTask !== null && ( + )} - setTrashOpen(false)} - onRestore={(taskId) => { - void (async () => { - try { - await restoreTask(teamName, taskId); - } catch { - // error via store - } - })(); - }} - /> + setTrashOpen(false)} + onRestore={(taskId) => { + void (async () => { + try { + await restoreTask(teamName, taskId); + } catch { + // error via store + } + })(); + }} + /> + {reviewDialogState.open && ( -
-
- {messagesPanelMode === 'bottom-sheet' && ( - )}
+
+ {messagesPanelMode === 'bottom-sheet' && ( + + )}
+
- {editorOpen && data.config.projectPath && ( - - setEditorOpen(false)} - onEditorAction={handleEditorAction} - /> - - )} + {editorOpen && data.config.projectPath && ( + + setEditorOpen(false)} + onEditorAction={handleEditorAction} + /> + + )} - {graphOpen && ( - - setGraphOpen(false)} - onPinAsTab={() => { - setGraphOpen(false); - useStore - .getState() - .openTab({ type: 'graph', label: `${data.config.name} Graph`, teamName }); - }} - onSendMessage={(memberName) => { - setSendDialogRecipient(memberName); - setSendDialogDefaultText(undefined); - setSendDialogDefaultChip(undefined); - setSendDialogOpen(true); - }} - onOpenTaskDetail={(taskId) => { - const task = data.tasks.find((t) => t.id === taskId); - if (task) setSelectedTask(task); - }} - onOpenMemberProfile={(memberName, options) => { - const member = members.find((m) => m.name === memberName); - if (member) { - setSelectedMember(member); - setSelectedMemberView({ - initialTab: options?.initialTab, - initialActivityFilter: options?.initialActivityFilter, - }); - } - }} - /> - - )} - - ); - }; - - return ( - <> - {spawnStatusWatcher} - {teamAgentRuntimeWatcher} - {leadContextWatcher} - {renderBody()} + {graphOpen && ( + + setGraphOpen(false)} + onPinAsTab={() => { + setGraphOpen(false); + useStore + .getState() + .openTab({ type: 'graph', label: `${data.config.name} Graph`, teamName }); + }} + onSendMessage={(memberName) => { + setSendDialogRecipient(memberName); + setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); + setSendDialogOpen(true); + }} + onOpenTaskDetail={(taskId) => { + const task = data.tasks.find((t) => t.id === taskId); + if (task) setSelectedTask(task); + }} + onOpenMemberProfile={(memberName, options) => { + const member = members.find((m) => m.name === memberName); + if (member) { + setSelectedMember(member); + setSelectedMemberView({ + initialTab: options?.initialTab, + initialActivityFilter: options?.initialActivityFilter, + }); + } + }} + /> + + )} ); - } -); + }; + + return ( + <> + {spawnStatusWatcher} + {teamAgentRuntimeWatcher} + {leadContextWatcher} + {renderBody()} + + ); +}); diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index df540b6e..5e597278 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -45,7 +45,6 @@ import { } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; -import type { ActiveTeamRef, TeamCopyData } from './dialogs/CreateTeamDialog'; import { TeamEmptyState } from './TeamEmptyState'; import { EMPTY_TEAM_FILTER, TeamListFilterPopover } from './TeamListFilterPopover'; import { @@ -54,13 +53,7 @@ import { teamMatchesProjectSelection, } from './teamProjectSelection'; -const CreateTeamDialog = lazy(() => - import('./dialogs/CreateTeamDialog').then((m) => ({ default: m.CreateTeamDialog })) -); -const LaunchTeamDialog = lazy(() => - import('./dialogs/LaunchTeamDialog').then((m) => ({ default: m.LaunchTeamDialog })) -); - +import type { ActiveTeamRef, TeamCopyData } from './dialogs/CreateTeamDialog'; import type { TeamListFilterState } from './TeamListFilterPopover'; import type { TeamStatus } from '@renderer/utils/teamListStatus'; import type { @@ -72,6 +65,13 @@ import type { TeamSummaryMember, } from '@shared/types'; +const CreateTeamDialog = lazy(() => + import('./dialogs/CreateTeamDialog').then((m) => ({ default: m.CreateTeamDialog })) +); +const LaunchTeamDialog = lazy(() => + import('./dialogs/LaunchTeamDialog').then((m) => ({ default: m.LaunchTeamDialog })) +); + function generateUniqueName(sourceName: string, existingNames: string[]): string { const base = sourceName.replace(/-\d+$/, ''); const existing = new Set(existingNames); @@ -238,7 +238,7 @@ const StatusBadge = ({ status }: { status: TeamStatus }): React.JSX.Element => { } }; -export const TeamListView = memo((): React.JSX.Element => { +export const TeamListView = memo(function TeamListView(): React.JSX.Element { const { isLight } = useTheme(); const electronMode = isElectronMode(); const [showCreateDialog, setShowCreateDialog] = useState(false); diff --git a/src/renderer/components/team/kanban/KanbanBoard.tsx b/src/renderer/components/team/kanban/KanbanBoard.tsx index 056f0014..e5241390 100644 --- a/src/renderer/components/team/kanban/KanbanBoard.tsx +++ b/src/renderer/components/team/kanban/KanbanBoard.tsx @@ -311,123 +311,119 @@ const SortableKanbanTaskCard = ({ ); }; -export const KanbanBoard = memo( - ({ - tasks, - teamName, - kanbanState, - filter, - sort, - sessions, - leadSessionId, - members, - onFilterChange, - onSortChange, - onRequestReview, - onApprove, - onRequestChanges, - onMoveBackToDone, - onStartTask, - onCompleteTask, - onCancelTask, - onScrollToTask, - onTaskClick, - onViewChanges, - onColumnOrderChange, - toolbarLeft, - onAddTask, - onDeleteTask, - deletedTaskCount, - onOpenTrash, - }: KanbanBoardProps): React.JSX.Element => { - const boardRef = useRef(null); - const scrollRestoreTimeoutsRef = useRef([]); - const [viewMode, setViewMode] = useState('grid'); - const [gridPrimaryColumnWidth, setGridPrimaryColumnWidth] = useState(null); - const [gridSkeletonDelayMs, setGridSkeletonDelayMs] = useState(SKELETON_HIDE_DELAY_MS); - const hasReviewers = kanbanState.reviewers.length > 0; - const enableTaskSorting = - viewMode === 'columns' && !!onColumnOrderChange && sort.field === 'manual'; +export const KanbanBoard = memo(function KanbanBoard({ + tasks, + teamName, + kanbanState, + filter, + sort, + sessions, + leadSessionId, + members, + onFilterChange, + onSortChange, + onRequestReview, + onApprove, + onRequestChanges, + onMoveBackToDone, + onStartTask, + onCompleteTask, + onCancelTask, + onScrollToTask, + onTaskClick, + onViewChanges, + onColumnOrderChange, + toolbarLeft, + onAddTask, + onDeleteTask, + deletedTaskCount, + onOpenTrash, +}: KanbanBoardProps): React.JSX.Element { + const boardRef = useRef(null); + const scrollRestoreTimeoutsRef = useRef([]); + const [viewMode, setViewMode] = useState('grid'); + const [gridPrimaryColumnWidth, setGridPrimaryColumnWidth] = useState(null); + const [gridSkeletonDelayMs, setGridSkeletonDelayMs] = useState(SKELETON_HIDE_DELAY_MS); + const hasReviewers = kanbanState.reviewers.length > 0; + const enableTaskSorting = + viewMode === 'columns' && !!onColumnOrderChange && sort.field === 'manual'; - const stableTaskMapRef = useRef<{ - signatures: string[]; - map: Map; - } | null>(null); - const taskMap = useMemo(() => { - const signatures = tasks.map( - (task) => `${task.id}\0${task.displayId ?? ''}\0${task.subject}\0${task.status}` - ); - const previous = stableTaskMapRef.current; - if ( - previous?.signatures.length === signatures.length && - previous.signatures.every((signature, index) => signature === signatures[index]) - ) { - return previous.map; - } - - const next = new Map(tasks.map((task) => [task.id, task])); - stableTaskMapRef.current = { signatures, map: next }; - return next; - }, [tasks]); - const memberColorMap = useMemo(() => buildMemberColorMap(members), [members]); - const grouped = useMemo(() => { - const result = new Map( - COLUMNS.map(({ id }) => [id, [] as TeamTask[]]) - ); - for (const task of tasks) { - const column = getTaskColumn(task, kanbanState); - if (!column) { - continue; - } - result.get(column)?.push(task); - } - return result; - }, [tasks, kanbanState]); - - const groupedOrdered = useMemo(() => { - const result = new Map(); - for (const column of COLUMNS) { - const columnTasks = grouped.get(column.id) ?? []; - const order = kanbanState.columnOrder?.[column.id]; - result.set(column.id, sortColumnTasksByField(columnTasks, sort.field, order)); - } - return result; - }, [grouped, kanbanState.columnOrder, sort.field]); - - const sensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { distance: 8 }, - }) + const stableTaskMapRef = useRef<{ + signatures: string[]; + map: Map; + } | null>(null); + const taskMap = useMemo(() => { + const signatures = tasks.map( + (task) => `${task.id}\0${task.displayId ?? ''}\0${task.subject}\0${task.status}` ); + const previous = stableTaskMapRef.current; + if ( + previous?.signatures.length === signatures.length && + previous.signatures.every((signature, index) => signature === signatures[index]) + ) { + return previous.map; + } - const handleDragEnd = useCallback( - (event: DragEndEvent) => { - const { active, over } = event; - if (!onColumnOrderChange || !over || active.id === over.id) { - return; - } - const activeData = active.data.current; - if (activeData?.type !== 'kanban-task') { - return; - } - const columnId = activeData.columnId as KanbanColumnId; - const orderedIds = groupedOrdered.get(columnId)?.map((t) => t.id) ?? []; - const oldIndex = orderedIds.indexOf(active.id as string); - const newIndex = orderedIds.indexOf(over.id as string); - if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) { - return; - } - const newOrder = arrayMove(orderedIds, oldIndex, newIndex); - onColumnOrderChange(columnId, newOrder); - }, - [onColumnOrderChange, groupedOrdered] + const next = new Map(tasks.map((task) => [task.id, task])); + stableTaskMapRef.current = { signatures, map: next }; + return next; + }, [tasks]); + const memberColorMap = useMemo(() => buildMemberColorMap(members), [members]); + const grouped = useMemo(() => { + const result = new Map( + COLUMNS.map(({ id }) => [id, [] as TeamTask[]]) ); + for (const task of tasks) { + const column = getTaskColumn(task, kanbanState); + if (!column) { + continue; + } + result.get(column)?.push(task); + } + return result; + }, [tasks, kanbanState]); - const renderCards = ( - columnId: KanbanColumnId, - columnTasks: TeamTask[], - compact?: boolean - ): React.JSX.Element => { + const groupedOrdered = useMemo(() => { + const result = new Map(); + for (const column of COLUMNS) { + const columnTasks = grouped.get(column.id) ?? []; + const order = kanbanState.columnOrder?.[column.id]; + result.set(column.id, sortColumnTasksByField(columnTasks, sort.field, order)); + } + return result; + }, [grouped, kanbanState.columnOrder, sort.field]); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 8 }, + }) + ); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + if (!onColumnOrderChange || !over || active.id === over.id) { + return; + } + const activeData = active.data.current; + if (activeData?.type !== 'kanban-task') { + return; + } + const columnId = activeData.columnId as KanbanColumnId; + const orderedIds = groupedOrdered.get(columnId)?.map((t) => t.id) ?? []; + const oldIndex = orderedIds.indexOf(active.id as string); + const newIndex = orderedIds.indexOf(over.id as string); + if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) { + return; + } + const newOrder = arrayMove(orderedIds, oldIndex, newIndex); + onColumnOrderChange(columnId, newOrder); + }, + [onColumnOrderChange, groupedOrdered] + ); + + const renderCards = useCallback( + (columnId: KanbanColumnId, columnTasks: TeamTask[], compact?: boolean): React.JSX.Element => { const addHandler = onAddTask && columnId === 'todo' ? () => onAddTask(false) @@ -517,248 +513,266 @@ export const KanbanBoard = memo( {addButton} ); - }; + }, + [ + enableTaskSorting, + hasReviewers, + kanbanState, + memberColorMap, + onAddTask, + onApprove, + onCancelTask, + onCompleteTask, + onDeleteTask, + onMoveBackToDone, + onRequestChanges, + onRequestReview, + onScrollToTask, + onStartTask, + onTaskClick, + onViewChanges, + taskMap, + teamName, + ] + ); - const visibleColumns = useMemo( - () => (filter.columns.size > 0 ? COLUMNS.filter((c) => filter.columns.has(c.id)) : COLUMNS), - [filter.columns] - ); - const primaryVisibleColumnId = visibleColumns[0]?.id ?? null; + const visibleColumns = useMemo( + () => (filter.columns.size > 0 ? COLUMNS.filter((c) => filter.columns.has(c.id)) : COLUMNS), + [filter.columns] + ); + const primaryVisibleColumnId = visibleColumns[0]?.id ?? null; - const resizableColumnIds = useMemo(() => visibleColumns.map((c) => c.id), [visibleColumns]); - const { widths: columnWidths, getHandleProps } = useResizableColumns({ - storageKey: teamName, - columnIds: resizableColumnIds, - }); - const columnModeSearchWidth = - primaryVisibleColumnId != null ? (columnWidths.get(primaryVisibleColumnId) ?? 256) : 256; - const toolbarLeftWidth = - viewMode === 'grid' - ? (gridPrimaryColumnWidth ?? columnModeSearchWidth) - : columnModeSearchWidth; + const resizableColumnIds = useMemo(() => visibleColumns.map((c) => c.id), [visibleColumns]); + const { widths: columnWidths, getHandleProps } = useResizableColumns({ + storageKey: teamName, + columnIds: resizableColumnIds, + }); + const columnModeSearchWidth = + primaryVisibleColumnId != null ? (columnWidths.get(primaryVisibleColumnId) ?? 256) : 256; + const toolbarLeftWidth = + viewMode === 'grid' ? (gridPrimaryColumnWidth ?? columnModeSearchWidth) : columnModeSearchWidth; - const clearScheduledScrollRestore = useCallback(() => { - for (const timeoutId of scrollRestoreTimeoutsRef.current) { - window.clearTimeout(timeoutId); + const clearScheduledScrollRestore = useCallback(() => { + for (const timeoutId of scrollRestoreTimeoutsRef.current) { + window.clearTimeout(timeoutId); + } + scrollRestoreTimeoutsRef.current = []; + }, []); + + useEffect(() => clearScheduledScrollRestore, [clearScheduledScrollRestore]); + + const findScrollContainer = useCallback((startNode: HTMLElement | null): HTMLElement | null => { + let current = startNode?.parentElement ?? null; + while (current) { + const { overflowY } = window.getComputedStyle(current); + if (SCROLLABLE_OVERFLOW_VALUES.has(overflowY)) { + return current; } - scrollRestoreTimeoutsRef.current = []; - }, []); + current = current.parentElement; + } + return null; + }, []); - useEffect(() => clearScheduledScrollRestore, [clearScheduledScrollRestore]); - - const findScrollContainer = useCallback((startNode: HTMLElement | null): HTMLElement | null => { - let current = startNode?.parentElement ?? null; - while (current) { - const { overflowY } = window.getComputedStyle(current); - if (SCROLLABLE_OVERFLOW_VALUES.has(overflowY)) { - return current; - } - current = current.parentElement; + const scheduleScrollRestore = useCallback( + (nextViewMode: KanbanViewMode, skeletonDelayMs: number) => { + const container = findScrollContainer(boardRef.current); + if (!container) { + return; } - return null; - }, []); - const scheduleScrollRestore = useCallback( - (nextViewMode: KanbanViewMode, skeletonDelayMs: number) => { - const container = findScrollContainer(boardRef.current); - if (!container) { - return; - } + const savedScrollTop = container.scrollTop; + clearScheduledScrollRestore(); - const savedScrollTop = container.scrollTop; - clearScheduledScrollRestore(); + const restore = (): void => { + container.scrollTop = savedScrollTop; + }; - const restore = (): void => { - container.scrollTop = savedScrollTop; + const delays = + nextViewMode === 'grid' ? [skeletonDelayMs + 40, skeletonDelayMs + 220] : [120]; + + scrollRestoreTimeoutsRef.current = delays.map((delay) => window.setTimeout(restore, delay)); + }, + [clearScheduledScrollRestore, findScrollContainer] + ); + + const switchViewMode = useCallback( + (nextViewMode: KanbanViewMode) => { + const nextSkeletonDelayMs = + nextViewMode === 'grid' && viewMode === 'columns' + ? SKELETON_HIDE_DELAY_MS_ON_MODE_SWITCH + : SKELETON_HIDE_DELAY_MS; + + setGridSkeletonDelayMs(nextSkeletonDelayMs); + scheduleScrollRestore(nextViewMode, nextSkeletonDelayMs); + setViewMode(nextViewMode); + }, + [scheduleScrollRestore, viewMode] + ); + + const gridColumns = useMemo( + () => + visibleColumns.map((column) => { + const columnTasks = groupedOrdered.get(column.id) ?? []; + const accent = COLUMN_ACCENTS[column.id]; + return { + id: column.id, + title: column.title, + count: columnTasks.length, + icon: accent.icon, + headerBg: accent.headerBg, + bodyBg: accent.bodyBg, + content: renderCards(column.id, columnTasks), + showAddButton: columnSupportsAddButton(column.id, onAddTask), + skeletonCards: columnTasks.map((task) => ({ + key: task.id, + height: estimateGridSkeletonCardHeight(task, column.id, kanbanState, hasReviewers), + })), }; + }), + [visibleColumns, groupedOrdered, renderCards, onAddTask, kanbanState, hasReviewers] + ); - const delays = - nextViewMode === 'grid' ? [skeletonDelayMs + 40, skeletonDelayMs + 220] : [120]; - - scrollRestoreTimeoutsRef.current = delays.map((delay) => window.setTimeout(restore, delay)); - }, - [clearScheduledScrollRestore, findScrollContainer] - ); - - const switchViewMode = useCallback( - (nextViewMode: KanbanViewMode) => { - const nextSkeletonDelayMs = - nextViewMode === 'grid' && viewMode === 'columns' - ? SKELETON_HIDE_DELAY_MS_ON_MODE_SWITCH - : SKELETON_HIDE_DELAY_MS; - - setGridSkeletonDelayMs(nextSkeletonDelayMs); - scheduleScrollRestore(nextViewMode, nextSkeletonDelayMs); - setViewMode(nextViewMode); - }, - [scheduleScrollRestore, viewMode] - ); - - const boardContent = ( -
-
- {toolbarLeft != null && ( -
- {toolbarLeft} -
- )} -
-
- -
- -
- {deletedTaskCount != null && deletedTaskCount > 0 && onOpenTrash ? ( - - - - - Trash - - ) : null} -
- - - - - Grid view - - - - - - Columns view - -
-
-
- - {viewMode === 'grid' ? ( - column.id)} - primaryColumnId={primaryVisibleColumnId} - onPrimaryColumnWidthChange={setGridPrimaryColumnWidth} - skeletonDelayMs={gridSkeletonDelayMs} - columns={visibleColumns.map((column) => { - const columnTasks = groupedOrdered.get(column.id) ?? []; - const accent = COLUMN_ACCENTS[column.id]; - - return { - id: column.id, - title: column.title, - count: columnTasks.length, - icon: accent.icon, - headerBg: accent.headerBg, - bodyBg: accent.bodyBg, - content: renderCards(column.id, columnTasks), - showAddButton: columnSupportsAddButton(column.id, onAddTask), - skeletonCards: columnTasks.map((task) => ({ - key: task.id, - height: estimateGridSkeletonCardHeight( - task, - column.id, - kanbanState, - hasReviewers - ), - })), - }; - })} - /> - ) : ( -
-
- {visibleColumns.map((column, index) => { - const columnTasks = groupedOrdered.get(column.id) ?? []; - const accent = COLUMN_ACCENTS[column.id]; - const width = columnWidths.get(column.id) ?? 256; - const handleProps = getHandleProps(column.id); - return ( -
-
- - {renderCards(column.id, columnTasks, true)} - -
- {index < visibleColumns.length - 1 ? ( -
-
-
- ) : null} -
- ); - })} -
+ const boardContent = ( +
+
+ {toolbarLeft != null && ( +
+ {toolbarLeft}
)} +
+
+ +
+ +
+ {deletedTaskCount != null && deletedTaskCount > 0 && onOpenTrash ? ( + + + + + Trash + + ) : null} +
+ + + + + Grid view + + + + + + Columns view + +
+
+ + {viewMode === 'grid' ? ( + column.id)} + primaryColumnId={primaryVisibleColumnId} + onPrimaryColumnWidthChange={setGridPrimaryColumnWidth} + skeletonDelayMs={gridSkeletonDelayMs} + columns={gridColumns} + /> + ) : ( +
+
+ {visibleColumns.map((column, index) => { + const columnTasks = groupedOrdered.get(column.id) ?? []; + const accent = COLUMN_ACCENTS[column.id]; + const width = columnWidths.get(column.id) ?? 256; + const handleProps = getHandleProps(column.id); + return ( +
+
+ + {renderCards(column.id, columnTasks, true)} + +
+ {index < visibleColumns.length - 1 ? ( +
+
+
+ ) : null} +
+ ); + })} +
+
+ )} +
+ ); + + if (enableTaskSorting) { + return ( + + {boardContent} + ); - - if (enableTaskSorting) { - return ( - - {boardContent} - - ); - } - - return boardContent; } -); + + return boardContent; +}); diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index dbfe9c34..70396058 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -91,632 +91,622 @@ function splitRuntimeSummaryMemory(runtimeSummary: string | undefined): { }; } -export const MemberCard = memo( - ({ +export const MemberCard = memo(function MemberCard({ + member, + memberColor, + runtimeSummary, + runtimeEntry, + runtimeRunId, + taskCounts, + isTeamAlive, + isTeamProvisioning, + leadActivity, + currentTask, + reviewTask, + isAwaitingReply, + isRemoved, + spawnStatus, + spawnEntry, + spawnError, + spawnLivenessSource, + spawnLaunchState, + spawnRuntimeAlive, + isLaunchSettling, + onOpenTask, + onOpenReviewTask, + onClick, + onSendMessage, + onAssignTask, + onRestartMember, + onSkipMemberForLaunch, +}: MemberCardProps): React.JSX.Element { + // NOTE: lead context display disabled — usage formula is inaccurate + // const teamName = useStore((s) => s.selectedTeamName); + // const leadContext = useStore((s) => + // member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined + // ); + const selectedTeamName = useStore((s) => s.selectedTeamName); + const [retryingLaunch, setRetryingLaunch] = useState(false); + const [retryLaunchError, setRetryLaunchError] = useState(null); + const [skippingLaunch, setSkippingLaunch] = useState(false); + const [skipLaunchError, setSkipLaunchError] = useState(null); + const teamMembers = useStore((s) => + selectedTeamName ? selectResolvedMembersForTeamName(s, selectedTeamName) : [] + ); + const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]); + const launchPresentation = buildMemberLaunchPresentation({ member, - memberColor, - runtimeSummary, + spawnStatus, + spawnLaunchState, + spawnLivenessSource, + spawnRuntimeAlive, runtimeEntry, - runtimeRunId, - taskCounts, + runtimeAdvisory: member.runtimeAdvisory, + isLaunchSettling, isTeamAlive, isTeamProvisioning, leadActivity, - currentTask, - reviewTask, - isAwaitingReply, - isRemoved, - spawnStatus, - spawnEntry, - spawnError, - spawnLivenessSource, - spawnLaunchState, - spawnRuntimeAlive, - isLaunchSettling, - onOpenTask, - onOpenReviewTask, - onClick, - onSendMessage, - onAssignTask, - onRestartMember, - onSkipMemberForLaunch, - }: MemberCardProps): React.JSX.Element => { - // NOTE: lead context display disabled — usage formula is inaccurate - // const teamName = useStore((s) => s.selectedTeamName); - // const leadContext = useStore((s) => - // member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined - // ); - const selectedTeamName = useStore((s) => s.selectedTeamName); - const [retryingLaunch, setRetryingLaunch] = useState(false); - const [retryLaunchError, setRetryLaunchError] = useState(null); - const [skippingLaunch, setSkippingLaunch] = useState(false); - const [skipLaunchError, setSkipLaunchError] = useState(null); - const teamMembers = useStore((s) => - selectedTeamName ? selectResolvedMembersForTeamName(s, selectedTeamName) : [] - ); - const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]); - const launchPresentation = buildMemberLaunchPresentation({ - member, - spawnStatus, - spawnLaunchState, - spawnLivenessSource, - spawnRuntimeAlive, - runtimeEntry, - runtimeAdvisory: member.runtimeAdvisory, - isLaunchSettling, - isTeamAlive, - isTeamProvisioning, - leadActivity, - }); - const dotClass = launchPresentation.dotClass; - const runtimeAdvisoryLabel = launchPresentation.runtimeAdvisoryLabel; - const runtimeAdvisoryTitle = launchPresentation.runtimeAdvisoryTitle; - const runtimeAdvisoryTone = launchPresentation.runtimeAdvisoryTone; - const presenceLabel = launchPresentation.presenceLabel; - const spawnCardClass = launchPresentation.cardClass; - const launchVisualState = launchPresentation.launchVisualState; - const launchStatusLabel = launchPresentation.launchStatusLabel; - const displayPresenceLabel = + }); + const dotClass = launchPresentation.dotClass; + const runtimeAdvisoryLabel = launchPresentation.runtimeAdvisoryLabel; + const runtimeAdvisoryTitle = launchPresentation.runtimeAdvisoryTitle; + const runtimeAdvisoryTone = launchPresentation.runtimeAdvisoryTone; + const presenceLabel = launchPresentation.presenceLabel; + const spawnCardClass = launchPresentation.cardClass; + const launchVisualState = launchPresentation.launchVisualState; + const launchStatusLabel = launchPresentation.launchStatusLabel; + const displayPresenceLabel = + launchVisualState === 'queued' || + launchVisualState === 'runtime_pending' || + launchVisualState === 'permission_pending' || + launchVisualState === 'shell_only' || + launchVisualState === 'runtime_candidate' || + launchVisualState === 'registered_only' || + launchVisualState === 'stale_runtime' + ? (launchStatusLabel ?? presenceLabel) + : presenceLabel; + const colors = getTeamColorSet(memberColor); + const { isLight } = useTheme(); + const pending = taskCounts?.pending ?? 0; + const inProgress = taskCounts?.inProgress ?? 0; + const completed = taskCounts?.completed ?? 0; + const totalTasks = pending + inProgress + completed; + const progressPercent = totalTasks > 0 ? Math.round((completed / totalTasks) * 100) : 0; + const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType); + const { summary: runtimeSummaryText, memory: memoryLabel } = + splitRuntimeSummaryMemory(runtimeSummary); + const memorySourceLabel = getRuntimeMemorySourceLabel(runtimeEntry); + const isLead = isLeadMember(member); + const workspacePath = member.cwd?.trim(); + const showWorkspaceBadge = !isLead && !isRemoved && member.isolation === 'worktree'; + const workspaceTooltipLines = [ + 'Worktree isolation is enabled.', + workspacePath ? `Path: ${workspacePath}` : 'Path is not available yet.', + member.gitBranch ? `Branch: ${member.gitBranch}` : null, + ].filter((line): line is string => Boolean(line)); + const activityTask = currentTask ?? reviewTask ?? null; + const activityTitle = currentTask + ? `Current task: #${deriveTaskDisplayId(currentTask.id)}` + : reviewTask + ? `Reviewing task: #${deriveTaskDisplayId(reviewTask.id)}` + : undefined; + const showStartingSkeleton = + !isRemoved && + presenceLabel === 'starting' && + spawnLaunchState !== 'failed_to_start' && + !activityTask && + !runtimeSummary; + const showLaunchBadge = + !isRemoved && + !runtimeAdvisoryLabel && + (presenceLabel === 'starting' || + presenceLabel === 'connecting' || launchVisualState === 'queued' || launchVisualState === 'runtime_pending' || - launchVisualState === 'permission_pending' || launchVisualState === 'shell_only' || launchVisualState === 'runtime_candidate' || launchVisualState === 'registered_only' || - launchVisualState === 'stale_runtime' - ? (launchStatusLabel ?? presenceLabel) - : presenceLabel; - const colors = getTeamColorSet(memberColor); - const { isLight } = useTheme(); - const pending = taskCounts?.pending ?? 0; - const inProgress = taskCounts?.inProgress ?? 0; - const completed = taskCounts?.completed ?? 0; - const totalTasks = pending + inProgress + completed; - const progressPercent = totalTasks > 0 ? Math.round((completed / totalTasks) * 100) : 0; - const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType); - const { summary: runtimeSummaryText, memory: memoryLabel } = - splitRuntimeSummaryMemory(runtimeSummary); - const memorySourceLabel = getRuntimeMemorySourceLabel(runtimeEntry); - const isLead = isLeadMember(member); - const workspacePath = member.cwd?.trim(); - const showWorkspaceBadge = !isLead && !isRemoved && member.isolation === 'worktree'; - const workspaceTooltipLines = [ - 'Worktree isolation is enabled.', - workspacePath ? `Path: ${workspacePath}` : 'Path is not available yet.', - member.gitBranch ? `Branch: ${member.gitBranch}` : null, - ].filter((line): line is string => Boolean(line)); - const activityTask = currentTask ?? reviewTask ?? null; - const activityTitle = currentTask - ? `Current task: #${deriveTaskDisplayId(currentTask.id)}` - : reviewTask - ? `Reviewing task: #${deriveTaskDisplayId(reviewTask.id)}` - : undefined; - const showStartingSkeleton = - !isRemoved && - presenceLabel === 'starting' && - spawnLaunchState !== 'failed_to_start' && - !activityTask && - !runtimeSummary; - const showLaunchBadge = - !isRemoved && - !runtimeAdvisoryLabel && - (presenceLabel === 'starting' || - presenceLabel === 'connecting' || - launchVisualState === 'queued' || - launchVisualState === 'runtime_pending' || - launchVisualState === 'shell_only' || - launchVisualState === 'runtime_candidate' || - launchVisualState === 'registered_only' || - launchVisualState === 'stale_runtime'); - const launchBadgeLabel = presenceLabel === 'starting' ? presenceLabel : displayPresenceLabel; - const launchDiagnosticsPayload = useMemo( - () => - buildMemberLaunchDiagnosticsPayload({ - teamName: selectedTeamName, - runId: runtimeRunId, - memberName: member.name, - spawnStatus, - launchState: spawnLaunchState, - livenessSource: spawnLivenessSource, - spawnEntry, - runtimeEntry, - }), - [ - member.name, - runtimeEntry, - runtimeRunId, - selectedTeamName, - spawnEntry, - spawnLaunchState, - spawnLivenessSource, + launchVisualState === 'stale_runtime'); + const launchBadgeLabel = presenceLabel === 'starting' ? presenceLabel : displayPresenceLabel; + const launchDiagnosticsPayload = useMemo( + () => + buildMemberLaunchDiagnosticsPayload({ + teamName: selectedTeamName, + runId: runtimeRunId, + memberName: member.name, spawnStatus, - ] - ); - const showCopyDiagnostics = - !isRemoved && - hasMemberLaunchDiagnosticsError(launchDiagnosticsPayload) && - hasMemberLaunchDiagnosticsDetails(launchDiagnosticsPayload); - const isFailedLaunch = spawnStatus === 'error' || spawnLaunchState === 'failed_to_start'; - const isSkippedLaunch = - spawnStatus === 'skipped' || - spawnLaunchState === 'skipped_for_launch' || - spawnEntry?.skippedForLaunch === true; - const showFailedLaunchBadge = !isRemoved && isFailedLaunch; - const showSkippedLaunchBadge = !isRemoved && isSkippedLaunch; - const hasLiveLaunchControls = - isTeamAlive === true || isTeamProvisioning === true || isLaunchSettling === true; - const hasRestartMemberControl = - !isRemoved && - !isLeadMember(member) && - Boolean(onRestartMember) && - hasLiveLaunchControls && - runtimeEntry?.restartable !== false; - const openCodeRelaunchActionable = isOpenCodeRelaunchActionable({ - member, - spawnEntry, + launchState: spawnLaunchState, + livenessSource: spawnLivenessSource, + spawnEntry, + runtimeEntry, + }), + [ + member.name, runtimeEntry, - }); - const canRelaunchOpenCode = hasRestartMemberControl && openCodeRelaunchActionable; - const canRetryLaunch = - (showFailedLaunchBadge || showSkippedLaunchBadge) && hasRestartMemberControl; - const canSkipFailedLaunch = - showFailedLaunchBadge && - !isLeadMember(member) && - Boolean(onSkipMemberForLaunch) && - hasLiveLaunchControls; - const showRuntimeAdvisoryBadge = - !isRemoved && - Boolean(runtimeAdvisoryLabel) && - !showLaunchBadge && - !isFailedLaunch && - !isSkippedLaunch && - (Boolean(activityTask) || !isAwaitingReply); - const restartActionIdleLabel = canRelaunchOpenCode ? 'Relaunch OpenCode' : 'Retry teammate'; - const restartActionBusyLabel = canRelaunchOpenCode - ? 'Relaunching OpenCode teammate' - : 'Retrying teammate'; - const restartActionErrorFallback = canRelaunchOpenCode - ? 'Failed to relaunch OpenCode teammate' - : 'Failed to retry teammate'; - const handleRestartMember = async ( - event: React.MouseEvent - ): Promise => { - event.preventDefault(); - event.stopPropagation(); - if (!onRestartMember || retryingLaunch) { - return; - } - setRetryLaunchError(null); - setRetryingLaunch(true); - try { - await onRestartMember(member.name); - } catch (error) { - setRetryLaunchError(error instanceof Error ? error.message : restartActionErrorFallback); - } finally { - setRetryingLaunch(false); - } - }; - const handleSkipFailedLaunch = async ( - event: React.MouseEvent - ): Promise => { - event.preventDefault(); - event.stopPropagation(); - if (!onSkipMemberForLaunch || skippingLaunch) { - return; - } - setSkipLaunchError(null); - setSkippingLaunch(true); - try { - await onSkipMemberForLaunch(member.name); - } catch (error) { - setSkipLaunchError(error instanceof Error ? error.message : 'Failed to skip teammate'); - } finally { - setSkippingLaunch(false); - } - }; + runtimeRunId, + selectedTeamName, + spawnEntry, + spawnLaunchState, + spawnLivenessSource, + spawnStatus, + ] + ); + const showCopyDiagnostics = + !isRemoved && + hasMemberLaunchDiagnosticsError(launchDiagnosticsPayload) && + hasMemberLaunchDiagnosticsDetails(launchDiagnosticsPayload); + const isFailedLaunch = spawnStatus === 'error' || spawnLaunchState === 'failed_to_start'; + const isSkippedLaunch = + spawnStatus === 'skipped' || + spawnLaunchState === 'skipped_for_launch' || + spawnEntry?.skippedForLaunch === true; + const showFailedLaunchBadge = !isRemoved && isFailedLaunch; + const showSkippedLaunchBadge = !isRemoved && isSkippedLaunch; + const hasLiveLaunchControls = + isTeamAlive === true || isTeamProvisioning === true || isLaunchSettling === true; + const hasRestartMemberControl = + !isRemoved && + !isLeadMember(member) && + Boolean(onRestartMember) && + hasLiveLaunchControls && + runtimeEntry?.restartable !== false; + const openCodeRelaunchActionable = isOpenCodeRelaunchActionable({ + member, + spawnEntry, + runtimeEntry, + }); + const canRelaunchOpenCode = hasRestartMemberControl && openCodeRelaunchActionable; + const canRetryLaunch = + (showFailedLaunchBadge || showSkippedLaunchBadge) && hasRestartMemberControl; + const canSkipFailedLaunch = + showFailedLaunchBadge && + !isLeadMember(member) && + Boolean(onSkipMemberForLaunch) && + hasLiveLaunchControls; + const showRuntimeAdvisoryBadge = + !isRemoved && + Boolean(runtimeAdvisoryLabel) && + !showLaunchBadge && + !isFailedLaunch && + !isSkippedLaunch && + (Boolean(activityTask) || !isAwaitingReply); + const restartActionIdleLabel = canRelaunchOpenCode ? 'Relaunch OpenCode' : 'Retry teammate'; + const restartActionBusyLabel = canRelaunchOpenCode + ? 'Relaunching OpenCode teammate' + : 'Retrying teammate'; + const restartActionErrorFallback = canRelaunchOpenCode + ? 'Failed to relaunch OpenCode teammate' + : 'Failed to retry teammate'; + const handleRestartMember = async (event: React.MouseEvent): Promise => { + event.preventDefault(); + event.stopPropagation(); + if (!onRestartMember || retryingLaunch) { + return; + } + setRetryLaunchError(null); + setRetryingLaunch(true); + try { + await onRestartMember(member.name); + } catch (error) { + setRetryLaunchError(error instanceof Error ? error.message : restartActionErrorFallback); + } finally { + setRetryingLaunch(false); + } + }; + const handleSkipFailedLaunch = async ( + event: React.MouseEvent + ): Promise => { + event.preventDefault(); + event.stopPropagation(); + if (!onSkipMemberForLaunch || skippingLaunch) { + return; + } + setSkipLaunchError(null); + setSkippingLaunch(true); + try { + await onSkipMemberForLaunch(member.name); + } catch (error) { + setSkipLaunchError(error instanceof Error ? error.message : 'Failed to skip teammate'); + } finally { + setSkippingLaunch(false); + } + }; - return ( + return ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick?.(); + } + }} > -
{ - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onClick?.(); - } - }} - > -
-
-
-
- {member.name} -
- +
+
+
+
+ {member.name}
-
-
- - {displayMemberName(member.name)} + +
+
+
+ + {displayMemberName(member.name)} + + {member.gitBranch && !showWorkspaceBadge ? ( + + + {member.gitBranch} - {member.gitBranch && !showWorkspaceBadge ? ( - - - {member.gitBranch} - - ) : null} - {showWorkspaceBadge ? ( - - - - worktree - - - -
- {workspaceTooltipLines.map((line) => ( -

- {line} -

- ))} -
-
-
- ) : null} - {currentTask ? ( - - ) : null} - {reviewTask ? ( - - ) : null} - {!activityTask && isAwaitingReply ? ( - <> - {runtimeAdvisoryTone === 'error' ? ( - - ) : ( - - )} - - {runtimeAdvisoryLabel ?? 'awaiting reply'} - - - ) : null} -
- {showStartingSkeleton ? ( - + {showStartingSkeleton ? ( + ); }; diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index 5e597278..b3ab27f7 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -736,7 +736,7 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element { ); } - const createDialogElement = ( + const createDialogElement = showCreateDialog && ( ); - const launchDialogElement = ( + const launchDialogElement = launchDialogOpen && ( - - + {dialogOpen && ( + + + + )}
); }; From bb03a12a482ce432040d17251c073a05fe66da03 Mon Sep 17 00:00:00 2001 From: Mike Date: Sun, 3 May 2026 14:53:03 +0500 Subject: [PATCH 14/14] fix(perf): suppress Show raw toggle in bare mode MarkdownViewer --- src/renderer/components/chat/viewers/MarkdownViewer.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index 6adeacfb..90faaf38 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -1183,8 +1183,8 @@ export const MarkdownViewer: React.FC = React.memo(function
)} - {/* Show raw toggle for no-label path */} - {!label && ( + {/* Show raw toggle for no-label path (skip in bare mode) */} + {!label && !bare && (