From 804e92419f02a60429b71ea2983a63f4fec9346d Mon Sep 17 00:00:00 2001 From: 777genius Date: Mon, 13 Apr 2026 19:19:52 +0300 Subject: [PATCH] feat(activity-detail): implement task activity detail retrieval and UI integration --- src/main/index.ts | 5 + src/main/ipc/handlers.ts | 3 + src/main/ipc/teams.ts | 41 +++ src/main/services/discovery/ProjectScanner.ts | 3 + src/main/services/team/index.ts | 1 + .../BoardTaskActivityDetailService.ts | 199 +++++++++++ src/main/types/domain.ts | 2 + src/main/utils/jsonl.ts | 11 + src/preload/constants/ipcChannels.ts | 3 + src/preload/index.ts | 10 + src/renderer/api/httpClient.ts | 5 + .../sidebar/DateGroupedSessions.tsx | 14 +- .../components/sidebar/SessionItem.tsx | 31 ++ .../team/dialogs/TeamModelSelector.tsx | 14 +- .../team/messages/MessageComposer.tsx | 23 +- .../team/taskLogs/TaskActivitySection.tsx | 313 ++++++++++++++---- src/renderer/utils/teamModelCatalog.ts | 17 + src/shared/types/api.ts | 6 + src/shared/types/team.ts | 24 ++ .../utils/boardTaskActivityPresentation.ts | 75 +++++ src/shared/utils/teamProvider.ts | 28 ++ test/main/ipc/teams.test.ts | 37 +++ .../BoardTaskActivityDetailService.test.ts | 148 +++++++++ .../team/taskLogs/TaskActivitySection.test.ts | 156 ++++++++- 24 files changed, 1084 insertions(+), 85 deletions(-) create mode 100644 src/main/services/team/taskLogs/activity/BoardTaskActivityDetailService.ts create mode 100644 src/shared/utils/boardTaskActivityPresentation.ts create mode 100644 test/main/services/team/BoardTaskActivityDetailService.test.ts diff --git a/src/main/index.ts b/src/main/index.ts index 747e1c2e..832baa3e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -103,6 +103,7 @@ import { import { syncTelemetryFlag } from './sentry'; import { BoardTaskActivityRecordSource, + BoardTaskActivityDetailService, BoardTaskActivityService, BoardTaskExactLogDetailService, BoardTaskExactLogsService, @@ -786,6 +787,9 @@ async function initializeServices(): Promise { const teamMemberLogsFinder = new TeamMemberLogsFinder(); const boardTaskActivityRecordSource = new BoardTaskActivityRecordSource(); const boardTaskActivityService = new BoardTaskActivityService(boardTaskActivityRecordSource); + const boardTaskActivityDetailService = new BoardTaskActivityDetailService( + boardTaskActivityRecordSource + ); const boardTaskExactLogsService = new BoardTaskExactLogsService(boardTaskActivityRecordSource); const boardTaskExactLogDetailService = new BoardTaskExactLogDetailService( boardTaskActivityRecordSource @@ -937,6 +941,7 @@ async function initializeServices(): Promise { teamMemberLogsFinder, memberStatsComputer, boardTaskActivityService, + boardTaskActivityDetailService, boardTaskLogStreamService, boardTaskExactLogsService, boardTaskExactLogDetailService, diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 819275be..772501e1 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -89,6 +89,7 @@ import { registerValidationHandlers, removeValidationHandlers } from './validati import { registerWindowHandlers, removeWindowHandlers } from './window'; import type { + BoardTaskActivityDetailService, BoardTaskActivityService, BoardTaskExactLogDetailService, BoardTaskExactLogsService, @@ -135,6 +136,7 @@ export function initializeIpcHandlers( teamMemberLogsFinder: TeamMemberLogsFinder, memberStatsComputer: MemberStatsComputer, boardTaskActivityService: BoardTaskActivityService, + boardTaskActivityDetailService: BoardTaskActivityDetailService, boardTaskLogStreamService: BoardTaskLogStreamService, boardTaskExactLogsService: BoardTaskExactLogsService, boardTaskExactLogDetailService: BoardTaskExactLogDetailService, @@ -184,6 +186,7 @@ export function initializeIpcHandlers( teammateToolTracker, branchStatusService, boardTaskActivityService, + boardTaskActivityDetailService, boardTaskLogStreamService, boardTaskExactLogsService, boardTaskExactLogDetailService diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 062f5e8e..c6d9dee7 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -28,6 +28,7 @@ import { TEAM_GET_PROJECT_BRANCH, TEAM_GET_SAVED_REQUEST, TEAM_GET_TASK_ACTIVITY, + TEAM_GET_TASK_ACTIVITY_DETAIL, TEAM_GET_TASK_ATTACHMENT, TEAM_GET_TASK_CHANGE_PRESENCE, TEAM_GET_TASK_EXACT_LOG_DETAIL, @@ -122,6 +123,7 @@ import { import type { BoardTaskActivityService, + BoardTaskActivityDetailService, BoardTaskExactLogDetailService, BoardTaskExactLogsService, BoardTaskLogStreamService, @@ -140,6 +142,7 @@ import type { AttachmentMeta, AttachmentPayload, BoardTaskActivityEntry, + BoardTaskActivityDetailResult, BoardTaskExactLogDetailResult, BoardTaskExactLogSummariesResponse, BoardTaskLogStreamResponse, @@ -390,6 +393,7 @@ let teamBackupService: TeamBackupService | null = null; let teammateToolTracker: TeammateToolTracker | null = null; let branchStatusService: BranchStatusService | null = null; let boardTaskActivityService: BoardTaskActivityService | null = null; +let boardTaskActivityDetailService: BoardTaskActivityDetailService | null = null; let boardTaskLogStreamService: BoardTaskLogStreamService | null = null; let boardTaskExactLogsService: BoardTaskExactLogsService | null = null; let boardTaskExactLogDetailService: BoardTaskExactLogDetailService | null = null; @@ -425,6 +429,7 @@ export function initializeTeamHandlers( toolTracker?: TeammateToolTracker, branchTracker?: BranchStatusService, taskActivityService?: BoardTaskActivityService, + taskActivityDetailService?: BoardTaskActivityDetailService, taskLogStreamService?: BoardTaskLogStreamService, taskExactLogsService?: BoardTaskExactLogsService, taskExactLogDetailService?: BoardTaskExactLogDetailService @@ -437,6 +442,7 @@ export function initializeTeamHandlers( teammateToolTracker = toolTracker ?? null; branchStatusService = branchTracker ?? null; boardTaskActivityService = taskActivityService ?? null; + boardTaskActivityDetailService = taskActivityDetailService ?? null; boardTaskLogStreamService = taskLogStreamService ?? null; boardTaskExactLogsService = taskExactLogsService ?? null; boardTaskExactLogDetailService = taskExactLogDetailService ?? null; @@ -475,6 +481,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void { ipcMain.handle(TEAM_GET_MEMBER_LOGS, handleGetMemberLogs); ipcMain.handle(TEAM_GET_LOGS_FOR_TASK, handleGetLogsForTask); ipcMain.handle(TEAM_GET_TASK_ACTIVITY, handleGetTaskActivity); + ipcMain.handle(TEAM_GET_TASK_ACTIVITY_DETAIL, handleGetTaskActivityDetail); ipcMain.handle(TEAM_GET_TASK_LOG_STREAM, handleGetTaskLogStream); ipcMain.handle(TEAM_GET_TASK_EXACT_LOG_SUMMARIES, handleGetTaskExactLogSummaries); ipcMain.handle(TEAM_GET_TASK_EXACT_LOG_DETAIL, handleGetTaskExactLogDetail); @@ -546,6 +553,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(TEAM_GET_MEMBER_LOGS); ipcMain.removeHandler(TEAM_GET_LOGS_FOR_TASK); ipcMain.removeHandler(TEAM_GET_TASK_ACTIVITY); + ipcMain.removeHandler(TEAM_GET_TASK_ACTIVITY_DETAIL); ipcMain.removeHandler(TEAM_GET_TASK_LOG_STREAM); ipcMain.removeHandler(TEAM_GET_TASK_EXACT_LOG_SUMMARIES); ipcMain.removeHandler(TEAM_GET_TASK_EXACT_LOG_DETAIL); @@ -618,6 +626,13 @@ function getBoardTaskActivityService(): BoardTaskActivityService { return boardTaskActivityService; } +function getBoardTaskActivityDetailService(): BoardTaskActivityDetailService { + if (!boardTaskActivityDetailService) { + throw new Error('Board task activity detail service is not initialized'); + } + return boardTaskActivityDetailService; +} + function getBoardTaskLogStreamService(): BoardTaskLogStreamService { if (!boardTaskLogStreamService) { throw new Error('Board task log stream service is not initialized'); @@ -2518,6 +2533,32 @@ async function handleGetTaskActivity( ); } +async function handleGetTaskActivityDetail( + _event: IpcMainInvokeEvent, + teamName: unknown, + taskId: unknown, + activityId: unknown +): Promise> { + const vTeam = validateTeamName(teamName); + if (!vTeam.valid) { + return { success: false, error: vTeam.error ?? 'Invalid teamName' }; + } + const vTask = validateTaskId(taskId); + if (!vTask.valid) { + return { success: false, error: vTask.error ?? 'Invalid taskId' }; + } + if (typeof activityId !== 'string' || activityId.trim().length === 0) { + return { success: false, error: 'activityId must be a non-empty string' }; + } + return wrapTeamHandler('getTaskActivityDetail', () => + getBoardTaskActivityDetailService().getTaskActivityDetail( + vTeam.value!, + vTask.value!, + activityId.trim() + ) + ); +} + async function handleGetTaskLogStream( _event: IpcMainInvokeEvent, teamName: unknown, diff --git a/src/main/services/discovery/ProjectScanner.ts b/src/main/services/discovery/ProjectScanner.ts index f1e9eb23..da24adb1 100644 --- a/src/main/services/discovery/ProjectScanner.ts +++ b/src/main/services/discovery/ProjectScanner.ts @@ -1002,6 +1002,7 @@ export class ProjectScanner { hasSubagents, messageCount: metadata.messageCount, isOngoing, + model: metadata.model ?? undefined, gitBranch: metadata.gitBranch ?? undefined, metadataLevel, contextConsumption: metadata.contextConsumption, @@ -1050,6 +1051,7 @@ export class ProjectScanner { messageCount: 0, isOngoing: false, gitBranch: null, + model: null, }; } } @@ -1069,6 +1071,7 @@ export class ProjectScanner { messageTimestamp: metadata.firstUserMessage?.timestamp, hasSubagents: false, messageCount: metadata.messageCount, + model: metadata.model ?? undefined, metadataLevel, }; } diff --git a/src/main/services/team/index.ts b/src/main/services/team/index.ts index d26e731c..ed3deb52 100644 --- a/src/main/services/team/index.ts +++ b/src/main/services/team/index.ts @@ -11,6 +11,7 @@ export { MemberStatsComputer } from './MemberStatsComputer'; export { ReviewApplierService } from './ReviewApplierService'; export { TaskBoundaryParser } from './TaskBoundaryParser'; export { BoardTaskActivityRecordSource } from './taskLogs/activity/BoardTaskActivityRecordSource'; +export { BoardTaskActivityDetailService } from './taskLogs/activity/BoardTaskActivityDetailService'; export { BoardTaskActivityService } from './taskLogs/activity/BoardTaskActivityService'; export { BoardTaskExactLogDetailService } from './taskLogs/exact/BoardTaskExactLogDetailService'; export { BoardTaskExactLogsService } from './taskLogs/exact/BoardTaskExactLogsService'; diff --git a/src/main/services/team/taskLogs/activity/BoardTaskActivityDetailService.ts b/src/main/services/team/taskLogs/activity/BoardTaskActivityDetailService.ts new file mode 100644 index 00000000..61846b5b --- /dev/null +++ b/src/main/services/team/taskLogs/activity/BoardTaskActivityDetailService.ts @@ -0,0 +1,199 @@ +import { + describeBoardTaskActivityLabel, + formatBoardTaskActivityTaskLabel, +} from '@shared/utils/boardTaskActivityLabels'; +import { + describeBoardTaskActivityActorLabel, + describeBoardTaskActivityContextLines, +} from '@shared/utils/boardTaskActivityPresentation'; + +import { BoardTaskActivityRecordSource } from './BoardTaskActivityRecordSource'; +import { BoardTaskExactLogChunkBuilder } from '../exact/BoardTaskExactLogChunkBuilder'; +import { BoardTaskExactLogDetailSelector } from '../exact/BoardTaskExactLogDetailSelector'; +import { BoardTaskExactLogStrictParser } from '../exact/BoardTaskExactLogStrictParser'; + +import type { BoardTaskActivityRecord } from './BoardTaskActivityRecord'; +import type { + BoardTaskActivityDetail, + BoardTaskActivityDetailMetadataRow, + BoardTaskActivityDetailResult, +} from '@shared/types'; +import type { BoardTaskExactLogBundleCandidate } from '../exact/BoardTaskExactLogTypes'; + +function scopeLabel(record: BoardTaskActivityRecord): string { + switch (record.actorContext.relation) { + case 'same_task': + return 'same task'; + case 'other_active_task': + return 'other active task'; + case 'idle': + return 'idle'; + case 'ambiguous': + return 'ambiguous'; + default: + return record.actorContext.relation; + } +} + +function formatTaskLabelOrLocator(record: BoardTaskActivityRecord['task']): string { + return formatBoardTaskActivityTaskLabel(record) ?? `#${record.locator.ref}`; +} + +function relationshipValue(record: BoardTaskActivityRecord): string | null { + const relationship = record.action?.details?.relationship; + const peerTaskLabel = formatBoardTaskActivityTaskLabel(record.action?.peerTask); + + if (relationship && peerTaskLabel) { + return `${relationship} ${peerTaskLabel}`; + } + if (relationship) { + return relationship; + } + if (peerTaskLabel) { + return peerTaskLabel; + } + return null; +} + +function buildMetadataRows(record: BoardTaskActivityRecord): BoardTaskActivityDetailMetadataRow[] { + const rows: BoardTaskActivityDetailMetadataRow[] = [ + { + label: 'Task', + value: formatTaskLabelOrLocator(record.task), + }, + { + label: 'Scope', + value: scopeLabel(record), + }, + ]; + + if (record.action?.canonicalToolName) { + rows.push({ label: 'Tool', value: record.action.canonicalToolName }); + } + if (record.action?.details?.status) { + rows.push({ label: 'Status', value: record.action.details.status }); + } + if ('owner' in (record.action?.details ?? {})) { + rows.push({ label: 'Owner', value: record.action?.details?.owner ?? 'cleared' }); + } + if ('clarification' in (record.action?.details ?? {})) { + rows.push({ + label: 'Clarification', + value: record.action?.details?.clarification ?? 'cleared', + }); + } + if (record.action?.details?.reviewer) { + rows.push({ label: 'Reviewer', value: record.action.details.reviewer }); + } + if (record.action?.details?.commentId) { + rows.push({ label: 'Comment', value: record.action.details.commentId }); + } + if (record.action?.details?.attachmentId) { + rows.push({ label: 'Attachment ID', value: record.action.details.attachmentId }); + } + if (record.action?.details?.filename) { + rows.push({ label: 'File', value: record.action.details.filename }); + } + const relationship = relationshipValue(record); + if (relationship) { + rows.push({ label: 'Relationship', value: relationship }); + } + const activeTaskLabel = formatBoardTaskActivityTaskLabel(record.actorContext.activeTask); + if (activeTaskLabel) { + rows.push({ label: 'Active task', value: activeTaskLabel }); + } + if (record.actorContext.activePhase) { + rows.push({ label: 'Phase', value: record.actorContext.activePhase }); + } + + return rows; +} + +function buildCandidate(record: BoardTaskActivityRecord): BoardTaskExactLogBundleCandidate { + return { + id: `activity:${record.id}`, + timestamp: record.timestamp, + actor: record.actor, + source: { + filePath: record.source.filePath, + messageUuid: record.source.messageUuid, + ...(record.source.toolUseId ? { toolUseId: record.source.toolUseId } : {}), + sourceOrder: record.source.sourceOrder, + }, + records: [record], + anchor: record.source.toolUseId + ? { + kind: 'tool', + filePath: record.source.filePath, + messageUuid: record.source.messageUuid, + toolUseId: record.source.toolUseId, + } + : { + kind: 'message', + filePath: record.source.filePath, + messageUuid: record.source.messageUuid, + }, + actionLabel: describeBoardTaskActivityLabel(record), + ...(record.action?.category ? { actionCategory: record.action.category } : {}), + ...(record.action?.canonicalToolName + ? { canonicalToolName: record.action.canonicalToolName } + : {}), + linkKinds: [record.linkKind], + targetRoles: [record.targetRole], + canLoadDetail: false, + }; +} + +export class BoardTaskActivityDetailService { + constructor( + private readonly recordSource: BoardTaskActivityRecordSource = new BoardTaskActivityRecordSource(), + private readonly strictParser: BoardTaskExactLogStrictParser = new BoardTaskExactLogStrictParser(), + private readonly detailSelector: BoardTaskExactLogDetailSelector = new BoardTaskExactLogDetailSelector(), + private readonly chunkBuilder: BoardTaskExactLogChunkBuilder = new BoardTaskExactLogChunkBuilder() + ) {} + + async getTaskActivityDetail( + teamName: string, + taskId: string, + activityId: string + ): Promise { + const records = await this.recordSource.getTaskRecords(teamName, taskId); + const record = records.find((candidate) => candidate.id === activityId); + if (!record) { + return { status: 'missing' }; + } + + const detail: BoardTaskActivityDetail = { + entryId: record.id, + summaryLabel: describeBoardTaskActivityLabel(record), + actorLabel: describeBoardTaskActivityActorLabel(record.actor), + timestamp: record.timestamp, + contextLines: describeBoardTaskActivityContextLines(record), + metadataRows: buildMetadataRows(record), + }; + + if (record.source.toolUseId) { + const parsedMessagesByFile = await this.strictParser.parseFiles([record.source.filePath]); + const detailCandidate = this.detailSelector.selectDetail({ + candidate: buildCandidate(record), + records, + parsedMessagesByFile, + }); + + if (detailCandidate) { + const chunks = this.chunkBuilder.buildBundleChunks(detailCandidate.filteredMessages); + if (chunks.length > 0) { + detail.logDetail = { + id: detailCandidate.id, + chunks, + }; + } + } + } + + return { + status: 'ok', + detail, + }; + } +} diff --git a/src/main/types/domain.ts b/src/main/types/domain.ts index b9600e24..85a67e06 100644 --- a/src/main/types/domain.ts +++ b/src/main/types/domain.ts @@ -104,6 +104,8 @@ export interface Session { messageCount: number; /** Whether the session is ongoing (last AI response has no output yet) */ isOngoing?: boolean; + /** Latest main-thread model seen in the session metadata scan */ + model?: string; /** Git branch name if available */ gitBranch?: string; /** Metadata completeness level */ diff --git a/src/main/utils/jsonl.ts b/src/main/utils/jsonl.ts index a1d42fba..3a345d5d 100644 --- a/src/main/utils/jsonl.ts +++ b/src/main/utils/jsonl.ts @@ -480,6 +480,7 @@ export interface SessionFileMetadata { messageCount: number; isOngoing: boolean; gitBranch: string | null; + model?: string | null; /** Total context consumed (compaction-aware) */ contextConsumption?: number; /** Number of compaction events */ @@ -502,6 +503,7 @@ export async function analyzeSessionFileMetadata( messageCount: 0, isOngoing: false, gitBranch: null, + model: null, }; } @@ -514,6 +516,7 @@ export async function analyzeSessionFileMetadata( messageCount: 0, isOngoing: false, gitBranch: null, + model: null, }; } if (stat.size > MAX_DEEP_SCAN_BYTES) { @@ -526,6 +529,7 @@ export async function analyzeSessionFileMetadata( messageCount: 0, isOngoing: false, gitBranch: null, + model: null, }; } catch { return { @@ -533,6 +537,7 @@ export async function analyzeSessionFileMetadata( messageCount: 0, isOngoing: false, gitBranch: null, + model: null, }; } } @@ -552,6 +557,7 @@ export async function analyzeSessionFileMetadata( // After a UserGroup, await the first main-thread assistant message to count the AIGroup let awaitingAIGroup = false; let gitBranch: string | null = null; + let model: string | null = null; let activityIndex = 0; let lastEndingIndex = -1; @@ -607,6 +613,10 @@ export async function analyzeSessionFileMetadata( gitBranch = entry.gitBranch; } + if (parsed.type === 'assistant' && !parsed.isSidechain && parsed.model !== '') { + model = parsed.model ?? model; + } + if (!firstUserMessage && entry.type === 'user') { const content = entry.message?.content; if (typeof content === 'string') { @@ -803,6 +813,7 @@ export async function analyzeSessionFileMetadata( messageCount, isOngoing: lastEndingIndex === -1 ? hasAnyOngoingActivity : hasActivityAfterLastEnding, gitBranch, + model, contextConsumption, compactionCount: compactionPhases.length > 0 ? compactionPhases.length : undefined, phaseBreakdown, diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 2d1b451d..6c25b52d 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -304,6 +304,9 @@ export const TEAM_GET_LOGS_FOR_TASK = 'team:getLogsForTask'; /** Get explicit board-task activity derived from transcript metadata */ export const TEAM_GET_TASK_ACTIVITY = 'team:getTaskActivity'; +/** Get focused inline detail for one task-activity entry */ +export const TEAM_GET_TASK_ACTIVITY_DETAIL = 'team:getTaskActivityDetail'; + /** Get one task-scoped log stream derived from explicit board-task activity */ export const TEAM_GET_TASK_LOG_STREAM = 'team:getTaskLogStream'; diff --git a/src/preload/index.ts b/src/preload/index.ts index cd0dfb78..79c12a8d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -130,6 +130,7 @@ import { TEAM_GET_PROJECT_BRANCH, TEAM_GET_SAVED_REQUEST, TEAM_GET_TASK_ACTIVITY, + TEAM_GET_TASK_ACTIVITY_DETAIL, TEAM_GET_TASK_ATTACHMENT, TEAM_GET_TASK_CHANGE_PRESENCE, TEAM_GET_TASK_EXACT_LOG_DETAIL, @@ -232,6 +233,7 @@ import type { ApplyReviewRequest, ApplyReviewResult, AttachmentFileData, + BoardTaskActivityDetailResult, BoardTaskActivityEntry, BoardTaskExactLogDetailResult, BoardTaskExactLogSummariesResponse, @@ -969,6 +971,14 @@ const electronAPI: ElectronAPI = { taskId ); }, + getTaskActivityDetail: async (teamName: string, taskId: string, activityId: string) => { + return invokeIpcWithResult( + TEAM_GET_TASK_ACTIVITY_DETAIL, + teamName, + taskId, + activityId + ); + }, getTaskLogStream: async (teamName: string, taskId: string) => { return invokeIpcWithResult( TEAM_GET_TASK_LOG_STREAM, diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 43d4b243..b205a4f6 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -9,6 +9,7 @@ import type { AppConfig, AttachmentFileData, + BoardTaskActivityDetailResult, BoardTaskExactLogDetailResult, BoardTaskExactLogSummariesResponse, BoardTaskLogStreamResponse, @@ -811,6 +812,10 @@ export class HttpAPIClient implements ElectronAPI { console.warn('[HttpAPIClient] getTaskActivity is not available in browser mode'); return []; }, + getTaskActivityDetail: async (): Promise => { + console.warn('[HttpAPIClient] getTaskActivityDetail is not available in browser mode'); + return { status: 'missing' }; + }, getTaskLogStream: async (): Promise => { console.warn('[HttpAPIClient] getTaskLogStream is not available in browser mode'); return { diff --git a/src/renderer/components/sidebar/DateGroupedSessions.tsx b/src/renderer/components/sidebar/DateGroupedSessions.tsx index 0cf12c66..51a86b62 100644 --- a/src/renderer/components/sidebar/DateGroupedSessions.tsx +++ b/src/renderer/components/sidebar/DateGroupedSessions.tsx @@ -739,15 +739,15 @@ export const DateGroupedSessions = (): React.JSX.Element => {

{sessionSortMode === 'most-context' ? 'By Context' : 'Sessions'}

{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- tooltip trigger via hover, not interactive */} setShowCountTooltip(true)} onMouseLeave={() => setShowCountTooltip(false)} @@ -898,11 +898,11 @@ export const DateGroupedSessions = (): React.JSX.Element => { > {item.type === 'pinned-header' ? (
@@ -911,11 +911,11 @@ export const DateGroupedSessions = (): React.JSX.Element => {
) : item.type === 'header' ? (
diff --git a/src/renderer/components/sidebar/SessionItem.tsx b/src/renderer/components/sidebar/SessionItem.tsx index b05565b2..ab6b72a9 100644 --- a/src/renderer/components/sidebar/SessionItem.tsx +++ b/src/renderer/components/sidebar/SessionItem.tsx @@ -7,8 +7,11 @@ import { useCallback, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; +import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; import { useStore } from '@renderer/store'; import { formatSessionLabel, parseSessionTitle } from '@renderer/utils/sessionTitleParser'; +import { getProviderScopedTeamModelLabel } from '@renderer/utils/teamModelCatalog'; +import { inferTeamProviderIdFromModel } from '@shared/utils/teamProvider'; import { formatTokensCompact } from '@shared/utils/tokenFormatting'; import { formatDistanceToNowStrict } from 'date-fns'; import { EyeOff, MessageSquare, Pin, Play, RotateCw, Users } from 'lucide-react'; @@ -131,6 +134,28 @@ const ConsumptionBadge = ({ ); }; +const SessionRuntimeBadge = ({ + model, +}: Readonly<{ + model: string | undefined; +}>): React.JSX.Element | null => { + const providerId = inferTeamProviderIdFromModel(model); + if (!providerId) { + return null; + } + + const modelLabel = getProviderScopedTeamModelLabel(providerId, model) ?? model?.trim(); + return ( + + + {modelLabel && {modelLabel}} + + ); +}; + export const SessionItem = ({ session, isActive, @@ -321,6 +346,12 @@ export const SessionItem = ({ · {formatShortTime(new Date(session.createdAt))} + {session.model && ( + <> + · + + + )} {session.contextConsumption != null && session.contextConsumption > 0 && ( <> · diff --git a/src/renderer/components/team/dialogs/TeamModelSelector.tsx b/src/renderer/components/team/dialogs/TeamModelSelector.tsx index 1f2e7db0..c1aa208a 100644 --- a/src/renderer/components/team/dialogs/TeamModelSelector.tsx +++ b/src/renderer/components/team/dialogs/TeamModelSelector.tsx @@ -18,6 +18,7 @@ import { } from '@renderer/utils/geminiUiFreeze'; import { doesTeamModelCarryProviderBrand, + getProviderScopedTeamModelLabel, getTeamModelLabel as getCatalogTeamModelLabel, getTeamModelUiDisabledReason, getTeamProviderLabel as getCatalogTeamProviderLabel, @@ -27,6 +28,8 @@ import { } from '@renderer/utils/teamModelCatalog'; import { Info } from 'lucide-react'; +export { getProviderScopedTeamModelLabel } from '@renderer/utils/teamModelCatalog'; + // --- Provider definitions --- interface ProviderDef { @@ -48,17 +51,6 @@ export function getTeamModelLabel(model: string): string { return getCatalogTeamModelLabel(model) ?? model; } -export function getProviderScopedTeamModelLabel( - providerId: 'anthropic' | 'codex' | 'gemini', - model: string -): string { - const baseLabel = getTeamModelLabel(model); - if (providerId !== 'codex') { - return baseLabel; - } - return baseLabel.replace(/^GPT-/i, ''); -} - export function getTeamProviderLabel(providerId: 'anthropic' | 'codex' | 'gemini'): string { return getCatalogTeamProviderLabel(providerId) ?? 'Anthropic'; } diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index b951cc04..7f79114c 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -445,6 +445,7 @@ export const MessageComposer = ({ const remaining = MAX_TEXT_LENGTH - trimmed.length; const hasAttachmentPreviewContent = draft.attachments.length > 0 || Boolean(draft.attachmentError ?? fileRestrictionError); + const shouldDockRecipientSelector = !hasAttachmentPreviewContent; const isCompactLayout = layout === 'compact'; const compactFooterNotice = slashCommandRestrictionReason ? ( @@ -473,7 +474,12 @@ export const MessageComposer = ({ onDrop={handleDropWrapper} onPaste={handlePasteWrapper} > -
+
{isLeadRecipient ? ( <> @@ -522,7 +528,10 @@ export const MessageComposer = ({ {/* Combined team + member selector */}
@@ -531,7 +540,10 @@ export const MessageComposer = ({
+ + + {expanded ? ( +
+ +
+ ) : null}
); }; @@ -142,16 +254,79 @@ export const TaskActivitySection = ({ teamName, taskId, }: TaskActivitySectionProps): React.JSX.Element => { + const [detailStates, setDetailStates] = useState>({}); const [entries, setEntries] = useState([]); + const [expandedId, setExpandedId] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const fetchDetail = useCallback( + async (entry: BoardTaskActivityEntry): Promise => { + setDetailStates((prev) => ({ + ...prev, + [entry.id]: { status: 'loading' }, + })); + + try { + const result = await api.teams.getTaskActivityDetail(teamName, taskId, entry.id); + setDetailStates((prev) => ({ + ...prev, + [entry.id]: + result.status === 'ok' + ? { status: 'ok', detail: normalizeDetail(result.detail) } + : { status: 'missing' }, + })); + } catch (detailError) { + setDetailStates((prev) => ({ + ...prev, + [entry.id]: { + status: 'error', + error: + detailError instanceof Error ? detailError.message : 'Failed to load activity detail', + }, + })); + } + }, + [taskId, teamName] + ); + + const handleToggle = useCallback( + async (entry: BoardTaskActivityEntry): Promise => { + if (expandedId === entry.id) { + setExpandedId(null); + return; + } + + setExpandedId(entry.id); + const existing = detailStates[entry.id]; + if ( + existing && + existing.status !== 'idle' && + existing.status !== 'error' && + existing.status !== 'loading' + ) { + return; + } + if (existing?.status === 'loading') { + return; + } + await fetchDetail(entry); + }, + [detailStates, expandedId, fetchDetail] + ); + useEffect(() => { let cancelled = false; - const load = async (): Promise => { + setEntries([]); + setExpandedId(null); + setDetailStates({}); + setLoading(true); + setError(null); + + const load = async (showSpinner: boolean): Promise => { try { - if (!cancelled && entries.length === 0) { + if (!cancelled && showSpinner) { setLoading(true); } if (!cancelled) { @@ -173,16 +348,16 @@ export const TaskActivitySection = ({ } }; - void load(); + void load(true); const intervalId = window.setInterval(() => { - void load(); + void load(false); }, 8000); return () => { cancelled = true; window.clearInterval(intervalId); }; - }, [entries.length, teamName, taskId]); + }, [teamName, taskId]); const visibleEntries = useMemo( () => @@ -225,11 +400,25 @@ export const TaskActivitySection = ({ return (
{visibleEntries.map((entry) => ( - + void handleToggle(entry)} + /> ))}
); - }, [error, hasOnlyLowSignalExecution, loading, visibleEntries]); + }, [ + detailStates, + error, + expandedId, + handleToggle, + hasOnlyLowSignalExecution, + loading, + visibleEntries, + ]); return (
diff --git a/src/renderer/utils/teamModelCatalog.ts b/src/renderer/utils/teamModelCatalog.ts index 41407858..ee7c0614 100644 --- a/src/renderer/utils/teamModelCatalog.ts +++ b/src/renderer/utils/teamModelCatalog.ts @@ -155,6 +155,23 @@ export function getTeamModelBadgeLabel( return trimmed; } +export function getProviderScopedTeamModelLabel( + providerId: SupportedProviderId, + model: string | undefined +): string | undefined { + const trimmed = model?.trim(); + if (!trimmed) { + return undefined; + } + + const baseLabel = getTeamModelLabel(trimmed) ?? trimmed; + if (providerId !== 'codex') { + return baseLabel; + } + + return baseLabel.replace(/^GPT-/i, ''); +} + export function sortTeamProviderModels( providerId: SupportedProviderId, models: readonly string[] diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 0510a7b6..938e3bfc 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -39,6 +39,7 @@ import type { import type { AddMemberRequest, AddTaskCommentRequest, + BoardTaskActivityDetailResult, AttachmentFileData, BoardTaskActivityEntry, BoardTaskExactLogDetailResult, @@ -482,6 +483,11 @@ export interface TeamsAPI { } ) => Promise; getTaskActivity: (teamName: string, taskId: string) => Promise; + getTaskActivityDetail: ( + teamName: string, + taskId: string, + activityId: string + ) => Promise; getTaskLogStream: (teamName: string, taskId: string) => Promise; getTaskExactLogSummaries: ( teamName: string, diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 65b50340..bf1b95fe 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -236,6 +236,30 @@ export interface BoardTaskActivityEntry { }; } +export interface BoardTaskActivityDetailMetadataRow { + label: string; + value: string; +} + +export interface BoardTaskActivityDetail { + entryId: string; + summaryLabel: string; + actorLabel: string; + timestamp: string; + contextLines: string[]; + metadataRows: BoardTaskActivityDetailMetadataRow[]; + logDetail?: BoardTaskExactLogDetail; +} + +export type BoardTaskActivityDetailResult = + | { + status: 'ok'; + detail: BoardTaskActivityDetail; + } + | { + status: 'missing'; + }; + export interface BoardTaskExactLogActor { memberName?: string; role: 'member' | 'lead' | 'unknown'; diff --git a/src/shared/utils/boardTaskActivityPresentation.ts b/src/shared/utils/boardTaskActivityPresentation.ts new file mode 100644 index 00000000..010183a1 --- /dev/null +++ b/src/shared/utils/boardTaskActivityPresentation.ts @@ -0,0 +1,75 @@ +import { formatBoardTaskActivityTaskLabel } from './boardTaskActivityLabels'; + +import type { + BoardTaskActivityAction, + BoardTaskActivityActor, + BoardTaskActivityActorContext, + BoardTaskActivityLinkKind, + BoardTaskActivityTaskRef, +} from '../types/team'; + +interface BoardTaskActivityPresentationInput { + action?: BoardTaskActivityAction; + actor: BoardTaskActivityActor; + actorContext: BoardTaskActivityActorContext; + task: BoardTaskActivityTaskRef; + linkKind: BoardTaskActivityLinkKind; +} + +export function describeBoardTaskActivityActorLabel(actor: BoardTaskActivityActor): string { + if (actor.memberName) { + return actor.memberName; + } + if (actor.role === 'lead' || actor.isSidechain === false) { + return 'lead session'; + } + return 'unknown actor'; +} + +function relationshipContextLabel(action: BoardTaskActivityAction | undefined): string | null { + const peerTaskLabel = formatBoardTaskActivityTaskLabel(action?.peerTask); + if (!peerTaskLabel) return null; + + switch (action?.relationshipPerspective) { + case 'incoming': + return `from ${peerTaskLabel}`; + case 'outgoing': + return `to ${peerTaskLabel}`; + default: + return `with ${peerTaskLabel}`; + } +} + +export function describeBoardTaskActivityContextLines( + input: BoardTaskActivityPresentationInput +): string[] { + const parts: string[] = []; + + const relationshipContext = relationshipContextLabel(input.action); + if (relationshipContext) { + parts.push(relationshipContext); + } + + if (input.actorContext.relation === 'other_active_task') { + const activeTaskLabel = formatBoardTaskActivityTaskLabel(input.actorContext.activeTask); + if (activeTaskLabel) { + parts.push(`while working on ${activeTaskLabel}`); + } else { + parts.push('while another task was active'); + } + } else if (input.actorContext.relation === 'ambiguous') { + parts.push('while multiple task scopes were active'); + } else if (input.actorContext.relation === 'idle' && input.linkKind !== 'execution') { + parts.push('without an active task scope'); + } + + if (input.task.resolution === 'deleted') { + parts.push('task is deleted'); + } else if (input.task.resolution === 'ambiguous') { + parts.push('task resolution is ambiguous'); + } else if (input.task.resolution === 'unresolved') { + parts.push('task could not be resolved'); + } + + return parts; +} diff --git a/src/shared/utils/teamProvider.ts b/src/shared/utils/teamProvider.ts index eb77bf67..d1acdfff 100644 --- a/src/shared/utils/teamProvider.ts +++ b/src/shared/utils/teamProvider.ts @@ -14,3 +14,31 @@ export function normalizeTeamProviderId( ): TeamProviderId { return normalizeOptionalTeamProviderId(value) ?? fallback; } + +export function inferTeamProviderIdFromModel( + model: string | undefined +): TeamProviderId | undefined { + const normalized = model?.trim().toLowerCase(); + if (!normalized) { + return undefined; + } + + if (normalized.startsWith('gpt-') || normalized.startsWith('codex')) { + return 'codex'; + } + + if (normalized.startsWith('gemini')) { + return 'gemini'; + } + + if ( + normalized.startsWith('claude') || + normalized === 'opus' || + normalized === 'sonnet' || + normalized === 'haiku' + ) { + return 'anthropic'; + } + + return undefined; +} diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts index 13353fe1..24412b24 100644 --- a/test/main/ipc/teams.test.ts +++ b/test/main/ipc/teams.test.ts @@ -1,6 +1,7 @@ import * as os from 'os'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { + BoardTaskActivityDetailResult, BoardTaskActivityEntry, BoardTaskLogStreamResponse, BoardTaskExactLogDetailResult, @@ -73,6 +74,7 @@ import { TEAM_GET_ALL_TASKS, TEAM_GET_LOGS_FOR_TASK, TEAM_GET_TASK_ACTIVITY, + TEAM_GET_TASK_ACTIVITY_DETAIL, TEAM_GET_TASK_LOG_STREAM, TEAM_GET_TASK_EXACT_LOG_DETAIL, TEAM_GET_TASK_EXACT_LOG_SUMMARIES, @@ -201,6 +203,10 @@ describe('ipc teams handlers', () => { const boardTaskActivityService = { getTaskActivity: vi.fn<() => Promise>(async () => []), }; + const boardTaskActivityDetailService = { + getTaskActivityDetail: + vi.fn<() => Promise>(async () => ({ status: 'missing' })), + }; const boardTaskLogStreamService = { getTaskLogStream: vi.fn<() => Promise>(async () => ({ @@ -235,6 +241,7 @@ describe('ipc teams handlers', () => { undefined, undefined, boardTaskActivityService as never, + boardTaskActivityDetailService as never, boardTaskLogStreamService as never, boardTaskExactLogsService as never, boardTaskExactLogDetailService as never, @@ -1154,6 +1161,36 @@ describe('ipc teams handlers', () => { expect(boardTaskActivityService.getTaskActivity).toHaveBeenCalledWith('my-team', 'task-1'); }); + it('returns focused task activity detail for one row', async () => { + const handler = handlers.get(TEAM_GET_TASK_ACTIVITY_DETAIL); + expect(handler).toBeDefined(); + + boardTaskActivityDetailService.getTaskActivityDetail.mockResolvedValueOnce({ + status: 'ok', + detail: { + entryId: 'activity-1', + summaryLabel: 'Added a comment', + actorLabel: 'bob', + timestamp: '2026-04-13T10:35:00.000Z', + contextLines: ['while working on #peer12345'], + metadataRows: [{ label: 'Comment', value: '42' }], + }, + }); + + const result = (await handler!({} as never, 'my-team', 'task-1', 'activity-1')) as { + success: boolean; + data?: BoardTaskActivityDetailResult; + }; + + expect(result.success).toBe(true); + expect(result.data?.status).toBe('ok'); + expect(boardTaskActivityDetailService.getTaskActivityDetail).toHaveBeenCalledWith( + 'my-team', + 'task-1', + 'activity-1' + ); + }); + describe('addTaskRelationship', () => { it('calls service on valid input', async () => { const handler = handlers.get(TEAM_ADD_TASK_RELATIONSHIP)!; diff --git a/test/main/services/team/BoardTaskActivityDetailService.test.ts b/test/main/services/team/BoardTaskActivityDetailService.test.ts new file mode 100644 index 00000000..20e8a015 --- /dev/null +++ b/test/main/services/team/BoardTaskActivityDetailService.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { BoardTaskActivityDetailService } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityDetailService'; + +import type { BoardTaskActivityRecord } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityRecord'; +import type { BoardTaskExactLogDetailCandidate } from '../../../../src/main/services/team/taskLogs/exact/BoardTaskExactLogTypes'; + +function makeRecord(overrides: Partial = {}): BoardTaskActivityRecord { + return { + id: 'record-1', + timestamp: '2026-04-13T10:35:00.000Z', + task: { + locator: { ref: 'abc12345', refKind: 'display', canonicalId: 'task-a' }, + resolution: 'resolved', + taskRef: { + taskId: 'task-a', + displayId: 'abc12345', + teamName: 'demo', + }, + }, + linkKind: 'board_action', + targetRole: 'subject', + actor: { + memberName: 'bob', + role: 'member', + sessionId: 'session-1', + agentId: 'agent-1', + isSidechain: true, + }, + actorContext: { + relation: 'other_active_task', + activePhase: 'work', + activeTask: { + locator: { ref: 'peer12345', refKind: 'display', canonicalId: 'task-b' }, + resolution: 'resolved', + taskRef: { + taskId: 'task-b', + displayId: 'peer12345', + teamName: 'demo', + }, + }, + }, + action: { + canonicalToolName: 'task_add_comment', + toolUseId: 'tool-1', + category: 'comment', + details: { + commentId: '42', + }, + }, + source: { + filePath: '/tmp/task.jsonl', + messageUuid: 'msg-1', + toolUseId: 'tool-1', + sourceOrder: 1, + }, + ...overrides, + }; +} + +describe('BoardTaskActivityDetailService', () => { + it('returns structured metadata and focused log detail for tool-backed activity', async () => { + const record = makeRecord(); + const detailCandidate: BoardTaskExactLogDetailCandidate = { + id: 'activity:record-1', + timestamp: record.timestamp, + actor: record.actor, + source: record.source, + records: [record], + filteredMessages: [], + }; + + const service = new BoardTaskActivityDetailService( + { getTaskRecords: vi.fn(async () => [record]) } as never, + { parseFiles: vi.fn(async () => new Map([['/tmp/task.jsonl', []]])) } as never, + { selectDetail: vi.fn(() => detailCandidate) } as never, + { buildBundleChunks: vi.fn(() => [{ id: 'chunk-1' }]) } as never + ); + + const result = await service.getTaskActivityDetail('demo', 'task-a', 'record-1'); + + expect(result.status).toBe('ok'); + if (result.status !== 'ok') { + throw new Error('expected ok detail'); + } + expect(result.detail.summaryLabel).toBe('Added a comment'); + expect(result.detail.actorLabel).toBe('bob'); + expect(result.detail.contextLines).toContain('while working on #peer12345'); + expect(result.detail.metadataRows).toEqual( + expect.arrayContaining([ + { label: 'Task', value: '#abc12345' }, + { label: 'Tool', value: 'task_add_comment' }, + { label: 'Comment', value: '42' }, + ]) + ); + expect(result.detail.logDetail?.chunks).toEqual([{ id: 'chunk-1' }]); + }); + + it('returns metadata only for non-tool-backed activity without parsing transcript content', async () => { + const record = makeRecord({ + id: 'record-2', + source: { + filePath: '/tmp/task.jsonl', + messageUuid: 'msg-2', + sourceOrder: 2, + }, + action: { + canonicalToolName: 'task_set_owner', + category: 'assignment', + details: { + owner: 'alice', + }, + }, + }); + const strictParser = { parseFiles: vi.fn(async () => new Map()) }; + const service = new BoardTaskActivityDetailService( + { getTaskRecords: vi.fn(async () => [record]) } as never, + strictParser as never, + { selectDetail: vi.fn() } as never, + { buildBundleChunks: vi.fn() } as never + ); + + const result = await service.getTaskActivityDetail('demo', 'task-a', 'record-2'); + + expect(result.status).toBe('ok'); + if (result.status !== 'ok') { + throw new Error('expected ok detail'); + } + expect(result.detail.metadataRows).toEqual( + expect.arrayContaining([{ label: 'Owner', value: 'alice' }]) + ); + expect(result.detail.logDetail).toBeUndefined(); + expect(strictParser.parseFiles).not.toHaveBeenCalled(); + }); + + it('returns missing when the activity id does not exist', async () => { + const service = new BoardTaskActivityDetailService( + { getTaskRecords: vi.fn(async () => [makeRecord()]) } as never, + { parseFiles: vi.fn() } as never, + { selectDetail: vi.fn() } as never, + { buildBundleChunks: vi.fn() } as never + ); + + await expect(service.getTaskActivityDetail('demo', 'task-a', 'missing-id')).resolves.toEqual({ + status: 'missing', + }); + }); +}); diff --git a/test/renderer/components/team/taskLogs/TaskActivitySection.test.ts b/test/renderer/components/team/taskLogs/TaskActivitySection.test.ts index 7daebb66..a202b190 100644 --- a/test/renderer/components/team/taskLogs/TaskActivitySection.test.ts +++ b/test/renderer/components/team/taskLogs/TaskActivitySection.test.ts @@ -2,10 +2,17 @@ import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { BoardTaskActivityEntry } from '../../../../../src/shared/types'; +import type { + BoardTaskActivityDetailResult, + BoardTaskActivityEntry, +} from '../../../../../src/shared/types'; const apiState = { getTaskActivity: vi.fn<(teamName: string, taskId: string) => Promise>(), + getTaskActivityDetail: + vi.fn< + (teamName: string, taskId: string, activityId: string) => Promise + >(), }; vi.mock('@renderer/api', () => ({ @@ -13,10 +20,59 @@ vi.mock('@renderer/api', () => ({ teams: { getTaskActivity: (...args: Parameters) => apiState.getTaskActivity(...args), + getTaskActivityDetail: (...args: Parameters) => + apiState.getTaskActivityDetail(...args), }, }, })); +vi.mock('@renderer/components/team/members/MemberExecutionLog', () => ({ + MemberExecutionLog: ({ + memberName, + chunks, + }: { + memberName?: string; + chunks: { id: string }[]; + }) => + React.createElement( + 'div', + { 'data-testid': 'member-execution-log' }, + `${memberName ?? 'lead'}:${chunks.length}` + ), +})); + +vi.mock('@renderer/types/data', () => ({ + asEnhancedChunkArray: (value: unknown) => value, +})); + +vi.mock('@shared/utils/boardTaskActivityPresentation', () => ({ + describeBoardTaskActivityActorLabel: (actor: { memberName?: string }) => + actor.memberName ?? 'lead session', + describeBoardTaskActivityContextLines: (entry: { + actorContext?: { relation?: string; activeTask?: { taskRef?: { displayId?: string } } }; + }) => + entry.actorContext?.relation === 'other_active_task' + ? [`while working on #${entry.actorContext.activeTask?.taskRef?.displayId ?? 'unknown'}`] + : [], +})); + +vi.mock('@shared/utils/boardTaskActivityLabels', () => ({ + describeBoardTaskActivityLabel: (entry: { action?: { canonicalToolName?: string } }) => { + switch (entry.action?.canonicalToolName) { + case 'task_get': + return 'Viewed task'; + case 'task_start': + return 'Started work'; + case 'task_add_comment': + return 'Added a comment'; + default: + return 'Worked on task'; + } + }, + formatBoardTaskActivityTaskLabel: (task?: { taskRef?: { displayId?: string } }) => + task?.taskRef?.displayId ? `#${task.taskRef.displayId}` : null, +})); + import { TaskActivitySection } from '@renderer/components/team/taskLogs/TaskActivitySection'; function flushMicrotasks(): Promise { @@ -69,6 +125,7 @@ describe('TaskActivitySection', () => { afterEach(() => { document.body.innerHTML = ''; apiState.getTaskActivity.mockReset(); + apiState.getTaskActivityDetail.mockReset(); vi.unstubAllGlobals(); }); @@ -152,4 +209,101 @@ describe('TaskActivitySection', () => { await flushMicrotasks(); }); }); + + it('loads inline detail lazily and renders metadata plus a focused log snippet', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + apiState.getTaskActivity.mockResolvedValue([ + makeEntry({ + id: 'comment-1', + timestamp: '2026-04-13T10:35:00.000Z', + linkKind: 'board_action', + actorContext: { + relation: 'other_active_task', + activePhase: 'work', + activeTask: { + locator: { + ref: 'peer12345', + refKind: 'display', + }, + resolution: 'resolved', + taskRef: { + taskId: 'task-2', + displayId: 'peer12345', + teamName: 'demo', + }, + }, + }, + action: { + canonicalToolName: 'task_add_comment', + category: 'comment', + toolUseId: 'tool-1', + details: { + commentId: '42', + }, + }, + source: { + messageUuid: 'comment-1-message', + filePath: '/tmp/transcript.jsonl', + toolUseId: 'tool-1', + sourceOrder: 5, + }, + }), + ]); + apiState.getTaskActivityDetail.mockResolvedValue({ + status: 'ok', + detail: { + entryId: 'comment-1', + summaryLabel: 'Added a comment', + actorLabel: 'bob', + timestamp: '2026-04-13T10:35:00.000Z', + contextLines: ['while working on #peer12345'], + metadataRows: [ + { label: 'Task', value: '#abc12345' }, + { label: 'Tool', value: 'task_add_comment' }, + { label: 'Comment', value: '42' }, + ], + logDetail: { + id: 'activity:comment-1', + chunks: [{ id: 'chunk-1' }] as never, + }, + }, + }); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(TaskActivitySection, { teamName: 'demo', taskId: 'task-a' })); + await flushMicrotasks(); + }); + + const button = host.querySelector('button'); + expect(button).not.toBeNull(); + + await act(async () => { + button?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await flushMicrotasks(); + }); + + expect(apiState.getTaskActivityDetail).toHaveBeenCalledWith('demo', 'task-a', 'comment-1'); + expect(host.textContent).toContain('Tool'); + expect(host.textContent).toContain('task_add_comment'); + expect(host.textContent).toContain('Comment'); + expect(host.textContent).toContain('42'); + expect(host.textContent).toContain('while working on #peer12345'); + expect(host.querySelector('[data-testid="member-execution-log"]')?.textContent).toBe('bob:1'); + + await act(async () => { + button?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await flushMicrotasks(); + }); + + expect(host.querySelector('[data-testid="member-execution-log"]')).toBeNull(); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); });