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 00000000..b8aec00b Binary files /dev/null and b/graph-log-preview-smoke.png differ 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(