From 9a1b01b2b6b7b5ea160034d77ff4cca8a285cdb4 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 7 May 2026 17:16:06 +0300 Subject: [PATCH] chore(runtime): bump runtime lock to 0.0.22 --- .../page-2026-05-07T11-17-03-761Z.yml | 6 + graph-log-preview-smoke.png | Bin 0 -> 68578 bytes .../agent-graph/src/layout/stableSlots.ts | 19 +- runtime.lock.json | 12 +- .../renderer/ui/GraphMemberLogPreviewHud.tsx | 157 +++++- src/features/codex-account/contracts/dto.ts | 1 + .../composition/createCodexAccountFeature.ts | 1 + .../CodexLoginSessionManager.ts | 12 + .../memberLogPreviewExtractor.test.ts | 453 ++++++++++++++++- .../policies/memberLogPreviewExtractor.ts | 481 +++++++++++++++++- .../components/dashboard/CliStatusBanner.tsx | 46 +- .../runtime/CodexLoginLinkCopyButton.tsx | 56 ++ .../runtime/ProviderRuntimeSettingsDialog.tsx | 59 ++- .../components/sidebar/GlobalTaskList.tsx | 9 + .../components/sidebar/SidebarTaskItem.tsx | 2 + .../components/sidebar/TaskContextMenu.tsx | 14 +- .../components/team/TeamDetailView.tsx | 1 + .../team/dialogs/CodexReconnectPrompt.tsx | 101 ++++ .../team/dialogs/CreateTeamDialog.tsx | 68 +-- .../team/dialogs/LaunchTeamDialog.tsx | 72 +-- .../team/dialogs/ProjectPathSelector.tsx | 62 ++- .../ProvisioningProviderStatusList.tsx | 4 +- .../team/dialogs/TeamModelSelector.tsx | 6 +- .../team/dialogs/projectPathOptions.ts | 16 +- .../team/dialogs/projectPathProjects.ts | 140 +++++ .../team/members/CurrentTaskIndicator.tsx | 81 ++- .../components/team/members/MemberCard.tsx | 13 + .../team/members/MemberHoverCard.tsx | 18 +- .../components/team/members/MemberList.tsx | 230 ++++++++- src/renderer/services/commentReadStorage.ts | 53 +- src/renderer/store/index.ts | 23 +- ...teamModelAvailability.codexCatalog.test.ts | 54 +- src/renderer/utils/memberActivityTimer.ts | 374 ++++++++++++++ src/renderer/utils/memberHelpers.ts | 48 ++ src/renderer/utils/teamModelCatalog.ts | 2 + .../main/CodexLoginSessionManager.test.ts | 3 + .../main/createCodexAccountFeature.test.ts | 5 + .../members/CurrentTaskIndicator.test.tsx | 68 +++ .../team/members/MemberHoverCard.test.ts | 56 +- .../team/members/MemberList.test.ts | 73 +++ .../GraphMemberLogPreviewHud.test.tsx | 115 ++++- .../agent-graph/useGraphSimulation.test.ts | 10 +- .../utils/memberActivityTimer.test.ts | 284 +++++++++++ test/renderer/utils/memberHelpers.test.ts | 68 +++ 44 files changed, 3191 insertions(+), 185 deletions(-) create mode 100644 .playwright-mcp/page-2026-05-07T11-17-03-761Z.yml create mode 100644 graph-log-preview-smoke.png create mode 100644 src/renderer/components/runtime/CodexLoginLinkCopyButton.tsx create mode 100644 src/renderer/components/team/dialogs/CodexReconnectPrompt.tsx create mode 100644 src/renderer/components/team/dialogs/projectPathProjects.ts create mode 100644 src/renderer/utils/memberActivityTimer.ts create mode 100644 test/renderer/components/team/members/CurrentTaskIndicator.test.tsx create mode 100644 test/renderer/utils/memberActivityTimer.test.ts diff --git a/.playwright-mcp/page-2026-05-07T11-17-03-761Z.yml b/.playwright-mcp/page-2026-05-07T11-17-03-761Z.yml new file mode 100644 index 00000000..80eb866f --- /dev/null +++ b/.playwright-mcp/page-2026-05-07T11-17-03-761Z.yml @@ -0,0 +1,6 @@ +- generic [ref=e1]: + - img + - img [ref=e2] + - generic [ref=e12]: + - generic [ref=e13]: Agent Teams AI + - generic [ref=e15]: Get more done by doing less. \ No newline at end of file diff --git a/graph-log-preview-smoke.png b/graph-log-preview-smoke.png new file mode 100644 index 0000000000000000000000000000000000000000..b8aec00b6f3d88f113c3cc084acb583e48b18153 GIT binary patch literal 68578 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 literal 0 HcmV?d00001 diff --git a/packages/agent-graph/src/layout/stableSlots.ts b/packages/agent-graph/src/layout/stableSlots.ts index 5f8672c2..a08ea95c 100644 --- a/packages/agent-graph/src/layout/stableSlots.ts +++ b/packages/agent-graph/src/layout/stableSlots.ts @@ -128,6 +128,7 @@ const SLOT_GEOMETRY = { const PROCESS_RAIL_NODE_GAP = 42; const PROCESS_RAIL_NODE_FOOTPRINT = 28; const GEOMETRY_EPSILON = 0.001; +const FEED_HEADER_BOTTOM_GAP = 4; const SMALL_TEAM_CARDINAL_RADIUS_STEP = 24; const SMALL_TEAM_CARDINAL_VERTICAL_PADDING = 77.7; const GRID_UNDER_LEAD_COLUMN_COUNT = 2; @@ -372,7 +373,7 @@ function buildOwnerFootprint(args: { const boardBandHeight = Math.max( activityColumnHeight, logColumnHeight, - SLOT_GEOMETRY.kanbanBandHeight + SLOT_GEOMETRY.kanbanBandHeight + getKanbanBandTopInset({ activityColumnWidth, logColumnWidth }) ); const innerContentWidth = Math.max(SLOT_GEOMETRY.ownerMinWidth, processBandWidth, boardBandWidth); const slotWidth = innerContentWidth + SLOT_GEOMETRY.memberSlotInnerPadding * 2; @@ -1362,9 +1363,10 @@ function buildSlotFrameAtOwnerAnchor( footprint.activityColumnWidth > 0 || footprint.logColumnWidth > 0 ? SLOT_GEOMETRY.boardColumnGap : 0; + const kanbanBandTopInset = getKanbanBandTopInset(footprint); const kanbanBandRect = createRect( logColumnRect.right + feedToKanbanGap, - boardBandRect.top, + boardBandRect.top + kanbanBandTopInset, footprint.kanbanBandWidth, footprint.kanbanBandHeight ); @@ -1390,6 +1392,19 @@ function getOwnerAnchorTopOffset(): number { return SLOT_GEOMETRY.memberSlotInnerPadding + SLOT_GEOMETRY.ownerBandHeight / 2; } +function getKanbanBandTopInset(args: { + activityColumnWidth: number; + logColumnWidth: number; +}): number { + if (args.activityColumnWidth <= 0 && args.logColumnWidth <= 0) { + return 0; + } + + const feedCardTopInset = ACTIVITY_LANE.headerHeight + FEED_HEADER_BOTTOM_GAP; + const taskPillTopInset = KANBAN_ZONE.headerHeight - TASK_PILL.height / 2; + return Math.max(0, feedCardTopInset - taskPillTopInset); +} + function buildCandidateAssignments(maxRingExclusive: number): GraphOwnerSlotAssignment[] { const candidates: GraphOwnerSlotAssignment[] = []; for (let ringIndex = 0; ringIndex < maxRingExclusive; ringIndex += 1) { diff --git a/runtime.lock.json b/runtime.lock.json index 7c6fc290..8f4467c8 100644 --- a/runtime.lock.json +++ b/runtime.lock.json @@ -1,27 +1,27 @@ { - "version": "0.0.21", - "sourceRef": "v0.0.21", + "version": "0.0.22", + "sourceRef": "v0.0.22", "sourceRepository": "777genius/agent_teams_orchestrator", "releaseRepository": "777genius/claude_agent_teams_ui", "releaseTag": "v1.2.0", "assets": { "darwin-arm64": { - "file": "agent-teams-runtime-darwin-arm64-v0.0.21.tar.gz", + "file": "agent-teams-runtime-darwin-arm64-v0.0.22.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "darwin-x64": { - "file": "agent-teams-runtime-darwin-x64-v0.0.21.tar.gz", + "file": "agent-teams-runtime-darwin-x64-v0.0.22.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "linux-x64": { - "file": "agent-teams-runtime-linux-x64-v0.0.21.tar.gz", + "file": "agent-teams-runtime-linux-x64-v0.0.22.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "win32-x64": { - "file": "agent-teams-runtime-win32-x64-v0.0.21.zip", + "file": "agent-teams-runtime-win32-x64-v0.0.22.zip", "archiveKind": "zip", "binaryName": "claude-multimodel.exe" } diff --git a/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx b/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx index 26f0e196..9a298332 100644 --- a/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx +++ b/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx @@ -27,6 +27,7 @@ import type { const LOG_PREVIEW_FALLBACK_WIDTH = 260; const LOG_PREVIEW_FALLBACK_HEIGHT = 292; +const NEW_LOG_HIGHLIGHT_MS = 1_000; interface StableRectLike { left: number; @@ -75,7 +76,12 @@ function formatRelativeTime(timestamp: string): string { function itemIcon(item: MemberLogPreviewItem): React.JSX.Element { const className = 'size-3.5 shrink-0'; const title = item.title.trim().toLowerCase(); + if (item.tone === 'error') { + return ; + } if ( + title.includes('message') || + title.includes('comment') || title === 'send message' || title === 'message sent' || title === 'add comment' || @@ -83,9 +89,6 @@ function itemIcon(item: MemberLogPreviewItem): React.JSX.Element { ) { return ; } - if (item.tone === 'error') { - return ; - } if (item.kind === 'tool_result') { return ; } @@ -106,6 +109,14 @@ function resolveEmptyText(preview: MemberLogPreviewMember | undefined, loading: return 'No recent logs'; } +function compactDisplayTitle(item: MemberLogPreviewItem): string { + const title = item.title.trim(); + if (item.kind === 'tool_result' && title.toLowerCase().endsWith(' result')) { + return title.slice(0, -' result'.length).trim() || title; + } + return title; +} + function setShellHidden(shell: HTMLDivElement): void { shell.style.opacity = '0'; shell.style.pointerEvents = 'none'; @@ -125,7 +136,12 @@ export const GraphMemberLogPreviewHud = ({ const worldLayerRef = useRef(null); const shellRefs = useRef(new Map()); const visibleKeyRef = useRef(''); + const knownItemIdsByMemberRef = useRef(new Map>()); + const highlightTimersRef = useRef(new Map>()); const [visibleMemberNames, setVisibleMemberNames] = useState([]); + const [highlightedItemIds, setHighlightedItemIds] = useState>( + () => new Set() + ); const { teamData } = useGraphActivityContext(teamName); const members = teamData?.members ?? []; const laneIdsByMember = useMemo(() => buildGraphLogPreviewLaneIdsByMember(members), [members]); @@ -155,6 +171,69 @@ export const GraphMemberLogPreviewHud = ({ [onOpenMemberProfile] ); + useEffect(() => { + knownItemIdsByMemberRef.current.clear(); + setHighlightedItemIds(new Set()); + for (const timer of highlightTimersRef.current.values()) { + clearTimeout(timer); + } + highlightTimersRef.current.clear(); + }, [teamName]); + + useEffect(() => { + return () => { + for (const timer of highlightTimersRef.current.values()) { + clearTimeout(timer); + } + highlightTimersRef.current.clear(); + }; + }, []); + + useEffect(() => { + if (!enabled) return; + + const newItemIds: string[] = []; + for (const [memberKey, preview] of previewsByMember) { + const currentIds = new Set(preview.items.map((item) => item.id)); + const knownIds = knownItemIdsByMemberRef.current.get(memberKey); + if (knownIds) { + for (const itemId of currentIds) { + if (!knownIds.has(itemId)) { + newItemIds.push(itemId); + } + } + } + knownItemIdsByMemberRef.current.set(memberKey, currentIds); + } + + if (newItemIds.length === 0) return; + + setHighlightedItemIds((current) => { + const next = new Set(current); + for (const itemId of newItemIds) { + next.add(itemId); + } + return next; + }); + + for (const itemId of newItemIds) { + const existingTimer = highlightTimersRef.current.get(itemId); + if (existingTimer) { + clearTimeout(existingTimer); + } + const timer = setTimeout(() => { + highlightTimersRef.current.delete(itemId); + setHighlightedItemIds((current) => { + if (!current.has(itemId)) return current; + const next = new Set(current); + next.delete(itemId); + return next; + }); + }, NEW_LOG_HIGHLIGHT_MS); + highlightTimersRef.current.set(itemId, timer); + } + }, [enabled, previewsByMember]); + useLayoutEffect(() => { if (!enabled || ownerNodes.length === 0) { for (const shell of shellRefs.current.values()) { @@ -285,29 +364,57 @@ export const GraphMemberLogPreviewHud = ({ }, [enabled, forwardWheelToGraph, ownerNodes]); const renderItem = useCallback( - (memberName: string, item: MemberLogPreviewItem) => ( - - ), - [openLogs] + + + + {displayTitle} + + {relativeTime ? ( + + {relativeTime} + + ) : null} + + + {previewText} + + + ); + }, + [highlightedItemIds, openLogs] ); if (!enabled || ownerNodes.length === 0) { diff --git a/src/features/codex-account/contracts/dto.ts b/src/features/codex-account/contracts/dto.ts index 341ab757..7e8b8361 100644 --- a/src/features/codex-account/contracts/dto.ts +++ b/src/features/codex-account/contracts/dto.ts @@ -62,6 +62,7 @@ export interface CodexLoginStateDto { status: CodexAccountLoginStatus; error: string | null; startedAt: string | null; + authUrl?: string | null; } export interface CodexRuntimeContextDto { diff --git a/src/features/codex-account/main/composition/createCodexAccountFeature.ts b/src/features/codex-account/main/composition/createCodexAccountFeature.ts index 728e4c16..05cb12a3 100644 --- a/src/features/codex-account/main/composition/createCodexAccountFeature.ts +++ b/src/features/codex-account/main/composition/createCodexAccountFeature.ts @@ -692,6 +692,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade { status: 'idle', error: loginState.status === 'failed' ? loginState.error : null, startedAt: null, + authUrl: null, }; } diff --git a/src/features/codex-account/main/infrastructure/CodexLoginSessionManager.ts b/src/features/codex-account/main/infrastructure/CodexLoginSessionManager.ts index be71bef5..81551872 100644 --- a/src/features/codex-account/main/infrastructure/CodexLoginSessionManager.ts +++ b/src/features/codex-account/main/infrastructure/CodexLoginSessionManager.ts @@ -26,6 +26,7 @@ export class CodexLoginSessionManager { status: 'idle', error: null, startedAt: null, + authUrl: null, }; private pendingStartToken: symbol | null = null; private activeSession: { @@ -71,6 +72,7 @@ export class CodexLoginSessionManager { status: 'starting', error: null, startedAt: new Date().toISOString(), + authUrl: null, }); try { @@ -135,6 +137,7 @@ export class CodexLoginSessionManager { status: 'pending', error: null, startedAt: this.state.startedAt, + authUrl: authUrl.toString(), }); await shell.openExternal(authUrl.toString()); @@ -158,6 +161,7 @@ export class CodexLoginSessionManager { status: 'failed', error: error instanceof Error ? error.message : String(error), startedAt: this.state.startedAt, + authUrl: this.state.authUrl, }); throw error; } @@ -170,6 +174,7 @@ export class CodexLoginSessionManager { status: 'cancelled', error: null, startedAt: null, + authUrl: null, }); this.emitSettled(); return; @@ -180,6 +185,7 @@ export class CodexLoginSessionManager { status: 'cancelled', error: null, startedAt: null, + authUrl: null, }); return; } @@ -207,6 +213,7 @@ export class CodexLoginSessionManager { status: 'cancelled', error: null, startedAt: null, + authUrl: null, }); this.emitSettled(); } @@ -221,6 +228,7 @@ export class CodexLoginSessionManager { status: 'idle', error: null, startedAt: null, + authUrl: null, }); return; } @@ -234,6 +242,7 @@ export class CodexLoginSessionManager { status: 'idle', error: null, startedAt: null, + authUrl: null, }); } @@ -255,12 +264,14 @@ export class CodexLoginSessionManager { status: 'idle', error: null, startedAt: null, + authUrl: null, }); } else { this.setState({ status: 'failed', error: notification.error ?? 'ChatGPT login failed.', startedAt: this.state.startedAt, + authUrl: this.state.authUrl, }); } @@ -281,6 +292,7 @@ export class CodexLoginSessionManager { status: 'failed', error: errorMessage, startedAt: this.state.startedAt, + authUrl: this.state.authUrl, }); this.emitSettled(); } diff --git a/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewExtractor.test.ts b/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewExtractor.test.ts index da45a508..a8bc4e24 100644 --- a/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewExtractor.test.ts +++ b/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewExtractor.test.ts @@ -53,6 +53,76 @@ describe('memberLogPreviewExtractor', () => { expect(result.items[1]?.preview).toBe('older answer'); }); + it('extracts readable inbound task and comment messages without agent-only blocks', () => { + const result = extractMemberLogPreviewItems({ + provider: 'opencode_runtime', + maxItems: 3, + textLimit: 160, + messages: [ + message({ + uuid: 'assigned', + type: 'user', + role: 'user', + timestamp: '2026-04-01T10:00:00.000Z', + content: `New task assigned to you: #01d7462a *Calculator - final build and test command* + + +Hidden tool protocol that must not be rendered. + + +Description: +Run final validation.`, + }), + message({ + uuid: 'comment', + type: 'user', + role: 'user', + timestamp: '2026-04-01T10:01:00.000Z', + content: `**Comment on task #1dcfefd2** _Calculator - logic smoke checklist_ + +> Logic smoke check passed. + + +Reply to this comment using MCP tool task_add_comment. +`, + }), + ], + }); + + expect(result.items[0]).toMatchObject({ + kind: 'text', + title: 'Comment received', + preview: '#1dcfefd2: Logic smoke check passed.', + }); + expect(result.items[1]).toMatchObject({ + kind: 'text', + title: 'Task assigned', + preview: '#01d7462a Calculator - final build and test command', + }); + expect(JSON.stringify(result.items)).not.toContain('info_for_agent'); + expect(JSON.stringify(result.items)).not.toContain('task_add_comment'); + }); + + it('skips meta tool-result user messages for inbound text extraction', () => { + const result = extractMemberLogPreviewItems({ + provider: 'opencode_runtime', + maxItems: 3, + textLimit: 160, + messages: [ + message({ + uuid: 'meta', + type: 'user', + role: 'user', + isMeta: true, + timestamp: '2026-04-01T10:00:00.000Z', + content: 'Internal runtime metadata', + }), + ], + }); + + expect(result.items).toEqual([]); + }); + it('extracts tool_use input and tool_result output without rendering huge payloads', () => { const hugeOutput = 'x'.repeat(10_000); const result = extractMemberLogPreviewItems({ @@ -95,7 +165,7 @@ describe('memberLogPreviewExtractor', () => { expect(result.items[0]).toMatchObject({ kind: 'tool_result', - title: 'Tool error', + title: 'Bash error', tone: 'error', laneId: 'secondary:opencode:alice', }); @@ -166,15 +236,64 @@ describe('memberLogPreviewExtractor', () => { title: 'Message sent', preview: 'Message sent to team-lead - #abc done', }); + expect(result.items).toHaveLength(1); + expect(JSON.stringify(result.items)).not.toContain('deliveredToInbox'); + }); + + it('keeps known tool names on structured error payloads', () => { + const result = extractMemberLogPreviewItems({ + provider: 'opencode_runtime', + maxItems: 3, + textLimit: 160, + messages: [ + message({ + uuid: 'send-call', + timestamp: '2026-04-01T10:00:00.000Z', + content: [ + { + type: 'tool_use', + id: 'tool-send', + name: 'agent-teams_message_send', + input: { + to: 'team-lead', + summary: '#abc done', + }, + }, + ], + }), + message({ + uuid: 'send-result', + type: 'user', + role: 'user', + timestamp: '2026-04-01T10:01:00.000Z', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-send', + content: { + success: false, + message: 'Delivery failed', + }, + }, + ], + }), + ], + }); + + expect(result.items[0]).toMatchObject({ + kind: 'tool_result', + title: 'Send message error', + preview: 'Delivery failed', + tone: 'error', + }); expect(result.items[1]).toMatchObject({ kind: 'tool_use', title: 'Send message', preview: 'to team-lead: #abc done', }); - expect(JSON.stringify(result.items)).not.toContain('deliveredToInbox'); }); - it('formats task comment result payloads without raw JSON noise', () => { + it('formats orphan comment result payloads without guessing add vs read semantics', () => { const result = extractMemberLogPreviewItems({ provider: 'claude_transcript', maxItems: 3, @@ -211,12 +330,119 @@ describe('memberLogPreviewExtractor', () => { expect(result.items).toHaveLength(1); expect(result.items[0]).toMatchObject({ kind: 'tool_result', - title: 'Comment added', + title: 'Comment', preview: 'Comment by tom on #task-799: Done with UI review', }); expect(JSON.stringify(result.items)).not.toContain('"comment"'); }); + it('uses tool context to name comment add results precisely', () => { + const result = extractMemberLogPreviewItems({ + provider: 'claude_transcript', + maxItems: 3, + textLimit: 160, + messages: [ + message({ + uuid: 'comment-call', + timestamp: '2026-04-01T10:00:00.000Z', + content: [ + { + type: 'tool_use', + id: 'tool-comment', + name: 'mcp__agent-teams__task_add_comment', + input: { + taskId: 'task-799', + text: 'Done with UI review', + }, + }, + ], + }), + message({ + uuid: 'comment-result', + type: 'user', + role: 'user', + timestamp: '2026-04-01T10:01:00.000Z', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-comment', + content: JSON.stringify({ + taskId: 'task-799', + comment: { + id: 'comment-1', + author: 'tom', + text: 'Done with UI review', + }, + }), + }, + ], + }), + ], + }); + + expect(result.items[0]).toMatchObject({ + kind: 'tool_result', + title: 'Comment added', + preview: 'Comment by tom on #task-799: Done with UI review', + }); + expect(result.items).toHaveLength(1); + }); + + it('distinguishes read-comment results from add-comment results', () => { + const result = extractMemberLogPreviewItems({ + provider: 'claude_transcript', + maxItems: 3, + textLimit: 160, + messages: [ + message({ + uuid: 'comment-call', + timestamp: '2026-04-01T10:00:00.000Z', + content: [ + { + type: 'tool_use', + id: 'tool-comment', + name: 'mcp__agent-teams__task_get_comment', + input: { + taskId: 'task-799', + commentId: '47697aeb', + }, + }, + ], + }), + message({ + uuid: 'comment-result', + type: 'user', + role: 'user', + timestamp: '2026-04-01T10:01:00.000Z', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-comment', + content: JSON.stringify({ + agent_teams_task_get_comment_response: { + taskId: 'task-799', + comment: { + id: '47697aeb-3734-4d5c-ae3e-42fafcbdea0b', + author: 'tom', + text: 'Готово по UI', + }, + }, + }), + }, + ], + }), + ], + }); + + expect(result.items[0]).toMatchObject({ + kind: 'tool_result', + title: 'Comment loaded', + preview: 'Comment by tom on #task-799: Готово по UI', + }); + expect(result.items).toHaveLength(1); + expect(JSON.stringify(result.items)).not.toContain('Comment added'); + }); + it('formats plain board tool results through the paired tool_use context', () => { const result = extractMemberLogPreviewItems({ provider: 'claude_transcript', @@ -257,6 +483,47 @@ describe('memberLogPreviewExtractor', () => { preview: 'Completed #abc12345', toolName: 'mcp__agent-teams__task_complete', }); + expect(result.items).toHaveLength(1); + }); + + it('keeps board tool input visible when the paired successful result is empty', () => { + const result = extractMemberLogPreviewItems({ + provider: 'claude_transcript', + maxItems: 3, + textLimit: 160, + messages: [ + message({ + uuid: 'complete-call', + timestamp: '2026-04-01T10:00:00.000Z', + content: [ + { + type: 'tool_use', + id: 'tool-complete', + name: 'mcp__agent-teams__task_complete', + input: { teamName: 'demo', taskId: 'abc12345', actor: 'tom' }, + }, + ], + }), + message({ + uuid: 'complete-result', + type: 'user', + role: 'user', + timestamp: '2026-04-01T10:01:00.000Z', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-complete', + content: '', + }, + ], + }), + ], + }); + + expect(result.items[0]).toMatchObject({ + kind: 'tool_result', + title: 'Complete task result', + }); expect(result.items[1]).toMatchObject({ kind: 'tool_use', title: 'Complete task', @@ -284,7 +551,7 @@ describe('memberLogPreviewExtractor', () => { task: { id: 'abc12345-0000-0000-0000-000000000000', displayId: 'abc12345', - title: 'Fix preview alignment', + subject: 'Fix preview alignment', status: 'in_progress', owner: 'tom', }, @@ -304,6 +571,182 @@ describe('memberLogPreviewExtractor', () => { expect(JSON.stringify(result.items)).not.toContain('agent_teams_task_get_response'); }); + it('formats common board and cross-team tool previews compactly', () => { + const result = extractMemberLogPreviewItems({ + provider: 'opencode_runtime', + maxItems: 3, + textLimit: 160, + messages: [ + message({ + uuid: 'cross-call', + timestamp: '2026-04-01T10:00:00.000Z', + content: [ + { + type: 'tool_use', + id: 'tool-cross', + name: 'agent-teams_cross_team_send', + input: { + toTeam: 'design-team', + summary: 'Need UI review', + text: 'Please review compact logs', + }, + }, + { + type: 'tool_use', + id: 'tool-link', + name: 'agent-teams_task_link', + input: { + taskId: 'abc12345', + targetId: 'def67890', + relationship: 'blocked-by', + }, + }, + ], + }), + message({ + uuid: 'cross-result', + type: 'user', + role: 'user', + timestamp: '2026-04-01T10:01:00.000Z', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-cross', + content: 'ok', + }, + ], + }), + ], + }); + + expect(result.items[0]).toMatchObject({ + kind: 'tool_result', + title: 'Cross-team message', + preview: 'to design-team: Need UI review', + }); + expect(result.items[1]).toMatchObject({ + kind: 'tool_use', + title: 'Link tasks', + preview: '#abc12345 blocked-by #def67890', + }); + expect(result.items).toHaveLength(2); + }); + + it('uses concrete names for generic runtime tool results', () => { + const result = extractMemberLogPreviewItems({ + provider: 'opencode_runtime', + maxItems: 3, + textLimit: 160, + messages: [ + message({ + uuid: 'bash-call', + timestamp: '2026-04-01T10:00:00.000Z', + content: [ + { + type: 'tool_use', + id: 'tool-bash', + name: 'bash', + input: { + command: 'pnpm test', + }, + }, + ], + }), + message({ + uuid: 'bash-result', + type: 'user', + role: 'user', + timestamp: '2026-04-01T10:01:00.000Z', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-bash', + content: 'Tests passed', + }, + ], + }), + ], + }); + + expect(result.items[0]).toMatchObject({ + kind: 'tool_result', + title: 'Bash result', + preview: 'Tests passed', + }); + expect(result.items[1]).toMatchObject({ + kind: 'tool_use', + title: 'Bash', + preview: 'pnpm test', + }); + }); + + it('does not label arbitrary message fields as sent messages', () => { + const result = extractMemberLogPreviewItems({ + provider: 'opencode_runtime', + maxItems: 3, + textLimit: 160, + messages: [ + message({ + uuid: 'generic-result', + type: 'user', + role: 'user', + timestamp: '2026-04-01T10:01:00.000Z', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-generic', + content: { + message: 'generic tool status', + }, + }, + ], + }), + ], + }); + + expect(result.items[0]).toMatchObject({ + kind: 'tool_result', + title: 'Tool result', + preview: 'generic tool status', + }); + }); + + it('formats unknown JSON string results without leaking raw JSON syntax', () => { + const result = extractMemberLogPreviewItems({ + provider: 'opencode_runtime', + maxItems: 3, + textLimit: 160, + messages: [ + message({ + uuid: 'generic-json', + type: 'user', + role: 'user', + timestamp: '2026-04-01T10:01:00.000Z', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-generic', + content: JSON.stringify({ + payload: { + nested: true, + }, + status: 'stored', + count: 2, + }), + }, + ], + }), + ], + }); + + expect(result.items[0]).toMatchObject({ + kind: 'tool_result', + title: 'Tool result', + preview: 'stored', + }); + expect(result.items[0]?.preview).not.toContain('{'); + }); + it('keeps orphan tool results visible because graph preview is diagnostic', () => { const result = extractMemberLogPreviewItems({ provider: 'claude_transcript', diff --git a/src/features/member-log-stream/core/domain/policies/memberLogPreviewExtractor.ts b/src/features/member-log-stream/core/domain/policies/memberLogPreviewExtractor.ts index 9ca89177..fad0ef24 100644 --- a/src/features/member-log-stream/core/domain/policies/memberLogPreviewExtractor.ts +++ b/src/features/member-log-stream/core/domain/policies/memberLogPreviewExtractor.ts @@ -19,6 +19,7 @@ export interface MemberLogPreviewParsedMessage { role?: string; timestamp: Date | string; content: string | MemberLogPreviewContentBlock[]; + isMeta?: boolean; toolCalls?: readonly { id: string; name: string; @@ -57,6 +58,8 @@ interface Candidate { timestampMs: number; order: number; textTruncated: boolean; + toolUseKey?: string; + supersededByResult?: boolean; } const UNKNOWN_TIMESTAMP_MS = 0; @@ -139,6 +142,19 @@ function compactWhitespace(value: string): string { return stripAngleTags(value).replace(/\s+/g, ' ').trim(); } +function removeHiddenInstructionBlocks(value: string): string { + let result = value; + for (const tag of [ + 'info_for_agent', + 'opencode_runtime_identity', + 'opencode_app_message_delivery', + 'system-reminder', + ]) { + result = result.replace(new RegExp(`<${tag}\\b[^>]*>[\\s\\S]*?<\\/${tag}>`, 'gi'), ' '); + } + return result; +} + function looksLikeJsonPayload(value: string): boolean { const trimmed = value.trim(); return trimmed.startsWith('{') || trimmed.startsWith('['); @@ -272,24 +288,89 @@ function canonicalToolNameFromWrapperKey(value: string | undefined): string | nu ); } +function humanizeFallbackToolName(toolName: string): string { + const stripped = canonicalToolName(toolName); + if (!stripped) return 'Tool use'; + const compact = stripped.replace(/[_-]+/g, ' ').trim(); + if (!compact) return toolName.trim() || 'Tool use'; + const lower = compact.toLowerCase(); + if (lower === 'bash' || lower === 'shell') return 'Bash'; + if (lower === 'read') return 'Read'; + if (lower === 'write') return 'Write'; + if (lower === 'edit') return 'Edit'; + if (lower === 'grep') return 'Grep'; + if (lower === 'glob') return 'Glob'; + if (lower === 'ls') return 'List files'; + return compact + .split(' ') + .map((part) => (part.length > 0 ? `${part[0]?.toUpperCase()}${part.slice(1)}` : part)) + .join(' '); +} + function formatToolTitle(toolName: string): string { const canonical = canonicalToolName(toolName); if (canonical === 'sendmessage' || canonical === 'message_send') return 'Send message'; + if (canonical === 'cross_team_send') return 'Cross-team message'; + if (canonical === 'runtime_deliver_message') return 'Runtime delivery'; + if (canonical === 'task_create' || canonical === 'task_create_from_message') return 'Create task'; if (canonical === 'task_complete') return 'Complete task'; if (canonical === 'task_add_comment') return 'Add comment'; if (canonical === 'task_get_comment') return 'Read comment'; if (canonical === 'task_get') return 'Read task'; + if (canonical === 'task_list') return 'List tasks'; + if (canonical === 'task_briefing') return 'Task briefing'; if (canonical === 'task_start') return 'Start task'; if (canonical === 'task_set_status') return 'Set status'; if (canonical === 'task_set_owner') return 'Set owner'; if (canonical === 'task_set_clarification') return 'Set clarification'; + if (canonical === 'task_attach_file') return 'Attach file'; if (canonical === 'task_attach_comment_file') return 'Attach comment file'; + if (canonical === 'task_link') return 'Link tasks'; + if (canonical === 'task_unlink') return 'Unlink tasks'; + if (canonical === 'task_restore') return 'Restore task'; if (canonical === 'review_request') return 'Request review'; if (canonical === 'review_start') return 'Start review'; + if (canonical === 'review_approve') return 'Approve review'; + if (canonical === 'review_request_changes') return 'Request changes'; if (canonical === 'runtime_bootstrap_checkin') return 'Runtime check-in'; if (canonical === 'member_briefing') return 'Member briefing'; if (canonical === 'task_add') return 'Add task'; - return toolName.trim() || 'Tool use'; + if (canonical === 'task_update') return 'Update task'; + if (canonical === 'task_delete') return 'Delete task'; + if (canonical === 'process_list') return 'List processes'; + return humanizeFallbackToolName(toolName); +} + +function formatGenericToolResultTitle( + toolContext: ToolUseContext | undefined, + isError: boolean +): string { + if (!toolContext) { + return isError ? 'Tool error' : 'Tool result'; + } + return `${formatToolTitle(toolContext.name)} ${isError ? 'error' : 'result'}`; +} + +function buildToolUseKey(input: { + provider: MemberLogStreamProvider; + sourceId: string; + toolUseId: string; +}): string { + return [input.provider, input.sourceId, input.toolUseId.trim()].join(':'); +} + +function isToolUseSupersededBySuccessResult(toolName: string): boolean { + const canonical = canonicalToolName(toolName); + return ( + canonical === 'sendmessage' || + canonical === 'message_send' || + canonical === 'cross_team_send' || + canonical === 'runtime_deliver_message' || + canonical === 'runtime_bootstrap_checkin' || + canonical === 'member_briefing' || + canonical.startsWith('task_') || + canonical.startsWith('review_') + ); } function stringField( @@ -326,7 +407,8 @@ function taskRefFromPayload( } function shortTaskSummary(task: Record | undefined): string | null { - const title = stringField(task, 'title') ?? stringField(task, 'name'); + const title = + stringField(task, 'title') ?? stringField(task, 'subject') ?? stringField(task, 'name'); const status = stringField(task, 'status'); const owner = stringField(task, 'owner'); const parts = [title, status ? `status ${status}` : null, owner ? `owner ${owner}` : null].filter( @@ -373,6 +455,64 @@ function formatTaskCommentPayload( return `Comment: ${commentText}`; } +function countArrayField(payload: Record, keys: readonly string[]): number | null { + for (const key of keys) { + const value = payload[key]; + if (Array.isArray(value)) { + return value.length; + } + } + return null; +} + +function formatTaskCollectionPayload(payload: Record): KnownPayloadPreview | null { + const taskCount = countArrayField(payload, ['tasks', 'items', 'actionable']); + const summary = + stringField(payload, 'summary') ?? + stringField(payload, 'message') ?? + stringField(payload, 'text'); + if (taskCount != null) { + return { + title: 'Task list', + text: summary ? `${taskCount} tasks - ${summary}` : `${taskCount} tasks`, + }; + } + return summary ? { title: 'Task list', text: summary } : null; +} + +function formatRelationshipPayload( + payload: Record, + fallbackInput?: Record | null +): string | null { + const sourceRef = taskRefFromPayload(payload, fallbackInput); + const targetRef = formatTaskRef( + stringField(payload, 'targetId') ?? + stringField(payload, 'targetTaskId') ?? + stringField(fallbackInput ?? undefined, 'targetId') ?? + stringField(fallbackInput ?? undefined, 'targetTaskId') + ); + const relationship = + stringField(payload, 'relationship') ?? stringField(fallbackInput ?? undefined, 'relationship'); + if (sourceRef && targetRef && relationship) return `${sourceRef} ${relationship} ${targetRef}`; + if (sourceRef && targetRef) return `${sourceRef} -> ${targetRef}`; + if (sourceRef) return sourceRef; + return targetRef; +} + +function formatReviewChangesText( + payload: Record, + fallbackInput?: Record | null +): string | null { + return ( + stringField(payload, 'comment') ?? + stringField(payload, 'note') ?? + stringField(payload, 'message') ?? + stringField(fallbackInput ?? undefined, 'comment') ?? + stringField(fallbackInput ?? undefined, 'note') ?? + stringField(fallbackInput ?? undefined, 'message') + ); +} + function formatTaskToolPayload( payload: Record, canonicalToolNameValue: string | null, @@ -393,13 +533,42 @@ function formatTaskToolPayload( const filename = stringField(payload, 'filename') ?? stringField(payload, 'fileName') ?? + stringField(payload, 'path') ?? + stringField(payload, 'filePath') ?? stringField(fallbackInput ?? undefined, 'filename') ?? - stringField(fallbackInput ?? undefined, 'fileName'); + stringField(fallbackInput ?? undefined, 'fileName') ?? + stringField(fallbackInput ?? undefined, 'path') ?? + stringField(fallbackInput ?? undefined, 'filePath'); if (canonical === 'task_add_comment') { const text = formatTaskCommentPayload(payload, fallbackInput); return text ? { title: 'Comment added', text } : null; } + if (canonical === 'task_get_comment') { + const text = formatTaskCommentPayload(payload, fallbackInput); + if (text) return { title: 'Comment loaded', text }; + const commentId = + stringField(payload, 'commentId') ?? stringField(fallbackInput ?? undefined, 'commentId'); + if (taskRef && commentId) { + return { title: 'Comment loaded', text: `${commentId} on ${taskRef}` }; + } + return taskRef ? { title: 'Comment loaded', text: `Loaded comment on ${taskRef}` } : null; + } + if (canonical === 'task_create' || canonical === 'task_create_from_message') { + if (taskRef && taskSummary) { + return { title: 'Task created', text: `${taskRef}: ${taskSummary}` }; + } + if (taskRef) return { title: 'Task created', text: `Created ${taskRef}` }; + } + if (canonical === 'task_list' || canonical === 'task_briefing') { + const collectionText = formatTaskCollectionPayload(payload); + if (collectionText) { + return { + title: canonical === 'task_briefing' ? 'Task briefing' : collectionText.title, + text: collectionText.text, + }; + } + } if (canonical === 'task_start') { return taskRef ? { title: 'Task started', text: `Started ${taskRef}` } : null; } @@ -428,6 +597,19 @@ function formatTaskToolPayload( if (taskRef && filename) return { title: 'Comment file', text: `${filename} on ${taskRef}` }; return taskRef ? { title: 'Comment file', text: `Attached file to ${taskRef}` } : null; } + if (canonical === 'task_attach_file') { + if (taskRef && filename) return { title: 'Task file', text: `${filename} on ${taskRef}` }; + return taskRef ? { title: 'Task file', text: `Attached file to ${taskRef}` } : null; + } + if (canonical === 'task_link' || canonical === 'task_unlink') { + const relationshipText = formatRelationshipPayload(payload, fallbackInput); + if (relationshipText) { + return { + title: canonical === 'task_link' ? 'Tasks linked' : 'Tasks unlinked', + text: relationshipText, + }; + } + } if (canonical === 'review_request') { const reviewer = stringField(payload, 'reviewer') ?? stringField(fallbackInput ?? undefined, 'reviewer'); @@ -438,6 +620,21 @@ function formatTaskToolPayload( if (canonical === 'review_start') { return taskRef ? { title: 'Review started', text: `Started review for ${taskRef}` } : null; } + if (canonical === 'review_approve') { + const note = formatReviewChangesText(payload, fallbackInput); + if (taskRef && note) return { title: 'Review approved', text: `${taskRef}: ${note}` }; + return taskRef ? { title: 'Review approved', text: `Approved ${taskRef}` } : null; + } + if (canonical === 'review_request_changes') { + const comment = formatReviewChangesText(payload, fallbackInput); + if (taskRef && comment) return { title: 'Changes requested', text: `${taskRef}: ${comment}` }; + return taskRef + ? { title: 'Changes requested', text: `Requested changes for ${taskRef}` } + : null; + } + if (canonical === 'task_restore') { + return taskRef ? { title: 'Task restored', text: `Restored ${taskRef}` } : null; + } if (taskRef && status) { return { title: 'Task update', text: `Task ${taskRef} ${status}` }; } @@ -510,6 +707,22 @@ function formatMessageSendPayload(payload: Record): string | nu return null; } +function looksLikeMessageSendPayload(payload: Record): boolean { + const routing = asRecord(payload.routing); + const messageRecord = asRecord(payload.message); + if (payload.deliveredToInbox === true || routing) { + return true; + } + return Boolean( + messageRecord && + (stringField(messageRecord, 'to') || + stringField(messageRecord, 'from') || + stringField(messageRecord, 'summary') || + stringField(messageRecord, 'text') || + stringField(messageRecord, 'content')) + ); +} + function formatMessageSendResultFromInput(payload: Record): string | null { const target = stringField(payload, 'to') ?? stringField(payload, 'target'); const summary = @@ -536,6 +749,27 @@ function formatMessageSendInputPayload(payload: Record): string return null; } +function formatCrossTeamPayload(payload: Record): string | null { + const routing = asRecord(payload.routing) ?? undefined; + const target = + stringField(payload, 'toTeam') ?? + stringField(payload, 'targetTeam') ?? + stringField(routing, 'toTeam') ?? + stringField(routing, 'targetTeam') ?? + stringField(routing, 'target'); + const summary = + stringField(payload, 'summary') ?? + stringField(payload, 'message') ?? + stringField(payload, 'text') ?? + stringField(payload, 'content') ?? + stringField(routing, 'summary') ?? + stringField(routing, 'content'); + if (target && summary) return `to ${target}: ${summary}`; + if (target) return `to ${target}`; + if (summary) return summary; + return null; +} + function formatPlainToolResultStatus( value: string, toolContext: ToolUseContext | undefined @@ -552,6 +786,10 @@ function formatPlainToolResultStatus( const text = fallbackInput ? formatMessageSendResultFromInput(fallbackInput) : null; return text ? { title: 'Message sent', text } : null; } + if (toolContext.canonicalName === 'cross_team_send') { + const text = fallbackInput ? formatCrossTeamPayload(fallbackInput) : null; + return text ? { title: 'Cross-team message', text } : null; + } return ( formatTaskToolPayload({}, toolContext.canonicalName, fallbackInput) ?? formatRuntimePayload({}, toolContext.canonicalName, fallbackInput) @@ -568,6 +806,13 @@ function formatTaskToolInputPayload( const owner = stringField(payload, 'owner'); const clarification = stringField(payload, 'clarification'); const reviewer = stringField(payload, 'reviewer'); + const commentId = stringField(payload, 'commentId'); + const filename = + stringField(payload, 'filename') ?? + stringField(payload, 'fileName') ?? + stringField(payload, 'filePath'); + const relationship = formatRelationshipPayload(payload, payload); + const reviewText = formatReviewChangesText(payload, payload); if (canonicalToolNameValue === 'task_add_comment') { if (taskRef && text) return `on ${taskRef}: ${text}`; @@ -575,6 +820,10 @@ function formatTaskToolInputPayload( if (text) return text; return null; } + if (canonicalToolNameValue === 'task_get_comment') { + if (taskRef && commentId) return `${commentId} on ${taskRef}`; + if (taskRef) return `comment on ${taskRef}`; + } if (canonicalToolNameValue === 'task_set_status') { if (taskRef && status) return `${taskRef} -> ${status}`; } @@ -587,6 +836,21 @@ function formatTaskToolInputPayload( if (canonicalToolNameValue === 'review_request') { if (taskRef && reviewer) return `${taskRef} -> ${reviewer}`; } + if ( + canonicalToolNameValue === 'review_approve' || + canonicalToolNameValue === 'review_request_changes' + ) { + if (taskRef && reviewText) return `${taskRef}: ${reviewText}`; + } + if ( + canonicalToolNameValue === 'task_attach_file' || + canonicalToolNameValue === 'task_attach_comment_file' + ) { + if (taskRef && filename) return `${filename} on ${taskRef}`; + } + if (canonicalToolNameValue === 'task_link' || canonicalToolNameValue === 'task_unlink') { + if (relationship) return relationship; + } if (taskRef) return taskRef; return null; } @@ -616,13 +880,24 @@ function formatKnownPayloadPreview( if (runtimeText) { return runtimeText; } - const messageText = formatMessageSendPayload(payload); + if (canonical === 'cross_team_send') { + const crossTeamText = formatCrossTeamPayload(payload); + if (crossTeamText) { + return { title: 'Cross-team message', text: crossTeamText }; + } + } + const messageText = + canonical === 'sendmessage' || + canonical === 'message_send' || + looksLikeMessageSendPayload(payload) + ? formatMessageSendPayload(payload) + : null; if (messageText) { return { title: 'Message sent', text: messageText }; } const commentText = formatTaskCommentPayload(payload); if (commentText) { - return { title: 'Comment added', text: commentText }; + return { title: 'Comment', text: commentText }; } const taskText = formatTaskStatusPayload(payload, fallbackInput); if (taskText) { @@ -646,6 +921,10 @@ function previewUnknownValue( if (plainStatus) { return { ...truncatePreview(plainStatus.text, limit), title: plainStatus.title }; } + const parsed = parseJsonLikeString(value); + if (parsed != null) { + return previewUnknownValue(parsed, limit, priorityKeys, toolContext); + } return truncatePreview(value, limit); } if (typeof value === 'number' || typeof value === 'boolean') { @@ -694,6 +973,13 @@ function previewToolInputValue(toolName: string, value: unknown, limit: number): return truncatePreview(formatted, limit); } } + if (canonical === 'cross_team_send') { + const payload = recordFromUnknown(value); + const formatted = payload ? formatCrossTeamPayload(payload) : null; + if (formatted) { + return truncatePreview(formatted, limit); + } + } const payload = recordFromUnknown(value); if (payload) { const taskFormatted = formatTaskToolInputPayload(canonical, payload); @@ -722,6 +1008,118 @@ function extractTextPreview( return preview.preview.length > 0 ? preview : null; } +function firstQuotedLine(value: string): string | null { + const line = value + .split(/\r?\n/) + .map((item) => item.trim()) + .find((item) => item.startsWith('>')); + return line ? line.replace(/^>\s*/, '').trim() || null : null; +} + +function findLineByPrefix(value: string, prefix: string): string | null { + const normalizedPrefix = prefix.toLowerCase(); + for (const line of value.split(/\r?\n/)) { + const trimmed = line.trim(); + if (trimmed.toLowerCase().startsWith(normalizedPrefix)) { + return trimmed; + } + } + return null; +} + +function parseTaskAssignmentLine(line: string): { taskRef: string; subject?: string } | null { + const prefix = 'New task assigned to you:'; + if (!line.toLowerCase().startsWith(prefix.toLowerCase())) { + return null; + } + const rest = line.slice(prefix.length).trim(); + const [taskRefCandidate = '', ...restParts] = rest.split(/\s+/); + if (!taskRefCandidate.startsWith('#')) { + return null; + } + const restText = restParts.join(' ').trim(); + const firstStar = restText.indexOf('*'); + const secondStar = firstStar >= 0 ? restText.indexOf('*', firstStar + 1) : -1; + const subject = + firstStar >= 0 && secondStar > firstStar + ? restText.slice(firstStar + 1, secondStar).trim() + : restText.replaceAll('*', '').trim(); + return { + taskRef: taskRefCandidate, + ...(subject ? { subject } : {}), + }; +} + +function parseCommentHeadingLine(line: string): { taskRef: string; subject?: string } | null { + const prefix = '**Comment on task '; + if (!line.toLowerCase().startsWith(prefix.toLowerCase())) { + return null; + } + const afterPrefix = line.slice(prefix.length); + const endRef = afterPrefix.indexOf('**'); + if (endRef <= 0) { + return null; + } + const taskRef = afterPrefix.slice(0, endRef).trim(); + if (!taskRef.startsWith('#')) { + return null; + } + const afterRef = afterPrefix.slice(endRef + 2).trim(); + const firstUnderscore = afterRef.indexOf('_'); + const secondUnderscore = firstUnderscore >= 0 ? afterRef.indexOf('_', firstUnderscore + 1) : -1; + const subject = + firstUnderscore >= 0 && secondUnderscore > firstUnderscore + ? afterRef.slice(firstUnderscore + 1, secondUnderscore).trim() + : undefined; + return { + taskRef, + ...(subject ? { subject } : {}), + }; +} + +function extractInboundTextPreview( + content: string | MemberLogPreviewContentBlock[], + textLimit: number +): { title: string; preview: string; truncated: boolean } | null { + const raw = + typeof content === 'string' + ? content + : content + .filter((block): block is Extract => { + return block.type === 'text' && typeof block.text === 'string'; + }) + .map((block) => block.text) + .join('\n'); + const visibleRaw = removeHiddenInstructionBlocks(raw); + const compact = compactWhitespace(visibleRaw); + if (!compact) { + return null; + } + + const assigned = parseTaskAssignmentLine( + findLineByPrefix(visibleRaw, 'New task assigned to you:') ?? '' + ); + if (assigned) { + const taskRef = assigned.taskRef; + const subject = assigned.subject; + const preview = truncatePreview(subject ? `${taskRef} ${subject}` : taskRef, textLimit); + return { title: 'Task assigned', ...preview }; + } + + const comment = parseCommentHeadingLine(findLineByPrefix(visibleRaw, '**Comment on task ') ?? ''); + if (comment) { + const taskRef = comment.taskRef; + const quoted = firstQuotedLine(visibleRaw); + const subject = comment.subject; + const text = quoted ?? subject ?? 'Comment received'; + const preview = truncatePreview(`${taskRef}: ${text}`, textLimit); + return { title: 'Comment received', ...preview }; + } + + const preview = truncatePreview(compact, textLimit); + return preview.preview ? { title: 'Message', ...preview } : null; +} + function isToolUseBlock( block: MemberLogPreviewContentBlock ): block is Extract { @@ -792,6 +1190,13 @@ function resolveMessageRole(message: MemberLogPreviewParsedMessage): string { return message.role ?? message.type ?? ''; } +function messageHasToolResult(message: MemberLogPreviewParsedMessage): boolean { + if ((message.toolResults?.length ?? 0) > 0) { + return true; + } + return Array.isArray(message.content) && message.content.some(isToolResultBlock); +} + function buildItemId(input: { provider: MemberLogStreamProvider; sourceId: string; @@ -824,6 +1229,8 @@ function buildCandidate(input: { laneId?: string; token: string; textTruncated: boolean; + toolUseKey?: string; + supersededByResult?: boolean; }): Candidate { const timestamp = timestampIso(input.message.timestamp); const messageId = input.message.uuid ?? `message-${input.messageIndex}`; @@ -850,6 +1257,8 @@ function buildCandidate(input: { timestampMs: timestampMs(input.message.timestamp), order: input.messageIndex * 1_000 + input.blockIndex, textTruncated: input.textTruncated, + ...(input.toolUseKey ? { toolUseKey: input.toolUseKey } : {}), + ...(input.supersededByResult ? { supersededByResult: true } : {}), }; } @@ -873,6 +1282,11 @@ function collectToolUseCandidates(input: { if (seen.has(id)) return; seen.add(id); const preview = previewToolInputValue(tool.name, tool.input, input.textLimit); + const toolUseKey = buildToolUseKey({ + provider: input.provider, + sourceId: input.sourceId, + toolUseId: id, + }); candidates.push( buildCandidate({ provider: input.provider, @@ -890,6 +1304,8 @@ function collectToolUseCandidates(input: { laneId: input.laneId, token: id, textTruncated: preview.truncated, + toolUseKey, + supersededByResult: isToolUseSupersededBySuccessResult(tool.name), }) ); }; @@ -933,6 +1349,11 @@ function collectToolResultCandidates(input: { if (seen.has(id)) return; seen.add(id); const toolContext = input.toolUseContexts.get(id); + const toolUseKey = buildToolUseKey({ + provider: input.provider, + sourceId: input.sourceId, + toolUseId: id, + }); const preview = previewUnknownValue( result.content, input.textLimit, @@ -940,6 +1361,10 @@ function collectToolResultCandidates(input: { toolContext ); const isError = result.isError === true || preview.title === 'Tool error'; + const title = + preview.title === 'Tool error' + ? formatGenericToolResultTitle(toolContext, true) + : (preview.title ?? formatGenericToolResultTitle(toolContext, isError)); candidates.push( buildCandidate({ provider: input.provider, @@ -948,7 +1373,7 @@ function collectToolResultCandidates(input: { messageIndex: input.messageIndex, blockIndex, kind: 'tool_result', - title: isError ? 'Tool error' : (preview.title ?? 'Tool result'), + title, preview: preview.preview, tone: isError ? 'error' : 'success', toolName: toolContext?.name, @@ -957,6 +1382,7 @@ function collectToolResultCandidates(input: { laneId: input.laneId, token: id, textTruncated: preview.truncated, + toolUseKey, }) ); }; @@ -1078,9 +1504,50 @@ export function extractMemberLogPreviewItems( ); } } + + if (role === 'user' && message.isMeta !== true && !messageHasToolResult(message)) { + const inboundPreview = extractInboundTextPreview(message.content, textLimit); + if (inboundPreview) { + candidates.push( + buildCandidate({ + provider: input.provider, + sourceId, + message, + messageIndex, + blockIndex: 8, + kind: 'text', + title: inboundPreview.title, + preview: inboundPreview.preview, + tone: 'neutral', + sourceLabel: input.sourceLabel, + sessionId: input.sessionId ?? message.sessionId, + laneId: input.laneId, + token: 'inbound-text', + textTruncated: inboundPreview.truncated, + }) + ); + } + } }); - const sorted = [...candidates]; + const successfulResultToolKeys = new Set( + candidates + .filter( + (candidate) => + candidate.item.kind === 'tool_result' && + candidate.item.tone !== 'error' && + Boolean(candidate.item.preview?.trim()) + ) + .map((candidate) => candidate.toolUseKey) + .filter((toolUseKey): toolUseKey is string => Boolean(toolUseKey)) + ); + const compactCandidates = candidates.filter((candidate) => { + if (candidate.item.kind !== 'tool_use') return true; + if (!candidate.supersededByResult || !candidate.toolUseKey) return true; + return !successfulResultToolKeys.has(candidate.toolUseKey); + }); + + const sorted = [...compactCandidates]; sorted.sort((left, right) => { const byTime = right.timestampMs - left.timestampMs; if (byTime !== 0) return byTime; diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx index 4e656dde..1a01a12d 100644 --- a/src/renderer/components/dashboard/CliStatusBanner.tsx +++ b/src/renderer/components/dashboard/CliStatusBanner.tsx @@ -29,6 +29,7 @@ import { isConnectionManagedRuntimeProvider, shouldShowProviderConnectAction, } from '@renderer/components/runtime/providerConnectionUi'; +import { CodexLoginLinkCopyButton } from '@renderer/components/runtime/CodexLoginLinkCopyButton'; import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges'; import { getProviderRuntimeBackendSummary } from '@renderer/components/runtime/ProviderRuntimeBackendSelector'; import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog'; @@ -102,7 +103,7 @@ function getCodexDashboardHint(provider: CliProviderStatus): string | null { } if (codex.login.status === 'starting' || codex.login.status === 'pending') { - return null; + return codex.login.authUrl ? 'Finish ChatGPT login in the browser.' : null; } const usageHint = codex.localActiveChatgptAccountPresent @@ -731,6 +732,8 @@ const InstalledBanner = ({ provider.connection?.codex?.launchAllowed !== true && provider.connection?.codex?.login.status !== 'starting' && provider.connection?.codex?.login.status !== 'pending'; + const codexLoginAuthUrl = provider.connection?.codex?.login.authUrl ?? null; + const showCodexLoginActions = codexNeedsReconnect || Boolean(codexLoginAuthUrl); const disconnectAction = getProviderDisconnectAction(provider); const providerLoading = cliProviderStatusLoading[provider.providerId] === true; const sourceProvider = sourceProviderMap.get(provider.providerId) ?? null; @@ -897,20 +900,33 @@ const InstalledBanner = ({ >
{codexDashboardHint} - {codexNeedsReconnect ? ( - + {showCodexLoginActions ? ( + <> + + + ) : null}
diff --git a/src/renderer/components/runtime/CodexLoginLinkCopyButton.tsx b/src/renderer/components/runtime/CodexLoginLinkCopyButton.tsx new file mode 100644 index 00000000..a37277e6 --- /dev/null +++ b/src/renderer/components/runtime/CodexLoginLinkCopyButton.tsx @@ -0,0 +1,56 @@ +import { useEffect, useState } from 'react'; + +import { Check, Copy } from 'lucide-react'; + +interface CodexLoginLinkCopyButtonProps { + authUrl?: string | null; + disabled?: boolean; + size?: 'xs' | 'sm'; +} + +export function CodexLoginLinkCopyButton({ + authUrl, + disabled = false, + size = 'sm', +}: CodexLoginLinkCopyButtonProps): React.JSX.Element | null { + const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>('idle'); + + useEffect(() => { + setCopyState('idle'); + }, [authUrl]); + + if (!authUrl) { + return null; + } + + const handleCopyAuthUrl = (): void => { + if (!navigator.clipboard) { + setCopyState('failed'); + return; + } + + void navigator.clipboard.writeText(authUrl).then( + () => setCopyState('copied'), + () => setCopyState('failed') + ); + }; + + const sizeClassName = size === 'xs' ? 'px-2 py-1 text-[10px]' : 'px-2.5 py-1.5 text-xs'; + + return ( + + ); +} diff --git a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx index 108e2155..3c04faae 100644 --- a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx +++ b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx @@ -39,6 +39,7 @@ import { SelectValue, } from '@renderer/components/ui/select'; import { Tabs, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'; +import { CodexLoginLinkCopyButton } from '@renderer/components/runtime/CodexLoginLinkCopyButton'; import { useStore } from '@renderer/store'; import { AlertTriangle, Key, Link2, Loader2, Trash2 } from 'lucide-react'; @@ -715,6 +716,7 @@ export const ProviderRuntimeSettingsDialog = ({ Boolean(codexConnection?.localActiveChatgptAccountPresent) && !codexHasActiveChatgptSession; const codexLoginPending = codexConnection?.login.status === 'starting' || codexConnection?.login.status === 'pending'; + const codexLoginAuthUrl = codexConnection?.login.authUrl ?? null; const configurableAuthModes = selectedProvider?.connection?.configurableAuthModes ?? []; const configuredAuthMode: CliProviderAuthMode | undefined = selectedProvider?.connection?.configuredAuthMode ?? configurableAuthModes[0] ?? undefined; @@ -1389,14 +1391,31 @@ export const ProviderRuntimeSettingsDialog = ({ Refresh {codexLoginPending ? ( - + <> + + {codexLoginAuthUrl ? ( + + ) : null} + + ) : codexHasActiveChatgptSession ? ( + <> + + + )} diff --git a/src/renderer/components/sidebar/GlobalTaskList.tsx b/src/renderer/components/sidebar/GlobalTaskList.tsx index f236f190..51abfb7f 100644 --- a/src/renderer/components/sidebar/GlobalTaskList.tsx +++ b/src/renderer/components/sidebar/GlobalTaskList.tsx @@ -5,6 +5,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui import { useCollapsedGroups } from '@renderer/hooks/useCollapsedGroups'; import { useTaskLocalState } from '@renderer/hooks/useTaskLocalState'; import { cn } from '@renderer/lib/utils'; +import { markTaskUnread } from '@renderer/services/commentReadStorage'; import { useStore } from '@renderer/store'; import { normalizePath } from '@renderer/utils/pathNormalize'; import { projectColor } from '@renderer/utils/projectColor'; @@ -283,6 +284,10 @@ export const GlobalTaskList = memo(function GlobalTaskList({ setRenamingTaskKey(null); }, []); + const handleMarkTaskUnread = useCallback((teamName: string, taskId: string): void => { + markTaskUnread(teamName, taskId); + }, []); + const handleDeleteTask = useCallback( async (teamName: string, taskId: string): Promise => { const confirmed = await confirm({ @@ -548,6 +553,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({ isArchived={false} onTogglePin={() => taskLocalState.togglePin(task.teamName, task.id)} onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)} + onMarkUnread={() => handleMarkTaskUnread(task.teamName, task.id)} onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} onDelete={() => handleDeleteTask(task.teamName, task.id)} > @@ -641,6 +647,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({ isArchived={taskLocalState.isArchived(task.teamName, task.id)} onTogglePin={() => taskLocalState.togglePin(task.teamName, task.id)} onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)} + onMarkUnread={() => handleMarkTaskUnread(task.teamName, task.id)} onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} onDelete={() => handleDeleteTask(task.teamName, task.id)} > @@ -726,6 +733,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({ onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id) } + onMarkUnread={() => handleMarkTaskUnread(task.teamName, task.id)} onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} onDelete={() => handleDeleteTask(task.teamName, task.id)} > @@ -832,6 +840,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({ onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id) } + onMarkUnread={() => handleMarkTaskUnread(task.teamName, task.id)} onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} onDelete={() => handleDeleteTask(task.teamName, task.id)} > diff --git a/src/renderer/components/sidebar/SidebarTaskItem.tsx b/src/renderer/components/sidebar/SidebarTaskItem.tsx index 13557262..fe775386 100644 --- a/src/renderer/components/sidebar/SidebarTaskItem.tsx +++ b/src/renderer/components/sidebar/SidebarTaskItem.tsx @@ -4,6 +4,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useTheme } from '@renderer/hooks/useTheme'; import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount'; +import { clearTaskManualUnread } from '@renderer/services/commentReadStorage'; import { useStore } from '@renderer/store'; import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers'; import { nameColorSet } from '@renderer/utils/projectColor'; @@ -157,6 +158,7 @@ export const SidebarTaskItem = memo(function SidebarTaskItem({ style={{ borderColor: 'var(--color-border)' }} onClick={() => { if (!isRenaming) { + clearTaskManualUnread(task.teamName, task.id); openGlobalTaskDetail(task.teamName, task.id); } }} diff --git a/src/renderer/components/sidebar/TaskContextMenu.tsx b/src/renderer/components/sidebar/TaskContextMenu.tsx index b5866643..98310b12 100644 --- a/src/renderer/components/sidebar/TaskContextMenu.tsx +++ b/src/renderer/components/sidebar/TaskContextMenu.tsx @@ -5,7 +5,7 @@ import { ContextMenuSeparator, ContextMenuTrigger, } from '@renderer/components/ui/context-menu'; -import { Archive, ArchiveRestore, Pencil, Pin, PinOff, Trash2 } from 'lucide-react'; +import { Archive, ArchiveRestore, Mail, Pencil, Pin, PinOff, Trash2 } from 'lucide-react'; import type { GlobalTask } from '@shared/types'; @@ -15,6 +15,7 @@ export interface TaskContextMenuProps { isArchived: boolean; onTogglePin: () => void; onToggleArchive: () => void; + onMarkUnread: () => void; onRename: () => void; onDelete?: () => void; children: React.ReactNode; @@ -26,6 +27,7 @@ export const TaskContextMenu = ({ isArchived, onTogglePin, onToggleArchive, + onMarkUnread, onRename, onDelete, children, @@ -55,6 +57,11 @@ export const TaskContextMenu = ({ Rename + + + Mark as unread + + @@ -74,10 +81,7 @@ export const TaskContextMenu = ({ {onDelete && ( <> - + Delete task diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index bcf58b78..967080db 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -809,6 +809,7 @@ const TeamMemberListBridge = memo(function TeamMemberListBridge({ return ( provider.providerId === 'codex' + ); + const codexConnection = codexProvider?.connection?.codex; + const loginStatus = codexConnection?.login.status; + const loginPending = loginStatus === 'starting' || loginStatus === 'pending'; + if (loginPending && codexConnection?.login.authUrl) { + return true; + } + + const codexNeedsReconnect = + Boolean(codexConnection?.localActiveChatgptAccountPresent) && + codexConnection?.launchAllowed !== true && + !loginPending; + + if (!codexNeedsReconnect) { + return false; + } + + if (containsReconnectCue(prepareMessage)) { + return true; + } + + return prepareChecks.some( + (check) => + check.providerId === 'codex' && check.details.some((detail) => containsReconnectCue(detail)) + ); +} + +export function CodexReconnectPrompt({ + authUrl, + reconnectBusy, + onReconnect, +}: { + authUrl: string | null; + reconnectBusy: boolean; + onReconnect: () => void; +}): React.JSX.Element { + return ( +
+
+

+ Codex found the local ChatGPT account, but this session is stale. Reconnect ChatGPT, then + finish login in the browser and retry this dialog. +

+ + +
+
+ ); +} diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 08c4e4e0..dd653a33 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -88,6 +88,7 @@ import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react'; import { AdvancedCliSection } from './AdvancedCliSection'; import { AnthropicFastModeSelector } from './AnthropicFastModeSelector'; +import { CodexReconnectPrompt, shouldShowCodexReconnectPrompt } from './CodexReconnectPrompt'; import { CodexFastModeSelector } from './CodexFastModeSelector'; import { clearInheritedMemberModelsUnavailableForProvider, @@ -95,6 +96,7 @@ import { } from './memberModelScope'; import { OptionalSettingsSection } from './OptionalSettingsSection'; import { ProjectPathSelector } from './ProjectPathSelector'; +import { loadProjectPathProjects, type ProjectPathProject } from './projectPathProjects'; import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey'; import { buildReusableProviderPrepareModelResults, @@ -155,7 +157,6 @@ const APP_TEAM_RUNTIME_DISALLOWED_TOOLS = 'TeamDelete,TodoWrite,TaskCreate,TaskU import type { EffortLevel, - Project, TeamCreateRequest, TeamFastMode, TeamProviderId, @@ -402,7 +403,7 @@ export const CreateTeamDialog = ({ const promptChipDraft = useChipDraftPersistence('createTeam:prompt:chips'); // ── Transient UI state (NOT persisted) ─────────────────────────────── - const [projects, setProjects] = useState([]); + const [projects, setProjects] = useState([]); const [projectsLoading, setProjectsLoading] = useState(false); const [projectsError, setProjectsError] = useState(null); const [localError, setLocalError] = useState(null); @@ -709,6 +710,19 @@ export const CreateTeamDialog = ({ }); }, [bootstrapCliStatus, cliStatus, cliStatusLoading, fetchCliStatus, multimodelEnabled, open]); + const handleCodexReconnect = useCallback(() => { + void (async () => { + const success = await codexAccount.startChatgptLogin(); + if (success) { + await refreshCliStatusForCurrentMode({ + multimodelEnabled, + bootstrapCliStatus, + fetchCliStatus, + }); + } + })(); + }, [bootstrapCliStatus, codexAccount, fetchCliStatus, multimodelEnabled]); + useEffect(() => { if (!open || !canCreate || !launchTeam) { prepareRequestSeqRef.current += 1; @@ -948,36 +962,11 @@ export const CreateTeamDialog = ({ let cancelled = false; void (async () => { try { - const nextProjects = (await api.getProjects()).filter( - (project) => !isEphemeralProjectPath(project.path) - ); + const nextProjects = await loadProjectPathProjects({ defaultProjectPath }); if (cancelled) { return; } - // If defaultProjectPath is set but not in the fetched list (e.g. new project - // without Claude sessions), add it as a synthetic entry so the Combobox can - // display and select it. - const normalizedDefaultProjectPath = defaultProjectPath - ? normalizePath(defaultProjectPath) - : null; - if ( - defaultProjectPath && - normalizedDefaultProjectPath && - !isEphemeralProjectPath(defaultProjectPath) && - !nextProjects.some((p) => normalizePath(p.path) === normalizedDefaultProjectPath) - ) { - const folderName = - defaultProjectPath.split(/[/\\]/).filter(Boolean).pop() ?? defaultProjectPath; - nextProjects.unshift({ - id: defaultProjectPath.replace(/[/\\]/g, '-'), - path: defaultProjectPath, - name: folderName, - sessions: [], - createdAt: Date.now(), - }); - } - setProjects(nextProjects); } catch (error) { if (cancelled) { @@ -1552,6 +1541,12 @@ export const CreateTeamDialog = ({ }), [prepareChecks, prepareMessage, prepareState, prepareWarnings] ); + const showCodexReconnectPrompt = shouldShowCodexReconnectPrompt({ + effectiveCliStatus, + selectedProviderIds: selectedMemberProviders, + prepareMessage: effectivePrepare.message, + prepareChecks, + }); const canOpenExistingTeam = activeError?.includes('Team already exists') === true && request.teamName.length > 0; @@ -2117,8 +2112,8 @@ export const CreateTeamDialog = ({ {prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
- {prepareWarnings.map((warning) => ( -

+ {prepareWarnings.map((warning, index) => ( +

{warning}

))} @@ -2152,9 +2147,9 @@ export const CreateTeamDialog = ({ ) : null} {prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
- {prepareWarnings.map((warning) => ( + {prepareWarnings.map((warning, index) => (

@@ -2166,6 +2161,15 @@ export const CreateTeamDialog = ({

{getProvisioningFailureHint(effectivePrepare.message, prepareChecks)}

+ {showCodexReconnectPrompt ? ( +
+ +
+ ) : null}
) : null}
diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 115d6873..8fabcc54 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -91,6 +91,7 @@ import { CronScheduleInput } from '../schedule/CronScheduleInput'; import { AdvancedCliSection } from './AdvancedCliSection'; import { AnthropicFastModeSelector } from './AnthropicFastModeSelector'; +import { CodexReconnectPrompt, shouldShowCodexReconnectPrompt } from './CodexReconnectPrompt'; import { CodexFastModeSelector } from './CodexFastModeSelector'; import { EffortLevelSelector } from './EffortLevelSelector'; import { resolveLaunchDialogPrefill } from './launchDialogPrefill'; @@ -100,6 +101,7 @@ import { } from './memberModelScope'; import { OptionalSettingsSection } from './OptionalSettingsSection'; import { ProjectPathSelector } from './ProjectPathSelector'; +import { loadProjectPathProjects, type ProjectPathProject } from './projectPathProjects'; import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey'; import { buildReusableProviderPrepareModelResults, @@ -153,7 +155,6 @@ import type { MentionSuggestion } from '@renderer/types/mention'; import type { CreateScheduleInput, EffortLevel, - Project, ResolvedTeamMember, Schedule, ScheduleLaunchConfig, @@ -404,7 +405,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen const chipDraft = useChipDraftPersistence( `launchTeam:${effectiveTeamName || 'standalone'}:${props.mode}:chips` ); - const [projects, setProjects] = useState([]); + const [projects, setProjects] = useState([]); const [projectsLoading, setProjectsLoading] = useState(false); const [projectsError, setProjectsError] = useState(null); const [localError, setLocalError] = useState(null); @@ -586,6 +587,19 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen }); }, [bootstrapCliStatus, cliStatus, cliStatusLoading, fetchCliStatus, multimodelEnabled, open]); + const handleCodexReconnect = React.useCallback(() => { + void (async () => { + const success = await codexAccount.startChatgptLogin(); + if (success) { + await refreshCliStatusForCurrentMode({ + multimodelEnabled, + bootstrapCliStatus, + fetchCliStatus, + }); + } + })(); + }, [bootstrapCliStatus, codexAccount, fetchCliStatus, multimodelEnabled]); + // Schedule store actions const createSchedule = useStore((s) => s.createSchedule); const updateSchedule = useStore((s) => s.updateSchedule); @@ -1579,6 +1593,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen // --------------------------------------------------------------------------- const repositoryGroups = useStore(useShallow((s) => s.repositoryGroups)); + const defaultProjectPath = isLaunchMode ? props.defaultProjectPath : undefined; useEffect(() => { if (!open) return; @@ -1589,30 +1604,13 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen let cancelled = false; void (async () => { try { - const apiProjects = (await api.getProjects()).filter( - (project) => !isEphemeralProjectPath(project.path) - ); + const nextProjects = await loadProjectPathProjects({ + defaultProjectPath, + repositoryGroups, + }); if (cancelled) return; - const pathSet = new Set(apiProjects.map((p) => p.path)); - const extras: Project[] = []; - for (const repo of repositoryGroups) { - for (const wt of repo.worktrees) { - if (!isEphemeralProjectPath(wt.path) && !pathSet.has(wt.path)) { - pathSet.add(wt.path); - extras.push({ - id: wt.id, - path: wt.path, - name: wt.name, - sessions: [], - totalSessions: 0, - createdAt: wt.createdAt ?? Date.now(), - }); - } - } - } - - setProjects([...apiProjects, ...extras]); + setProjects(nextProjects); } catch (error) { if (cancelled) return; setProjectsError(error instanceof Error ? error.message : 'Failed to load projects'); @@ -1625,10 +1623,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen return () => { cancelled = true; }; - }, [open, repositoryGroups]); + }, [open, repositoryGroups, defaultProjectPath]); // Pre-select defaultProjectPath (launch mode) or first project - const defaultProjectPath = isLaunchMode ? props.defaultProjectPath : undefined; useEffect(() => { if (!open || cwdMode !== 'project' || selectedProjectPath) return; @@ -1920,6 +1917,12 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen }), [prepareChecks, prepareMessage, prepareState, prepareWarnings] ); + const showCodexReconnectPrompt = shouldShowCodexReconnectPrompt({ + effectiveCliStatus, + selectedProviderIds: selectedMemberProviders, + prepareMessage: effectivePrepare.message, + prepareChecks, + }); const launchInFlight = useStore((s) => isLaunchMode && effectiveTeamName ? isTeamProvisioningActive(s, effectiveTeamName) : false ); @@ -2819,8 +2822,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen {prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
- {prepareWarnings.map((warning) => ( -

+ {prepareWarnings.map((warning, index) => ( +

{warning}

))} @@ -2858,9 +2861,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen ) : null} {prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
- {prepareWarnings.map((warning) => ( + {prepareWarnings.map((warning, index) => (

@@ -2889,6 +2892,15 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen ) : null}

+ {showCodexReconnectPrompt ? ( +
+ +
+ ) : null}
) : null} diff --git a/src/renderer/components/team/dialogs/ProjectPathSelector.tsx b/src/renderer/components/team/dialogs/ProjectPathSelector.tsx index 071b5fbc..da65e517 100644 --- a/src/renderer/components/team/dialogs/ProjectPathSelector.tsx +++ b/src/renderer/components/team/dialogs/ProjectPathSelector.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { api } from '@renderer/api'; +import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; import { Button } from '@renderer/components/ui/button'; import { Combobox } from '@renderer/components/ui/combobox'; import { Input } from '@renderer/components/ui/input'; @@ -8,9 +9,14 @@ import { Label } from '@renderer/components/ui/label'; import { cn } from '@renderer/lib/utils'; import { Check, FolderOpen } from 'lucide-react'; -import { buildProjectPathOptions } from './projectPathOptions'; +import { + buildProjectPathOptions, + type ProjectPathOptionMeta, + type ProjectPathProject, +} from './projectPathOptions'; -import type { Project } from '@shared/types'; +import type { DashboardRecentProjectSource } from '@features/recent-projects/contracts'; +import type { ComboboxOption } from '@renderer/components/ui/combobox'; function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); @@ -45,6 +51,49 @@ function renderHighlightedText(text: string, query: string): React.JSX.Element { ); } +function getOptionSource(option: ComboboxOption): DashboardRecentProjectSource | undefined { + return (option.meta as ProjectPathOptionMeta | undefined)?.discoverySource; +} + +function getSourceLabel(source: DashboardRecentProjectSource): string { + switch (source) { + case 'claude': + return 'Found by Claude'; + case 'codex': + return 'Found by Codex'; + case 'mixed': + return 'Found by Claude and Codex'; + } +} + +function ProjectSourceBadge({ + source, +}: { + source?: DashboardRecentProjectSource; +}): React.JSX.Element | null { + if (!source) { + return null; + } + + const logos = + source === 'mixed' + ? (['anthropic', 'codex'] as const) + : source === 'codex' + ? (['codex'] as const) + : (['anthropic'] as const); + + return ( + + {logos.map((providerId) => ( + + ))} + + ); +} + export type CwdMode = 'project' | 'custom'; interface ProjectPathSelectorProps { @@ -54,7 +103,7 @@ interface ProjectPathSelectorProps { onSelectedProjectPathChange: (path: string) => void; customCwd: string; onCustomCwdChange: (cwd: string) => void; - projects: Project[]; + projects: ProjectPathProject[]; projectsLoading: boolean; projectsError: string | null; fieldError?: string | null; @@ -123,6 +172,12 @@ export const ProjectPathSelector = ({ searchPlaceholder="Search project by name or path" emptyMessage="Nothing found" disabled={projectsLoading || projectOptions.length === 0} + renderTriggerLabel={(option) => ( + + + {option.label} + + )} renderOption={(option, isSelected, query) => ( <> +

{renderHighlightedText(option.label, query)} diff --git a/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx index a092ec38..dd4ff008 100644 --- a/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx +++ b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx @@ -619,9 +619,9 @@ export const ProvisioningProviderStatusList = ({

{visibleDetails.length > 0 ? (
- {visibleDetails.map((detail) => ( + {visibleDetails.map((detail, index) => (

{detail} diff --git a/src/renderer/components/team/dialogs/TeamModelSelector.tsx b/src/renderer/components/team/dialogs/TeamModelSelector.tsx index b112b872..50c5007d 100644 --- a/src/renderer/components/team/dialogs/TeamModelSelector.tsx +++ b/src/renderer/components/team/dialogs/TeamModelSelector.tsx @@ -563,7 +563,11 @@ export const TeamModelSelector: React.FC = ({ }} > - {opt.label} + + {opt.label} + {sourceBadgeLabel ? ( , + order: string[], + project: ProjectPathProject +): void { + if (isEphemeralProjectPath(project.path)) { + return; + } + + const normalizedPath = normalizePath(project.path); + const existing = byNormalizedPath.get(normalizedPath); + if (!existing) { + byNormalizedPath.set(normalizedPath, project); + order.push(normalizedPath); + return; + } + + existing.discoverySource = mergeDiscoverySource( + existing.discoverySource, + project.discoverySource + ); + if (!existing.mostRecentSession && project.mostRecentSession) { + existing.mostRecentSession = project.mostRecentSession; + } +} + +function recentProjectToProject(project: { + id: string; + name: string; + primaryPath: string; + mostRecentActivity: number; + source: DashboardRecentProjectSource; +}): ProjectPathProject { + return { + id: `recent:${project.id}`, + path: project.primaryPath, + name: project.name, + sessions: [], + totalSessions: 0, + createdAt: project.mostRecentActivity, + mostRecentSession: project.mostRecentActivity, + discoverySource: project.source, + }; +} + +function repositoryWorktreeToProject(worktree: RepositoryGroup['worktrees'][number]): Project { + return { + id: worktree.id, + path: worktree.path, + name: worktree.name, + sessions: [], + totalSessions: 0, + createdAt: worktree.createdAt ?? Date.now(), + }; +} + +function syntheticProjectFromPath(projectPath: string): Project { + return { + id: projectPath.replace(/[/\\]/g, '-'), + path: projectPath, + name: getPathName(projectPath), + sessions: [], + totalSessions: 0, + createdAt: Date.now(), + }; +} + +export async function loadProjectPathProjects({ + defaultProjectPath, + repositoryGroups = [], +}: LoadProjectPathProjectsOptions = {}): Promise { + const [projectsResult, recentProjectsResult] = await Promise.allSettled([ + api.getProjects(), + api.getDashboardRecentProjects(), + ]); + + if (projectsResult.status === 'rejected' && recentProjectsResult.status === 'rejected') { + throw projectsResult.reason; + } + + const byNormalizedPath = new Map(); + const order: string[] = []; + const apiProjects = projectsResult.status === 'fulfilled' ? projectsResult.value : []; + const recentProjects = + recentProjectsResult.status === 'fulfilled' ? recentProjectsResult.value.projects : []; + + for (const project of apiProjects) { + upsertProject(byNormalizedPath, order, { + ...project, + discoverySource: 'claude', + }); + } + + for (const project of recentProjects) { + upsertProject(byNormalizedPath, order, recentProjectToProject(project)); + } + + for (const repo of repositoryGroups) { + for (const worktree of repo.worktrees) { + upsertProject(byNormalizedPath, order, repositoryWorktreeToProject(worktree)); + } + } + + if (defaultProjectPath && !isEphemeralProjectPath(defaultProjectPath)) { + upsertProject(byNormalizedPath, order, syntheticProjectFromPath(defaultProjectPath)); + } + + return order.flatMap((path) => { + const project = byNormalizedPath.get(path); + return project ? [project] : []; + }); +} diff --git a/src/renderer/components/team/members/CurrentTaskIndicator.tsx b/src/renderer/components/team/members/CurrentTaskIndicator.tsx index 594eabaf..220ce60c 100644 --- a/src/renderer/components/team/members/CurrentTaskIndicator.tsx +++ b/src/renderer/components/team/members/CurrentTaskIndicator.tsx @@ -1,8 +1,14 @@ -import { memo } from 'react'; +import { memo, useEffect, useState } from 'react'; import { SyncedLoader2 } from '@renderer/components/ui/SyncedLoader2'; +import { + formatMemberActivityElapsed, + readMemberActivityTimerElapsed, + syncMemberActivityTimer, +} from '@renderer/utils/memberActivityTimer'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; +import type { MemberActivityTimerAnchor } from '@renderer/utils/memberActivityTimer'; import type { TeamTaskWithKanban } from '@shared/types'; interface CurrentTaskIndicatorProps { @@ -10,9 +16,71 @@ interface CurrentTaskIndicatorProps { borderColor: string; maxSubjectLength?: number; activityLabel?: string; + activityTimer?: MemberActivityTimerAnchor | null; + isTimerRunning?: boolean; onOpenTask?: () => void; } +function useActivityTimerLabel( + activityTimer: MemberActivityTimerAnchor | null | undefined, + isTimerRunning: boolean +): string | null { + const [nowMs, setNowMs] = useState(() => Date.now()); + + useEffect(() => { + if (!activityTimer) return; + const now = Date.now(); + syncMemberActivityTimer({ + timerId: activityTimer.timerId, + startedAtMs: activityTimer.startedAtMs, + baseElapsedMs: activityTimer.baseElapsedMs, + running: isTimerRunning, + runId: activityTimer.runId, + nowMs: now, + }); + + return () => { + syncMemberActivityTimer({ + timerId: activityTimer.timerId, + startedAtMs: activityTimer.startedAtMs, + baseElapsedMs: activityTimer.baseElapsedMs, + running: isTimerRunning, + runId: activityTimer.runId, + nowMs: Date.now(), + }); + }; + }, [activityTimer, isTimerRunning]); + + useEffect(() => { + if (!activityTimer || !isTimerRunning) return; + const handle = window.setInterval(() => { + const now = Date.now(); + syncMemberActivityTimer({ + timerId: activityTimer.timerId, + startedAtMs: activityTimer.startedAtMs, + baseElapsedMs: activityTimer.baseElapsedMs, + running: true, + runId: activityTimer.runId, + nowMs: now, + }); + setNowMs(now); + }, 1000); + return () => window.clearInterval(handle); + }, [activityTimer, isTimerRunning]); + + if (!activityTimer) return null; + return formatMemberActivityElapsed( + readMemberActivityTimerElapsed({ + timerId: activityTimer.timerId, + startedAtMs: activityTimer.startedAtMs, + baseElapsedMs: activityTimer.baseElapsedMs, + running: isTimerRunning, + runId: activityTimer.runId, + nowMs, + }) + ); +} + /** * Inline indicator showing a spinning loader + "working on" + task label button. * Shared between MemberCard and MemberHoverCard. @@ -23,8 +91,11 @@ export const CurrentTaskIndicator = memo( borderColor, maxSubjectLength, activityLabel = 'working on', + activityTimer, + isTimerRunning = true, onOpenTask, }: CurrentTaskIndicatorProps): React.JSX.Element => { + const timerLabel = useActivityTimerLabel(activityTimer, isTimerRunning); const subjectText = typeof maxSubjectLength === 'number' && maxSubjectLength > 0 && @@ -54,6 +125,14 @@ export const CurrentTaskIndicator = memo( > {formatTaskDisplayLabel(task)} {subjectText} + {timerLabel ? ( + + {timerLabel} + + ) : null}

); } diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index 7008575b..8b7e02a1 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -30,6 +30,7 @@ import { CurrentTaskIndicator } from './CurrentTaskIndicator'; import { MemberLaunchDiagnosticsButton } from './MemberLaunchDiagnosticsButton'; import { MemberPresenceDot } from './MemberPresenceDot'; +import type { MemberActivityTimerAnchor } from '@renderer/utils/memberActivityTimer'; import type { TaskStatusCounts } from '@renderer/utils/pathNormalize'; import type { LeadActivityState, @@ -54,6 +55,10 @@ interface MemberCardProps { leadActivity?: LeadActivityState; currentTask?: TeamTaskWithKanban | null; reviewTask?: TeamTaskWithKanban | null; + currentTaskTimer?: MemberActivityTimerAnchor | null; + reviewTaskTimer?: MemberActivityTimerAnchor | null; + currentTaskTimerRunning?: boolean; + reviewTaskTimerRunning?: boolean; isAwaitingReply?: boolean; isRemoved?: boolean; spawnStatus?: MemberSpawnStatus; @@ -132,6 +137,10 @@ export const MemberCard = memo(function MemberCard({ leadActivity, currentTask, reviewTask, + currentTaskTimer, + reviewTaskTimer, + currentTaskTimerRunning = isTeamAlive !== false, + reviewTaskTimerRunning = isTeamAlive !== false, isAwaitingReply, isRemoved, spawnStatus, @@ -433,6 +442,8 @@ export const MemberCard = memo(function MemberCard({ task={currentTask} borderColor={colors.border} activityLabel="working on" + activityTimer={currentTaskTimer} + isTimerRunning={currentTaskTimerRunning} onOpenTask={onOpenTask} /> ) : null} @@ -441,6 +452,8 @@ export const MemberCard = memo(function MemberCard({ task={reviewTask} borderColor={colors.border} activityLabel="reviewing" + activityTimer={reviewTaskTimer} + isTimerRunning={reviewTaskTimerRunning} onOpenTask={onOpenReviewTask} /> ) : null} diff --git a/src/renderer/components/team/members/MemberHoverCard.tsx b/src/renderer/components/team/members/MemberHoverCard.tsx index dd1ce9e2..81402d29 100644 --- a/src/renderer/components/team/members/MemberHoverCard.tsx +++ b/src/renderer/components/team/members/MemberHoverCard.tsx @@ -23,14 +23,15 @@ import { buildMemberAvatarMap, buildMemberLaunchPresentation, displayMemberName, + shouldDisplayMemberCurrentTask, } from '@renderer/utils/memberHelpers'; -import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState'; import { buildMemberLaunchDiagnosticsPayload, getMemberLaunchDiagnosticsErrorMessage, hasMemberLaunchDiagnosticsDetails, hasMemberLaunchDiagnosticsError, } from '@renderer/utils/memberLaunchDiagnostics'; +import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState'; import { isLeadMember } from '@shared/utils/leadDetection'; import { getTeamTaskWorkflowColumn } from '@shared/utils/teamTaskState'; import { ExternalLink } from 'lucide-react'; @@ -42,7 +43,7 @@ import { CurrentTaskIndicator } from './CurrentTaskIndicator'; import { MemberLaunchDiagnosticsButton } from './MemberLaunchDiagnosticsButton'; import { MemberPresenceDot } from './MemberPresenceDot'; -import type { LeadActivityState, TeamTaskWithKanban } from '@shared/types'; +import type { TeamTaskWithKanban } from '@shared/types'; interface MemberHoverCardProps { /** The member name to look up */ @@ -131,7 +132,18 @@ export const MemberHoverCard = memo(function MemberHoverCard({ const currentTaskCandidate: TeamTaskWithKanban | null = member.currentTaskId ? (tasks.find((t) => t.id === member.currentTaskId) ?? null) : null; - const currentTask = isDisplayableCurrentTask(currentTaskCandidate) ? currentTaskCandidate : null; + const currentTask = + isDisplayableCurrentTask(currentTaskCandidate) && + shouldDisplayMemberCurrentTask({ + member, + isTeamAlive, + spawnStatus: spawnEntry?.status, + spawnLaunchState: spawnEntry?.launchState, + spawnRuntimeAlive: spawnEntry?.runtimeAlive, + runtimeEntry, + }) + ? currentTaskCandidate + : null; const presentationMember = member.currentTaskId && !currentTask ? { diff --git a/src/renderer/components/team/members/MemberList.tsx b/src/renderer/components/team/members/MemberList.tsx index 334d8c2c..0368b32e 100644 --- a/src/renderer/components/team/members/MemberList.tsx +++ b/src/renderer/components/team/members/MemberList.tsx @@ -1,6 +1,11 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; +import { + deriveReviewActivityTimerAnchor, + deriveWorkActivityTimerAnchor, + syncMemberActivityTimer, +} from '@renderer/utils/memberActivityTimer'; +import { buildMemberColorMap, shouldDisplayMemberCurrentTask } from '@renderer/utils/memberHelpers'; import { resolveMemberRuntimeSummary } from '@renderer/utils/memberRuntimeSummary'; import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState'; import { isLeadMember } from '@shared/utils/leadDetection'; @@ -9,6 +14,7 @@ import { getTeamTaskWorkflowColumn } from '@shared/utils/teamTaskState'; import { MemberCard } from './MemberCard'; import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice'; +import type { MemberActivityTimerAnchor } from '@renderer/utils/memberActivityTimer'; import type { TaskStatusCounts } from '@renderer/utils/pathNormalize'; import type { LeadActivityState, @@ -22,6 +28,7 @@ import type { } from '@shared/types'; interface MemberListProps { + teamName?: string; members: ResolvedTeamMember[]; memberTaskCounts?: Map; taskMap?: Map; @@ -101,6 +108,45 @@ function areTaskStatusCountsMapsEquivalent( return true; } +function areTaskWorkIntervalsEquivalent( + left: TeamTaskWithKanban['workIntervals'], + right: TeamTaskWithKanban['workIntervals'] +): boolean { + if (left === right) return true; + if (!left || !right) return left === right; + if (left.length !== right.length) return false; + return left.every((interval, index) => { + const other = right[index]; + if (!other) return false; + return interval.startedAt === other.startedAt && interval.completedAt === other.completedAt; + }); +} + +function areTaskHistoryEventsEquivalent( + left: TeamTaskWithKanban['historyEvents'], + right: TeamTaskWithKanban['historyEvents'] +): boolean { + if (left === right) return true; + if (!left || !right) return left === right; + if (left.length !== right.length) return false; + return left.every((event, index) => { + const other = right[index]; + if (!other) return false; + const leftRow = event as unknown as Record; + const rightRow = other as unknown as Record; + return ( + event.id === other.id && + event.type === other.type && + event.timestamp === other.timestamp && + leftRow.actor === rightRow.actor && + leftRow.reviewer === rightRow.reviewer && + leftRow.from === rightRow.from && + leftRow.to === rightRow.to && + leftRow.status === rightRow.status + ); + }); +} + function areMemberTaskMapsEquivalent( left: Map | undefined, right: Map | undefined @@ -118,7 +164,9 @@ function areMemberTaskMapsEquivalent( leftTask.status !== rightTask.status || leftTask.reviewer !== rightTask.reviewer || leftTask.reviewState !== rightTask.reviewState || - leftTask.kanbanColumn !== rightTask.kanbanColumn + leftTask.kanbanColumn !== rightTask.kanbanColumn || + !areTaskWorkIntervalsEquivalent(leftTask.workIntervals, rightTask.workIntervals) || + !areTaskHistoryEventsEquivalent(leftTask.historyEvents, rightTask.historyEvents) ) { return false; } @@ -243,6 +291,7 @@ function areMemberListPropsEqual( next: Readonly ): boolean { return ( + prev.teamName === next.teamName && areResolvedMembersEquivalent(prev.members, next.members) && areTaskStatusCountsMapsEquivalent(prev.memberTaskCounts, next.memberTaskCounts) && areMemberTaskMapsEquivalent(prev.taskMap, next.taskMap) && @@ -270,6 +319,10 @@ interface MemberCardRowProps { memberColor: string; currentTask: TeamTaskWithKanban | null; reviewTask: TeamTaskWithKanban | null; + currentTaskTimer: MemberActivityTimerAnchor | null; + reviewTaskTimer: MemberActivityTimerAnchor | null; + currentTaskTimerRunning: boolean; + reviewTaskTimerRunning: boolean; awaitingReply: boolean; taskCounts?: TaskStatusCounts | null; runtimeSummary?: string; @@ -299,6 +352,10 @@ const MemberCardRow = memo(function MemberCardRow({ memberColor, currentTask, reviewTask, + currentTaskTimer, + reviewTaskTimer, + currentTaskTimerRunning, + reviewTaskTimerRunning, awaitingReply, taskCounts, runtimeSummary, @@ -346,6 +403,10 @@ const MemberCardRow = memo(function MemberCardRow({ leadActivity={isLeadMember(member) ? leadActivity : undefined} currentTask={currentTask} reviewTask={reviewTask} + currentTaskTimer={currentTaskTimer} + reviewTaskTimer={reviewTaskTimer} + currentTaskTimerRunning={currentTaskTimerRunning} + reviewTaskTimerRunning={reviewTaskTimerRunning} isAwaitingReply={awaitingReply} isRemoved={isRemoved} runtimeSummary={runtimeSummary} @@ -370,6 +431,7 @@ const MemberCardRow = memo(function MemberCardRow({ }); export const MemberList = memo(function MemberList({ + teamName = '__unknown_team__', members, memberTaskCounts, taskMap, @@ -434,6 +496,124 @@ export const MemberList = memo(function MemberList({ return result; }, [taskMap]); + const isMemberActivityTimerRunning = useCallback( + ( + spawnEntry: MemberSpawnStatusEntry | undefined, + runtimeEntry: TeamAgentRuntimeEntry | undefined + ): boolean => { + if (isTeamAlive === false) return false; + if ( + spawnEntry?.status === 'offline' || + spawnEntry?.status === 'error' || + spawnEntry?.status === 'skipped' + ) { + return false; + } + if (spawnEntry?.runtimeAlive === false && spawnEntry.status !== 'online') { + return false; + } + if ( + runtimeEntry?.livenessKind === 'shell_only' || + runtimeEntry?.livenessKind === 'registered_only' || + runtimeEntry?.livenessKind === 'stale_metadata' || + runtimeEntry?.livenessKind === 'not_found' + ) { + return false; + } + return true; + }, + [isTeamAlive] + ); + + const getActivityTimerRunId = useCallback( + (running: boolean): string | null => { + if (!running) return null; + return runtimeRunId ?? 'runtime:unknown'; + }, + [runtimeRunId] + ); + + const withActivityTimerRunId = useCallback( + ( + anchor: MemberActivityTimerAnchor | null, + running: boolean + ): MemberActivityTimerAnchor | null => { + if (!anchor) return null; + return { + ...anchor, + runId: getActivityTimerRunId(running), + }; + }, + [getActivityTimerRunId] + ); + + useEffect(() => { + if (!taskMap) return; + const nowMs = Date.now(); + for (const member of activeMembers) { + const spawnEntry = memberSpawnStatuses?.get(member.name); + const runtimeEntry = memberRuntimeEntries?.get(member.name); + const running = isMemberActivityTimerRunning(spawnEntry, runtimeEntry); + const currentTaskCandidate = member.currentTaskId + ? (taskMap.get(member.currentTaskId) ?? null) + : null; + if (isDisplayableCurrentTask(currentTaskCandidate)) { + const anchor = deriveWorkActivityTimerAnchor(currentTaskCandidate, { + teamName, + memberName: member.name, + }); + if (anchor) { + const visible = + running && + shouldDisplayMemberCurrentTask({ + member, + isTeamAlive, + spawnStatus: spawnEntry?.status, + spawnLaunchState: spawnEntry?.launchState, + spawnRuntimeAlive: spawnEntry?.runtimeAlive, + runtimeEntry, + }); + syncMemberActivityTimer({ + timerId: anchor.timerId, + startedAtMs: anchor.startedAtMs, + baseElapsedMs: anchor.baseElapsedMs, + running: visible, + runId: getActivityTimerRunId(visible), + nowMs, + }); + } + } + + const reviewTask = reviewTaskByMember.get(member.name) ?? null; + if (reviewTask) { + const anchor = deriveReviewActivityTimerAnchor(reviewTask, { + teamName, + memberName: member.name, + }); + if (anchor) { + syncMemberActivityTimer({ + timerId: anchor.timerId, + startedAtMs: anchor.startedAtMs, + baseElapsedMs: anchor.baseElapsedMs, + running, + runId: getActivityTimerRunId(running), + nowMs, + }); + } + } + } + }, [ + activeMembers, + getActivityTimerRunId, + isMemberActivityTimerRunning, + isTeamAlive, + memberRuntimeEntries, + memberSpawnStatuses, + reviewTaskByMember, + taskMap, + teamName, + ]); + const buildRuntimeSummary = useCallback( ( member: ResolvedTeamMember, @@ -457,16 +637,44 @@ export const MemberList = memo(function MemberList({
{activeMembers.map((member) => { + const spawnEntry = memberSpawnStatuses?.get(member.name); + const runtimeEntry = memberRuntimeEntries?.get(member.name); const currentTaskCandidate = member.currentTaskId && taskMap ? (taskMap.get(member.currentTaskId) ?? null) : null; - const currentTask = isDisplayableCurrentTask(currentTaskCandidate) - ? currentTaskCandidate - : null; + const currentTask = + isDisplayableCurrentTask(currentTaskCandidate) && + shouldDisplayMemberCurrentTask({ + member, + isTeamAlive, + spawnStatus: spawnEntry?.status, + spawnLaunchState: spawnEntry?.launchState, + spawnRuntimeAlive: spawnEntry?.runtimeAlive, + runtimeEntry, + }) + ? currentTaskCandidate + : null; const reviewCandidate = reviewTaskByMember.get(member.name) ?? null; const reviewTask = reviewCandidate && reviewCandidate.id !== currentTask?.id ? reviewCandidate : null; - const spawnEntry = memberSpawnStatuses?.get(member.name); - const runtimeEntry = memberRuntimeEntries?.get(member.name); + const activityTimerRunning = isMemberActivityTimerRunning(spawnEntry, runtimeEntry); + const currentTaskTimer = withActivityTimerRunId( + currentTask + ? deriveWorkActivityTimerAnchor(currentTask, { + teamName, + memberName: member.name, + }) + : null, + activityTimerRunning + ); + const reviewTaskTimer = withActivityTimerRunId( + reviewTask + ? deriveReviewActivityTimerAnchor(reviewTask, { + teamName, + memberName: member.name, + }) + : null, + activityTimerRunning + ); return ( ; // key = "teamName/taskId" @@ -116,9 +117,12 @@ export function getSnapshot(): ReadState { * Mark specific comment IDs as read for a given team/task. */ export function markCommentsRead(teamName: string, taskId: string, commentIds: string[]): void { - if (commentIds.length === 0) return; const key = `${teamName}/${taskId}`; const prev = cache[key]; + if (commentIds.length === 0) { + if (prev?.manualUnread) clearTaskManualUnread(teamName, taskId); + return; + } const prevSet = new Set(prev?.readIds ?? []); let changed = false; for (const id of commentIds) { @@ -127,7 +131,7 @@ export function markCommentsRead(teamName: string, taskId: string, commentIds: s changed = true; } } - if (!changed) return; + if (!changed && !prev?.manualUnread) return; cache = { ...cache, [key]: { @@ -148,7 +152,7 @@ export function markAsRead(teamName: string, taskId: string, latestTimestamp: nu const prev = cache[key]; // Update lastUpdated to at least this timestamp (for legacy migration support) const prevLastUpdated = prev?.lastUpdated ?? 0; - if (latestTimestamp <= prevLastUpdated && prev) return; + if (latestTimestamp <= prevLastUpdated && prev && !prev.manualUnread) return; cache = { ...cache, [key]: { @@ -160,6 +164,43 @@ export function markAsRead(teamName: string, taskId: string, latestTimestamp: nu scheduleSave(); } +/** + * Manually mark a task as unread even when it has no unread comments. + */ +export function markTaskUnread(teamName: string, taskId: string): void { + const key = `${teamName}/${taskId}`; + const prev = cache[key]; + if (prev?.manualUnread) return; + cache = { + ...cache, + [key]: { + readIds: prev?.readIds ?? [], + lastUpdated: Date.now(), + manualUnread: true, + }, + }; + notify(); + scheduleSave(); +} + +/** + * Clear only the manual unread marker. Comment read state is preserved. + */ +export function clearTaskManualUnread(teamName: string, taskId: string): void { + const key = `${teamName}/${taskId}`; + const prev = cache[key]; + if (!prev?.manualUnread) return; + cache = { + ...cache, + [key]: { + readIds: prev.readIds, + lastUpdated: Date.now(), + }, + }; + notify(); + scheduleSave(); +} + /** * Count unread comments for a task. * A comment is unread if its ID is NOT in the readIds set. @@ -177,9 +218,9 @@ export function getUnreadCount( taskId: string, comments: { id?: string; createdAt: string }[] ): number { - if (!comments || comments.length === 0) return 0; const key = `${teamName}/${taskId}`; const entry = readState[key]; + if (!comments || comments.length === 0) return entry?.manualUnread ? 1 : 0; if (!entry) return comments.length; const readSet = new Set(entry.readIds); @@ -200,7 +241,7 @@ export function getUnreadCount( // Otherwise → unread count++; } - return count; + return entry.manualUnread && count === 0 ? 1 : count; } /** @@ -272,6 +313,7 @@ async function load(): Promise { merged[k] = { readIds: Array.from(mergedIds), lastUpdated: Math.max(prev.lastUpdated, entry.lastUpdated), + ...(prev.manualUnread || entry.manualUnread ? { manualUnread: true } : {}), }; } } @@ -290,6 +332,7 @@ async function load(): Promise { merged[k] = { readIds: [...new Set([...merged[k].readIds, ...v.readIds])], lastUpdated: Math.max(merged[k].lastUpdated, v.lastUpdated), + ...(merged[k].manualUnread || v.manualUnread ? { manualUnread: true } : {}), }; } } diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 8d7fa9d9..e57fea10 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -80,6 +80,7 @@ import type { } from '@shared/types'; const ENABLE_AUTO_TEAM_CHANGE_PRESENCE_TRACKING = false; +const ENABLE_IN_PROGRESS_CHANGE_PRESENCE_BACKGROUND_POLL = false; const IN_PROGRESS_CHANGE_PRESENCE_POLL_MS = 10_000; const FINISHED_TOOL_DISPLAY_MS = 1_500; const MAX_TOOL_HISTORY_PER_MEMBER = 6; @@ -257,14 +258,20 @@ export function initializeNotificationListeners(): () => void { cleanupFns.push(() => { if (cliStatusTimer) clearTimeout(cliStatusTimer); }); - // This lightweight renderer-side poll keeps visible in-progress task badges fresh. - // It is intentionally independent from the backend log-source tracking feature flag below. - const inProgressChangePresencePollTimer = setInterval(() => { - void pollVisibleTeamInProgressChangePresence(); - }, IN_PROGRESS_CHANGE_PRESENCE_POLL_MS); - cleanupFns.push(() => { - clearInterval(inProgressChangePresencePollTimer); - }); + // TODO(task-change-presence): re-enable this only after the board uses a bounded + // batch/priority presence pipeline. The old one-task-per-tick poll was accurate + // only after enough time or after opening a task popup, while still doing periodic + // summary extraction work in the background. The replacement should check visible + // tasks first, dedupe in-flight requests, keep popup/full diff requests higher + // priority, and never render "unknown" as "no_changes". + if (ENABLE_IN_PROGRESS_CHANGE_PRESENCE_BACKGROUND_POLL) { + const inProgressChangePresencePollTimer = setInterval(() => { + void pollVisibleTeamInProgressChangePresence(); + }, IN_PROGRESS_CHANGE_PRESENCE_POLL_MS); + cleanupFns.push(() => { + clearInterval(inProgressChangePresencePollTimer); + }); + } const pendingSessionRefreshTimers = new Map>(); const pendingProjectRefreshTimers = new Map>(); const teamLastRelevantActivityAt = new Map(); diff --git a/src/renderer/utils/__tests__/teamModelAvailability.codexCatalog.test.ts b/src/renderer/utils/__tests__/teamModelAvailability.codexCatalog.test.ts index 555233f9..a579f5cc 100644 --- a/src/renderer/utils/__tests__/teamModelAvailability.codexCatalog.test.ts +++ b/src/renderer/utils/__tests__/teamModelAvailability.codexCatalog.test.ts @@ -116,7 +116,7 @@ function createAnthropicProviderStatus( } describe('team model availability Codex catalog integration', () => { - it('uses app-server catalog models even when the static Codex list has not learned a new model yet', () => { + it('uses app-server catalog models with runtime-backed labels', () => { const providerStatus = createCodexProviderStatus( [ { @@ -171,12 +171,62 @@ describe('team model availability Codex catalog integration', () => { expect(getAvailableTeamProviderModelOptions('codex', providerStatus)[1]).toMatchObject({ value: 'gpt-5.5', label: '5.5', - badgeLabel: 'New', availabilityStatus: 'available', }); expect(getTeamModelSelectionError('codex', 'gpt-5.5', providerStatus)).toBeNull(); }); + it('orders GPT-5.5 first after the virtual default option', () => { + const providerStatus = createCodexProviderStatus([ + { + id: 'gpt-5.4', + launchModel: 'gpt-5.4', + displayName: 'GPT-5.4', + hidden: false, + supportedReasoningEfforts: ['low', 'medium', 'high'], + defaultReasoningEffort: 'medium', + inputModalities: ['text', 'image'], + supportsPersonality: false, + isDefault: true, + upgrade: false, + source: 'app-server', + badgeLabel: '5.4', + }, + { + id: 'gpt-5.5', + launchModel: 'gpt-5.5', + displayName: 'GPT-5.5', + hidden: false, + supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh'], + defaultReasoningEffort: 'high', + inputModalities: ['text', 'image'], + supportsPersonality: false, + isDefault: false, + upgrade: false, + source: 'app-server', + badgeLabel: '5.5', + }, + { + id: 'gpt-5.2', + launchModel: 'gpt-5.2', + displayName: 'GPT-5.2', + hidden: false, + supportedReasoningEfforts: ['low', 'medium', 'high'], + defaultReasoningEffort: 'medium', + inputModalities: ['text', 'image'], + supportsPersonality: false, + isDefault: false, + upgrade: false, + source: 'app-server', + badgeLabel: '5.2', + }, + ]); + + expect( + getAvailableTeamProviderModelOptions('codex', providerStatus).map((model) => model.value) + ).toEqual(['', 'gpt-5.5', 'gpt-5.4', 'gpt-5.2']); + }); + it('keeps existing disabled model policy on top of the dynamic catalog', () => { const providerStatus = createCodexProviderStatus([ { diff --git a/src/renderer/utils/memberActivityTimer.ts b/src/renderer/utils/memberActivityTimer.ts new file mode 100644 index 00000000..d52f5c43 --- /dev/null +++ b/src/renderer/utils/memberActivityTimer.ts @@ -0,0 +1,374 @@ +import { isTeamTaskActivelyWorked } from '@shared/utils/teamTaskState'; + +import type { TeamTaskWithKanban } from '@shared/types'; + +export type MemberActivityPhase = 'work' | 'review'; + +export interface MemberActivityTimerAnchor { + timerId: string; + startedAt: string; + startedAtMs: number; + baseElapsedMs: number; + runId?: string | null; +} + +interface StoredActivityTimer { + version: 1; + startedAtMs: number; + baseElapsedMs: number; + elapsedMs: number; + updatedAtMs: number; + running: boolean; + runId?: string | null; +} + +const STORAGE_PREFIX = 'member-activity-timer:'; +const MAX_UNOBSERVED_RUN_TRANSITION_MS = 5_000; +const timers = new Map(); + +function parseIsoMs(value: string | null | undefined): number { + if (!value) return 0; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +function normalizeMemberName(value: string | null | undefined): string { + return typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : ''; +} + +function safeStorageGet(key: string): string | null { + try { + return globalThis.localStorage?.getItem(key) ?? null; + } catch { + return null; + } +} + +function safeStorageSet(key: string, value: string): void { + try { + globalThis.localStorage?.setItem(key, value); + } catch { + // localStorage can be unavailable in tests or restricted browser contexts. + } +} + +function storageKey(timerId: string): string { + return `${STORAGE_PREFIX}${timerId}`; +} + +function isStoredTimer(value: unknown): value is StoredActivityTimer { + if (!value || typeof value !== 'object') return false; + const row = value as Partial; + return ( + row.version === 1 && + typeof row.startedAtMs === 'number' && + Number.isFinite(row.startedAtMs) && + (row.baseElapsedMs === undefined || + (typeof row.baseElapsedMs === 'number' && Number.isFinite(row.baseElapsedMs))) && + typeof row.elapsedMs === 'number' && + Number.isFinite(row.elapsedMs) && + typeof row.updatedAtMs === 'number' && + Number.isFinite(row.updatedAtMs) && + typeof row.running === 'boolean' && + (row.runId === undefined || row.runId === null || typeof row.runId === 'string') + ); +} + +function readStoredTimer( + timerId: string, + startedAtMs: number, + baseElapsedMs: number +): StoredActivityTimer | null { + const cached = timers.get(timerId); + if (cached?.startedAtMs === startedAtMs) { + return cached.baseElapsedMs === baseElapsedMs + ? cached + : { ...cached, baseElapsedMs, elapsedMs: Math.max(baseElapsedMs, cached.elapsedMs) }; + } + + const raw = safeStorageGet(storageKey(timerId)); + if (!raw) return null; + + try { + const parsed = JSON.parse(raw) as unknown; + if (!isStoredTimer(parsed) || parsed.startedAtMs !== startedAtMs) return null; + const sanitized: StoredActivityTimer = { + version: 1, + startedAtMs: parsed.startedAtMs, + baseElapsedMs, + elapsedMs: Math.max(baseElapsedMs, parsed.elapsedMs), + updatedAtMs: Math.max(parsed.startedAtMs, parsed.updatedAtMs), + running: parsed.running, + runId: parsed.runId ?? null, + }; + timers.set(timerId, sanitized); + return sanitized; + } catch { + return null; + } +} + +function writeStoredTimer(timerId: string, timer: StoredActivityTimer): void { + timers.set(timerId, timer); + safeStorageSet(storageKey(timerId), JSON.stringify(timer)); +} + +function createInitialTimer( + startedAtMs: number, + baseElapsedMs: number, + running: boolean, + nowMs: number, + runId: string | null | undefined +): StoredActivityTimer { + if (running) { + return { + version: 1, + startedAtMs, + baseElapsedMs, + elapsedMs: baseElapsedMs, + updatedAtMs: startedAtMs, + running: true, + runId, + }; + } + + return { + version: 1, + startedAtMs, + baseElapsedMs, + elapsedMs: baseElapsedMs, + updatedAtMs: nowMs, + running: false, + runId, + }; +} + +function materializeElapsed( + timer: StoredActivityTimer, + nowMs: number, + runId: string | null | undefined +): number { + const baseElapsedMs = Math.max(0, timer.baseElapsedMs); + if (!timer.running) return Math.max(baseElapsedMs, timer.elapsedMs); + + const rawGapMs = Math.max(0, nowMs - timer.updatedAtMs); + const sameRun = (timer.runId ?? null) === (runId ?? null); + const gapMs = sameRun ? rawGapMs : Math.min(rawGapMs, MAX_UNOBSERVED_RUN_TRANSITION_MS); + return Math.max(baseElapsedMs, timer.elapsedMs + gapMs); +} + +export function createMemberActivityTimerId({ + teamName, + memberName, + phase, + taskId, + startedAt, +}: { + teamName: string; + memberName: string; + phase: MemberActivityPhase; + taskId: string; + startedAt: string; +}): string { + return [teamName, normalizeMemberName(memberName), phase, taskId, startedAt].join('\u0000'); +} + +export function syncMemberActivityTimer({ + timerId, + startedAtMs, + baseElapsedMs = 0, + running, + runId, + nowMs = Date.now(), +}: { + timerId: string; + startedAtMs: number; + baseElapsedMs?: number; + running: boolean; + runId?: string | null; + nowMs?: number; +}): number { + const existing = + readStoredTimer(timerId, startedAtMs, baseElapsedMs) ?? + createInitialTimer(startedAtMs, baseElapsedMs, running, nowMs, runId); + const elapsedMs = materializeElapsed(existing, nowMs, runId); + const next: StoredActivityTimer = { + version: 1, + startedAtMs, + baseElapsedMs, + elapsedMs, + updatedAtMs: nowMs, + running, + runId, + }; + writeStoredTimer(timerId, next); + return elapsedMs; +} + +export function readMemberActivityTimerElapsed({ + timerId, + startedAtMs, + baseElapsedMs = 0, + running, + runId, + nowMs = Date.now(), +}: { + timerId: string; + startedAtMs: number; + baseElapsedMs?: number; + running: boolean; + runId?: string | null; + nowMs?: number; +}): number { + const timer = + readStoredTimer(timerId, startedAtMs, baseElapsedMs) ?? + createInitialTimer(startedAtMs, baseElapsedMs, running, nowMs, runId); + return materializeElapsed(timer, nowMs, runId); +} + +export function formatMemberActivityElapsed(elapsedMs: number): string { + const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000)); + if (totalSeconds < 60) { + return `${totalSeconds}s`; + } + const totalMinutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + if (totalMinutes < 60) { + return `${totalMinutes}m ${String(seconds).padStart(2, '0')}s`; + } + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + return `${hours}h ${String(minutes).padStart(2, '0')}m`; +} + +export function deriveWorkActivityTimerAnchor( + task: TeamTaskWithKanban, + params: { + teamName: string; + memberName: string; + } +): MemberActivityTimerAnchor | null { + if (!isTeamTaskActivelyWorked(task)) return null; + + const intervals = Array.isArray(task.workIntervals) ? task.workIntervals : []; + let baseElapsedMs = 0; + for (let index = intervals.length - 1; index >= 0; index -= 1) { + const interval = intervals[index]; + const startedAtMs = parseIsoMs(interval?.startedAt); + if (startedAtMs > 0 && !interval?.completedAt) { + for (let previousIndex = 0; previousIndex < index; previousIndex += 1) { + const previous = intervals[previousIndex]; + const previousStartedAtMs = parseIsoMs(previous?.startedAt); + const previousCompletedAtMs = parseIsoMs(previous?.completedAt); + if (previousStartedAtMs > 0 && previousCompletedAtMs > previousStartedAtMs) { + baseElapsedMs += previousCompletedAtMs - previousStartedAtMs; + } + } + return { + startedAt: interval.startedAt, + startedAtMs, + baseElapsedMs, + timerId: createMemberActivityTimerId({ + teamName: params.teamName, + memberName: params.memberName, + phase: 'work', + taskId: task.id, + startedAt: interval.startedAt, + }), + }; + } + } + if (intervals.length > 0) return null; + + const events = Array.isArray(task.historyEvents) ? task.historyEvents : []; + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index]; + if (event.type === 'status_changed' && event.to === 'in_progress') { + const startedAtMs = parseIsoMs(event.timestamp); + if (startedAtMs > 0) { + return { + startedAt: event.timestamp, + startedAtMs, + baseElapsedMs: 0, + timerId: createMemberActivityTimerId({ + teamName: params.teamName, + memberName: params.memberName, + phase: 'work', + taskId: task.id, + startedAt: event.timestamp, + }), + }; + } + } + if (event.type === 'task_created' && event.status === 'in_progress') { + const startedAtMs = parseIsoMs(event.timestamp); + if (startedAtMs > 0) { + return { + startedAt: event.timestamp, + startedAtMs, + baseElapsedMs: 0, + timerId: createMemberActivityTimerId({ + teamName: params.teamName, + memberName: params.memberName, + phase: 'work', + taskId: task.id, + startedAt: event.timestamp, + }), + }; + } + } + } + + return null; +} + +export function deriveReviewActivityTimerAnchor( + task: TeamTaskWithKanban, + params: { + teamName: string; + memberName: string; + } +): MemberActivityTimerAnchor | null { + const memberKey = normalizeMemberName(params.memberName); + if (!memberKey) return null; + + const events = Array.isArray(task.historyEvents) ? task.historyEvents : []; + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index]; + if (event.type === 'review_started') { + if (normalizeMemberName(event.actor) !== memberKey) { + return null; + } + const startedAtMs = parseIsoMs(event.timestamp); + if (startedAtMs <= 0) return null; + return { + startedAt: event.timestamp, + startedAtMs, + baseElapsedMs: 0, + timerId: createMemberActivityTimerId({ + teamName: params.teamName, + memberName: params.memberName, + phase: 'review', + taskId: task.id, + startedAt: event.timestamp, + }), + }; + } + + if ( + event.type === 'review_approved' || + event.type === 'review_changes_requested' || + event.type === 'task_created' || + (event.type === 'status_changed' && + (event.to === 'in_progress' || event.to === 'pending' || event.to === 'deleted')) + ) { + return null; + } + } + + return null; +} + +export function resetMemberActivityTimerStoreForTests(): void { + timers.clear(); +} diff --git a/src/renderer/utils/memberHelpers.ts b/src/renderer/utils/memberHelpers.ts index 4c43e506..54caff24 100644 --- a/src/renderer/utils/memberHelpers.ts +++ b/src/renderer/utils/memberHelpers.ts @@ -711,6 +711,54 @@ function getLaunchVisualStateDotClass(visualState: MemberLaunchVisualState): str } } +export function shouldDisplayMemberCurrentTask({ + member, + isTeamAlive, + spawnStatus, + spawnLaunchState, + spawnRuntimeAlive, + runtimeEntry, +}: { + member: ResolvedTeamMember; + isTeamAlive?: boolean; + spawnStatus?: MemberSpawnStatus; + spawnLaunchState?: MemberLaunchState; + spawnRuntimeAlive?: boolean; + runtimeEntry?: TeamAgentRuntimeEntry; +}): boolean { + if (member.removedAt || member.status === 'terminated') { + return false; + } + if (isTeamAlive === false) { + return false; + } + if (spawnStatus === 'offline' || spawnStatus === 'error' || spawnStatus === 'skipped') { + return false; + } + if ( + spawnLaunchState === 'failed_to_start' || + spawnLaunchState === 'skipped_for_launch' || + spawnLaunchState === 'runtime_pending_permission' + ) { + return false; + } + if ( + runtimeEntry?.livenessKind === 'shell_only' || + runtimeEntry?.livenessKind === 'registered_only' || + runtimeEntry?.livenessKind === 'stale_metadata' || + runtimeEntry?.livenessKind === 'not_found' + ) { + return false; + } + if (runtimeEntry?.alive === false && spawnStatus !== 'online') { + return false; + } + if (spawnRuntimeAlive === false && spawnStatus !== 'online') { + return false; + } + return true; +} + function isQueuedOpenCodeLaunch( member: ResolvedTeamMember, spawnStatus: MemberSpawnStatus | undefined, diff --git a/src/renderer/utils/teamModelCatalog.ts b/src/renderer/utils/teamModelCatalog.ts index da480b2c..eb31e052 100644 --- a/src/renderer/utils/teamModelCatalog.ts +++ b/src/renderer/utils/teamModelCatalog.ts @@ -84,6 +84,7 @@ const TEAM_MODEL_LABEL_OVERRIDES: Record = { 'claude-haiku-4-5': 'Haiku 4.5', 'claude-haiku-4-5-20251001': 'Haiku 4.5', 'gpt-5.4': 'GPT-5.4', + 'gpt-5.5': 'GPT-5.5', 'gpt-5.4-mini': 'GPT-5.4 Mini', 'gpt-5.3-codex': 'GPT-5.3 Codex', 'gpt-5.3-codex-spark': 'GPT-5.3 Codex Spark', @@ -107,6 +108,7 @@ const TEAM_PROVIDER_MODEL_OPTIONS: Record { expect(fakeSession.request).toHaveBeenCalledTimes(1); expect(openExternalMock).toHaveBeenCalledTimes(1); expect(manager.getState().status).toBe('pending'); + expect(manager.getState().authUrl).toBe('https://chatgpt.com/auth'); }); it('cancels a login cleanly while the app-server session is still starting', async () => { @@ -135,6 +136,7 @@ describe('CodexLoginSessionManager', () => { status: 'cancelled', error: null, startedAt: null, + authUrl: null, }); }); @@ -170,6 +172,7 @@ describe('CodexLoginSessionManager', () => { status: 'idle', error: null, startedAt: null, + authUrl: null, }); }); diff --git a/test/features/codex-account/main/createCodexAccountFeature.test.ts b/test/features/codex-account/main/createCodexAccountFeature.test.ts index 4d4b802e..3e2d3a44 100644 --- a/test/features/codex-account/main/createCodexAccountFeature.test.ts +++ b/test/features/codex-account/main/createCodexAccountFeature.test.ts @@ -40,6 +40,7 @@ const { status: 'idle' as CodexAccountLoginStatus, error: null as string | null, startedAt: null as string | null, + authUrl: null as string | null, }, }, loginStateListeners: new Set<() => void>(), @@ -857,6 +858,7 @@ describe('createCodexAccountFeature', () => { status: 'pending', error: null, startedAt: '2026-04-20T12:00:00.000Z', + authUrl: 'https://chatgpt.com/auth', }); }); @@ -872,6 +874,7 @@ describe('createCodexAccountFeature', () => { expect(pendingSnapshot.login).toMatchObject({ status: 'pending', startedAt: '2026-04-20T12:00:00.000Z', + authUrl: 'https://chatgpt.com/auth', }); expect(loginStartMock).toHaveBeenCalledTimes(1); } finally { @@ -893,12 +896,14 @@ describe('createCodexAccountFeature', () => { status: 'pending', error: null, startedAt: '2026-04-20T12:00:00.000Z', + authUrl: 'https://chatgpt.com/auth', }); loginCancelMock.mockImplementation(() => { emitLoginState({ status: 'cancelled', error: null, startedAt: null, + authUrl: null, }); for (const listener of loginSettledListeners) { listener(); diff --git a/test/renderer/components/team/members/CurrentTaskIndicator.test.tsx b/test/renderer/components/team/members/CurrentTaskIndicator.test.tsx new file mode 100644 index 00000000..ade3735e --- /dev/null +++ b/test/renderer/components/team/members/CurrentTaskIndicator.test.tsx @@ -0,0 +1,68 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { CurrentTaskIndicator } from '@renderer/components/team/members/CurrentTaskIndicator'; +import { + createMemberActivityTimerId, + resetMemberActivityTimerStoreForTests, +} from '@renderer/utils/memberActivityTimer'; + +import type { TeamTaskWithKanban } from '@shared/types'; + +const task: TeamTaskWithKanban = { + id: 'task-1', + displayId: 'abc12345', + subject: 'Build feature', + status: 'in_progress', +}; + +describe('CurrentTaskIndicator', () => { + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + resetMemberActivityTimerStoreForTests(); + globalThis.localStorage?.clear(); + document.body.innerHTML = ''; + }); + + it('renders a compact activity timer from the persisted task start anchor', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-07T09:01:05.000Z')); + const startedAt = '2026-05-07T09:00:00.000Z'; + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('1m 05s'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +}); diff --git a/test/renderer/components/team/members/MemberHoverCard.test.ts b/test/renderer/components/team/members/MemberHoverCard.test.ts index 09153c73..2bb73ef2 100644 --- a/test/renderer/components/team/members/MemberHoverCard.test.ts +++ b/test/renderer/components/team/members/MemberHoverCard.test.ts @@ -2,7 +2,7 @@ import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { ResolvedTeamMember } from '@shared/types'; +import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; const member: ResolvedTeamMember = { name: 'alice', @@ -22,7 +22,7 @@ const storeState = { selectedTeamData: { members: [member], isAlive: true, - tasks: [], + tasks: [] as TeamTaskWithKanban[], }, selectedTeamName: 'northstar-core', progress: null as Record | null, @@ -118,7 +118,18 @@ vi.mock('@renderer/components/ui/tooltip', () => ({ })); vi.mock('@renderer/components/team/members/CurrentTaskIndicator', () => ({ - CurrentTaskIndicator: () => null, + CurrentTaskIndicator: ({ + task, + activityLabel, + }: { + task: TeamTaskWithKanban; + activityLabel?: string; + }) => + React.createElement( + 'span', + { 'data-testid': 'hover-current-task' }, + `${activityLabel ?? 'task'} ${task.id}` + ), })); import { MemberHoverCard } from '@renderer/components/team/members/MemberHoverCard'; @@ -307,6 +318,45 @@ describe('MemberHoverCard spawn-aware presence', () => { }); }); + it('does not show a working-on task when the member is offline', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const task: TeamTaskWithKanban = { + id: 'task-active', + subject: 'Active work', + status: 'in_progress', + }; + storeState.selectedTeamData.members = [{ ...member, currentTaskId: task.id }]; + storeState.selectedTeamData.tasks = [task]; + storeState.memberSpawnStatusesByTeam['northstar-core'].alice = { + status: 'offline', + launchState: 'confirmed_alive', + updatedAt: '2026-04-09T10:00:00.000Z', + runtimeAlive: false, + }; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(MemberHoverCard, { + name: 'alice', + children: React.createElement('button', { type: 'button' }, 'alice'), + }) + ); + await Promise.resolve(); + }); + + expect(host.querySelector('[data-testid="hover-current-task"]')).toBeNull(); + expect(host.textContent).not.toContain('working on'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('copies launch diagnostics with the active runtime run id only for launch errors', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); const writeText = vi.fn().mockResolvedValue(undefined); diff --git a/test/renderer/components/team/members/MemberList.test.ts b/test/renderer/components/team/members/MemberList.test.ts index b5880a44..83665994 100644 --- a/test/renderer/components/team/members/MemberList.test.ts +++ b/test/renderer/components/team/members/MemberList.test.ts @@ -89,6 +89,24 @@ function failedSpawnStatus(reason: string): MemberSpawnStatusEntry { }; } +function offlineSpawnStatus(): MemberSpawnStatusEntry { + return { + status: 'offline', + launchState: 'confirmed_alive', + updatedAt: '2026-04-23T10:00:00.000Z', + runtimeAlive: false, + bootstrapConfirmed: false, + }; +} + +function activeTask(id = 'task-active'): TeamTaskWithKanban { + return { + id, + subject: 'Active task', + status: 'in_progress', + }; +} + describe('MemberList spawn-status memoization', () => { beforeEach(() => { vi.stubGlobal( @@ -240,6 +258,61 @@ describe('MemberList spawn-status memoization', () => { }); }); + it('does not pass active current tasks to cards while the whole team is offline', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const task = activeTask(); + const members: ResolvedTeamMember[] = [{ ...member, currentTaskId: task.id }]; + + await act(async () => { + root.render( + React.createElement(MemberList, { + members, + isTeamAlive: false, + taskMap: new Map([[task.id, task]]), + }) + ); + await Promise.resolve(); + }); + + expect(host.querySelector('[data-testid="current-bob"]')).toBeNull(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('does not pass active current tasks to cards for individually offline members', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const task = activeTask(); + const members: ResolvedTeamMember[] = [{ ...member, currentTaskId: task.id }]; + + await act(async () => { + root.render( + React.createElement(MemberList, { + members, + isTeamAlive: true, + taskMap: new Map([[task.id, task]]), + memberSpawnStatuses: new Map([['bob', offlineSpawnStatus()]]), + }) + ); + await Promise.resolve(); + }); + + expect(host.querySelector('[data-testid="current-bob"]')).toBeNull(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('passes skip callbacks to failed member cards and rerenders when the callback changes', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); const host = document.createElement('div'); diff --git a/test/renderer/features/agent-graph/GraphMemberLogPreviewHud.test.tsx b/test/renderer/features/agent-graph/GraphMemberLogPreviewHud.test.tsx index c75c49a1..a438bf52 100644 --- a/test/renderer/features/agent-graph/GraphMemberLogPreviewHud.test.tsx +++ b/test/renderer/features/agent-graph/GraphMemberLogPreviewHud.test.tsx @@ -6,7 +6,7 @@ import { GraphMemberLogPreviewHud } from '@features/agent-graph/renderer/ui/Grap import type { GraphNode } from '@claude-teams/agent-graph'; -const previewsByMember = new Map([ +const basePreviewsByMember = new Map([ [ 'team-lead', { @@ -43,6 +43,24 @@ const previewsByMember = new Map([ preview: 'pnpm test', tone: 'warning' as const, }, + { + id: 'preview-2', + kind: 'tool_result' as const, + provider: 'opencode_runtime' as const, + timestamp: '2026-04-03T00:00:30.000Z', + title: 'Send message error', + preview: 'OpenCode tool failed without output', + tone: 'error' as const, + }, + { + id: 'preview-3', + kind: 'tool_result' as const, + provider: 'opencode_runtime' as const, + timestamp: '2026-04-03T00:00:40.000Z', + title: 'Bash result', + preview: 'Tests passed', + tone: 'success' as const, + }, ], coverage: [{ provider: 'claude_transcript' as const, status: 'included' as const }], warnings: [], @@ -52,11 +70,12 @@ const previewsByMember = new Map([ }, ], ]); +let mockedPreviewsByMember = basePreviewsByMember; vi.mock('@features/agent-graph/renderer/hooks/useGraphMemberLogPreviews', () => ({ buildGraphLogPreviewLaneIdsByMember: () => ({ alice: 'secondary:opencode:alice' }), useGraphMemberLogPreviews: () => ({ - previewsByMember, + previewsByMember: mockedPreviewsByMember, loading: false, error: null, reload: vi.fn(), @@ -93,6 +112,7 @@ describe('GraphMemberLogPreviewHud', () => { vi.stubGlobal('cancelAnimationFrame', vi.fn()); vi.useFakeTimers(); vi.setSystemTime(new Date('2026-04-03T00:01:00.000Z')); + mockedPreviewsByMember = basePreviewsByMember; }); afterEach(() => { @@ -141,6 +161,20 @@ describe('GraphMemberLogPreviewHud', () => { button.textContent?.includes('pnpm test') ); expect(row).not.toBeUndefined(); + expect(row?.querySelector('.float-left')).not.toBeNull(); + expect(row?.querySelector('.line-clamp-3')).toBeNull(); + expect(row?.textContent).toContain('pnpm test'); + + const errorRow = Array.from(host.querySelectorAll('button')).find((button) => + button.textContent?.includes('OpenCode tool failed') + ); + expect(errorRow?.querySelector('svg.text-rose-300')).not.toBeNull(); + + const resultRow = Array.from(host.querySelectorAll('button')).find((button) => + button.textContent?.includes('Tests passed') + ); + expect(resultRow?.textContent).toContain('Bash'); + expect(resultRow?.textContent).not.toContain('Bash result'); await act(async () => { row?.dispatchEvent(new MouseEvent('click', { bubbles: true })); @@ -166,6 +200,83 @@ describe('GraphMemberLogPreviewHud', () => { }); }); + it('briefly highlights a newly appeared preview row', async () => { + const node: GraphNode = { + id: 'member:alpha-team:alice', + kind: 'member', + label: 'alice', + state: 'active', + domainRef: { kind: 'member', teamName: 'alpha-team', memberName: 'alice' }, + }; + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const renderHud = (): void => { + root.render( + ({ + left: 40, + top: 80, + right: 300, + bottom: 372, + width: 260, + height: 292, + })} + getCameraZoom={() => 1} + worldToScreen={(x, y) => ({ x, y })} + getViewportSize={() => ({ width: 1200, height: 800 })} + focusNodeIds={null} + /> + ); + }; + + await act(async () => { + renderHud(); + await Promise.resolve(); + }); + + const alicePreview = basePreviewsByMember.get('alice')!; + mockedPreviewsByMember = new Map(basePreviewsByMember); + mockedPreviewsByMember.set('alice', { + ...alicePreview, + items: [ + { + id: 'preview-new', + kind: 'text' as const, + provider: 'claude_transcript' as const, + timestamp: '2026-04-03T00:01:00.000Z', + title: 'Assistant', + preview: 'new compact log', + tone: 'neutral' as const, + }, + ...alicePreview.items, + ], + }); + + await act(async () => { + renderHud(); + await Promise.resolve(); + }); + + const newRow = Array.from(host.querySelectorAll('button')).find((button) => + button.textContent?.includes('new compact log') + ); + expect(newRow?.className).toContain('border-sky-300/70'); + + await act(async () => { + vi.advanceTimersByTime(1_000); + await Promise.resolve(); + }); + + expect(newRow?.className).not.toContain('border-sky-300/70'); + + act(() => { + root.unmount(); + }); + }); + it('renders lead log previews and opens the lead profile logs tab', async () => { const leadNode: GraphNode = { id: 'lead:alpha-team', diff --git a/test/renderer/features/agent-graph/useGraphSimulation.test.ts b/test/renderer/features/agent-graph/useGraphSimulation.test.ts index 5cb11542..a921e312 100644 --- a/test/renderer/features/agent-graph/useGraphSimulation.test.ts +++ b/test/renderer/features/agent-graph/useGraphSimulation.test.ts @@ -11,7 +11,10 @@ import { validateStableSlotLayout, } from '../../../../packages/agent-graph/src/layout/stableSlots'; import { KanbanLayoutEngine } from '../../../../packages/agent-graph/src/layout/kanbanLayout'; -import { TASK_PILL } from '../../../../packages/agent-graph/src/constants/canvas-constants'; +import { + KANBAN_ZONE, + TASK_PILL, +} from '../../../../packages/agent-graph/src/constants/canvas-constants'; import { ACTIVITY_LANE } from '../../../../packages/agent-graph/src/layout/activityLane'; import { STABLE_SLOT_GEOMETRY, @@ -171,7 +174,10 @@ describe('stable slot layout planner', () => { expect(frame).toBeDefined(); expect(frame?.boardBandRect.top).toBe(frame?.activityColumnRect.top); expect(frame?.boardBandRect.top).toBe(frame?.logColumnRect.top); - expect(frame?.boardBandRect.top).toBe(frame?.kanbanBandRect.top); + const expectedKanbanTopInset = + ACTIVITY_LANE.headerHeight + 4 - (KANBAN_ZONE.headerHeight - TASK_PILL.height / 2); + expect(frame?.kanbanBandRect.top).toBe(frame!.boardBandRect.top + expectedKanbanTopInset); + expect(frame?.kanbanBandRect.bottom).toBeLessThanOrEqual(frame!.boardBandRect.bottom); expect(frame?.activityColumnRect.left).toBe(frame?.boardBandRect.left); expect(frame?.logColumnRect.left).toBeGreaterThan(frame?.activityColumnRect.right ?? 0); expect(frame?.kanbanBandRect.left).toBeGreaterThan(frame?.logColumnRect.right ?? 0); diff --git a/test/renderer/utils/memberActivityTimer.test.ts b/test/renderer/utils/memberActivityTimer.test.ts new file mode 100644 index 00000000..b5f824ee --- /dev/null +++ b/test/renderer/utils/memberActivityTimer.test.ts @@ -0,0 +1,284 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + createMemberActivityTimerId, + deriveReviewActivityTimerAnchor, + deriveWorkActivityTimerAnchor, + formatMemberActivityElapsed, + readMemberActivityTimerElapsed, + resetMemberActivityTimerStoreForTests, + syncMemberActivityTimer, +} from '@renderer/utils/memberActivityTimer'; + +import type { TeamTaskWithKanban } from '@shared/types'; + +const baseTask: TeamTaskWithKanban = { + id: 'task-1', + displayId: 'abc12345', + subject: 'Build feature', + status: 'in_progress', + createdAt: '2026-05-07T09:00:00.000Z', + reviewState: 'none', +}; + +describe('memberActivityTimer', () => { + afterEach(() => { + vi.useRealTimers(); + resetMemberActivityTimerStoreForTests(); + globalThis.localStorage?.clear(); + }); + + it('anchors work timers to the active work interval', () => { + const task: TeamTaskWithKanban = { + ...baseTask, + workIntervals: [ + { + startedAt: '2026-05-07T09:10:00.000Z', + completedAt: '2026-05-07T09:15:00.000Z', + }, + { startedAt: '2026-05-07T09:20:00.000Z' }, + ], + }; + + const anchor = deriveWorkActivityTimerAnchor(task, { + teamName: 'alpha', + memberName: 'bob', + }); + + expect(anchor?.startedAt).toBe('2026-05-07T09:20:00.000Z'); + expect(anchor?.baseElapsedMs).toBe(300_000); + expect(anchor?.timerId).toContain('task-1'); + }); + + it('adds completed work intervals to the active timer elapsed value', () => { + const task: TeamTaskWithKanban = { + ...baseTask, + workIntervals: [ + { + startedAt: '2026-05-07T09:10:00.000Z', + completedAt: '2026-05-07T09:15:00.000Z', + }, + { startedAt: '2026-05-07T09:20:00.000Z' }, + ], + }; + const anchor = deriveWorkActivityTimerAnchor(task, { + teamName: 'alpha', + memberName: 'bob', + }); + expect(anchor).not.toBeNull(); + + expect( + readMemberActivityTimerElapsed({ + timerId: anchor!.timerId, + startedAtMs: anchor!.startedAtMs, + baseElapsedMs: anchor!.baseElapsedMs, + running: true, + runId: 'run-1', + nowMs: Date.parse('2026-05-07T09:21:00.000Z'), + }) + ).toBe(360_000); + }); + + it('does not invent a work timer when task start evidence is missing', () => { + expect( + deriveWorkActivityTimerAnchor(baseTask, { + teamName: 'alpha', + memberName: 'bob', + }) + ).toBeNull(); + }); + + it('treats closed work intervals without an active interval as paused', () => { + const task: TeamTaskWithKanban = { + ...baseTask, + workIntervals: [ + { + startedAt: '2026-05-07T09:10:00.000Z', + completedAt: '2026-05-07T09:15:00.000Z', + }, + ], + historyEvents: [ + { + id: 'evt-1', + type: 'status_changed', + from: 'pending', + to: 'in_progress', + timestamp: '2026-05-07T09:10:00.000Z', + }, + ], + }; + + expect( + deriveWorkActivityTimerAnchor(task, { + teamName: 'alpha', + memberName: 'bob', + }) + ).toBeNull(); + }); + + it('anchors review timers only after the reviewer actually starts review', () => { + const assignedOnly: TeamTaskWithKanban = { + ...baseTask, + status: 'completed', + reviewState: 'review', + kanbanColumn: 'review', + reviewer: 'alice', + historyEvents: [ + { + id: 'evt-1', + type: 'review_requested', + from: 'none', + to: 'review', + reviewer: 'alice', + timestamp: '2026-05-07T09:30:00.000Z', + }, + ], + }; + + expect( + deriveReviewActivityTimerAnchor(assignedOnly, { + teamName: 'alpha', + memberName: 'alice', + }) + ).toBeNull(); + + const started: TeamTaskWithKanban = { + ...assignedOnly, + historyEvents: [ + ...(assignedOnly.historyEvents ?? []), + { + id: 'evt-2', + type: 'review_started', + from: 'review', + to: 'review', + actor: 'alice', + timestamp: '2026-05-07T09:35:00.000Z', + }, + ], + }; + + expect( + deriveReviewActivityTimerAnchor(started, { + teamName: 'alpha', + memberName: 'alice', + })?.startedAt + ).toBe('2026-05-07T09:35:00.000Z'); + }); + + it('pauses elapsed time while the activity is not running and resumes from the frozen value', () => { + const timerId = createMemberActivityTimerId({ + teamName: 'alpha', + memberName: 'bob', + phase: 'work', + taskId: 'task-1', + startedAt: '2026-05-07T09:00:00.000Z', + }); + const startedAtMs = Date.parse('2026-05-07T09:00:00.000Z'); + + syncMemberActivityTimer({ + timerId, + startedAtMs, + baseElapsedMs: 0, + running: true, + runId: 'run-1', + nowMs: Date.parse('2026-05-07T09:01:00.000Z'), + }); + + expect( + readMemberActivityTimerElapsed({ + timerId, + startedAtMs, + baseElapsedMs: 0, + running: true, + runId: 'run-1', + nowMs: Date.parse('2026-05-07T09:02:00.000Z'), + }) + ).toBe(120_000); + + syncMemberActivityTimer({ + timerId, + startedAtMs, + baseElapsedMs: 0, + running: false, + runId: 'run-1', + nowMs: Date.parse('2026-05-07T09:02:00.000Z'), + }); + + expect( + readMemberActivityTimerElapsed({ + timerId, + startedAtMs, + baseElapsedMs: 0, + running: false, + runId: 'run-1', + nowMs: Date.parse('2026-05-07T09:05:00.000Z'), + }) + ).toBe(120_000); + + syncMemberActivityTimer({ + timerId, + startedAtMs, + baseElapsedMs: 0, + running: true, + runId: 'run-1', + nowMs: Date.parse('2026-05-07T09:05:00.000Z'), + }); + + expect( + readMemberActivityTimerElapsed({ + timerId, + startedAtMs, + baseElapsedMs: 0, + running: true, + runId: 'run-1', + nowMs: Date.parse('2026-05-07T09:06:00.000Z'), + }) + ).toBe(180_000); + }); + + it('caps elapsed time across unobserved runtime run transitions', () => { + const timerId = createMemberActivityTimerId({ + teamName: 'alpha', + memberName: 'bob', + phase: 'work', + taskId: 'task-1', + startedAt: '2026-05-07T09:00:00.000Z', + }); + const startedAtMs = Date.parse('2026-05-07T09:00:00.000Z'); + + syncMemberActivityTimer({ + timerId, + startedAtMs, + baseElapsedMs: 0, + running: true, + runId: 'run-1', + nowMs: Date.parse('2026-05-07T09:01:00.000Z'), + }); + + syncMemberActivityTimer({ + timerId, + startedAtMs, + baseElapsedMs: 0, + running: true, + runId: 'run-2', + nowMs: Date.parse('2026-05-07T10:00:00.000Z'), + }); + + expect( + readMemberActivityTimerElapsed({ + timerId, + startedAtMs, + baseElapsedMs: 0, + running: true, + runId: 'run-2', + nowMs: Date.parse('2026-05-07T10:00:00.000Z'), + }) + ).toBe(65_000); + }); + + it('formats seconds, minutes, and hours compactly', () => { + expect(formatMemberActivityElapsed(9_000)).toBe('9s'); + expect(formatMemberActivityElapsed(65_000)).toBe('1m 05s'); + expect(formatMemberActivityElapsed(3_780_000)).toBe('1h 03m'); + }); +}); diff --git a/test/renderer/utils/memberHelpers.test.ts b/test/renderer/utils/memberHelpers.test.ts index 7c0e1ea5..7395d715 100644 --- a/test/renderer/utils/memberHelpers.test.ts +++ b/test/renderer/utils/memberHelpers.test.ts @@ -8,6 +8,7 @@ import { getMemberRuntimeAdvisoryTitle, getMemberRuntimeAdvisoryTone, isOpenCodeRelaunchActionable, + shouldDisplayMemberCurrentTask, } from '@renderer/utils/memberHelpers'; import type { ResolvedTeamMember } from '@shared/types'; @@ -27,6 +28,73 @@ const member: ResolvedTeamMember = { }; describe('memberHelpers spawn-aware presence', () => { + it('does not display current task labels for offline or terminal launch states', () => { + expect( + shouldDisplayMemberCurrentTask({ + member: { ...member, currentTaskId: 'task-1' }, + isTeamAlive: false, + }) + ).toBe(false); + + expect( + shouldDisplayMemberCurrentTask({ + member: { ...member, currentTaskId: 'task-1' }, + isTeamAlive: true, + spawnStatus: 'offline', + spawnLaunchState: 'confirmed_alive', + spawnRuntimeAlive: false, + }) + ).toBe(false); + + expect( + shouldDisplayMemberCurrentTask({ + member: { ...member, currentTaskId: 'task-1' }, + isTeamAlive: true, + spawnStatus: 'error', + spawnLaunchState: 'failed_to_start', + }) + ).toBe(false); + }); + + it('does not display current task labels for runtime entries without a live agent runtime', () => { + expect( + shouldDisplayMemberCurrentTask({ + member: { ...member, currentTaskId: 'task-1' }, + isTeamAlive: true, + spawnStatus: 'online', + spawnLaunchState: 'confirmed_alive', + runtimeEntry: { + memberName: 'alice', + alive: false, + restartable: true, + providerId: 'opencode', + livenessKind: 'stale_metadata', + updatedAt: '2026-04-24T12:00:00.000Z', + }, + }) + ).toBe(false); + }); + + it('keeps current task labels for confirmed online members', () => { + expect( + shouldDisplayMemberCurrentTask({ + member: { ...member, currentTaskId: 'task-1' }, + isTeamAlive: true, + spawnStatus: 'online', + spawnLaunchState: 'confirmed_alive', + spawnRuntimeAlive: true, + runtimeEntry: { + memberName: 'alice', + alive: true, + restartable: true, + providerId: 'gemini', + livenessKind: 'confirmed_bootstrap', + updatedAt: '2026-04-24T12:00:00.000Z', + }, + }) + ).toBe(true); + }); + it('shows process-online teammates as online with a green dot', () => { expect( getSpawnAwarePresenceLabel(