feat(activity-detail): implement task activity detail retrieval and UI integration

This commit is contained in:
777genius 2026-04-13 19:19:52 +03:00
parent 5b1f369950
commit 804e92419f
24 changed files with 1084 additions and 85 deletions

View file

@ -103,6 +103,7 @@ import {
import { syncTelemetryFlag } from './sentry';
import {
BoardTaskActivityRecordSource,
BoardTaskActivityDetailService,
BoardTaskActivityService,
BoardTaskExactLogDetailService,
BoardTaskExactLogsService,
@ -786,6 +787,9 @@ async function initializeServices(): Promise<void> {
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<void> {
teamMemberLogsFinder,
memberStatsComputer,
boardTaskActivityService,
boardTaskActivityDetailService,
boardTaskLogStreamService,
boardTaskExactLogsService,
boardTaskExactLogDetailService,

View file

@ -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

View file

@ -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<IpcResult<BoardTaskActivityDetailResult>> {
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,

View file

@ -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,
};
}

View file

@ -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';

View file

@ -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<BoardTaskActivityDetailResult> {
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,
};
}
}

View file

@ -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 */

View file

@ -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 !== '<synthetic>') {
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,

View file

@ -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';

View file

@ -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<BoardTaskActivityDetailResult>(
TEAM_GET_TASK_ACTIVITY_DETAIL,
teamName,
taskId,
activityId
);
},
getTaskLogStream: async (teamName: string, taskId: string) => {
return invokeIpcWithResult<BoardTaskLogStreamResponse>(
TEAM_GET_TASK_LOG_STREAM,

View file

@ -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<BoardTaskActivityDetailResult> => {
console.warn('[HttpAPIClient] getTaskActivityDetail is not available in browser mode');
return { status: 'missing' };
},
getTaskLogStream: async (): Promise<BoardTaskLogStreamResponse> => {
console.warn('[HttpAPIClient] getTaskLogStream is not available in browser mode');
return {

View file

@ -739,15 +739,15 @@ export const DateGroupedSessions = (): React.JSX.Element => {
<div className="flex items-center gap-2 px-2 py-1.5">
<Calendar className="size-3.5" style={{ color: 'var(--color-text-muted)' }} />
<h2
className="text-[11px] uppercase tracking-wider"
style={{ color: 'var(--color-text-muted)' }}
className="text-[12px] font-semibold text-text-secondary"
style={{ color: 'var(--color-text-secondary)' }}
>
{sessionSortMode === 'most-context' ? 'By Context' : 'Sessions'}
</h2>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- tooltip trigger via hover, not interactive */}
<span
ref={countRef}
className="text-[11px]"
className="text-[10px]"
style={{ color: 'var(--color-text-muted)', opacity: 0.6 }}
onMouseEnter={() => setShowCountTooltip(true)}
onMouseLeave={() => setShowCountTooltip(false)}
@ -898,11 +898,11 @@ export const DateGroupedSessions = (): React.JSX.Element => {
>
{item.type === 'pinned-header' ? (
<div
className="sticky top-0 flex h-full items-center gap-1.5 border-t px-2 py-1.5 text-[11px] font-semibold uppercase tracking-wider backdrop-blur-sm"
className="sticky top-0 flex h-full items-center gap-1.5 border-t px-2 py-1.5 text-[11px] font-semibold text-text-secondary backdrop-blur-sm"
style={{
backgroundColor:
'color-mix(in srgb, var(--color-surface-sidebar) 95%, transparent)',
color: 'var(--color-text-muted)',
color: 'var(--color-text-secondary)',
borderColor: 'var(--color-border-emphasis)',
}}
>
@ -911,11 +911,11 @@ export const DateGroupedSessions = (): React.JSX.Element => {
</div>
) : item.type === 'header' ? (
<div
className="sticky top-0 flex h-full items-center border-t px-2 py-1.5 text-[11px] font-semibold uppercase tracking-wider backdrop-blur-sm"
className="sticky top-0 flex h-full items-center border-t px-2 py-1.5 text-[11px] font-semibold text-text-secondary backdrop-blur-sm"
style={{
backgroundColor:
'color-mix(in srgb, var(--color-surface-sidebar) 95%, transparent)',
color: 'var(--color-text-muted)',
color: 'var(--color-text-secondary)',
borderColor: 'var(--color-border-emphasis)',
}}
>

View file

@ -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 (
<span
className="flex min-w-0 shrink items-center gap-1"
title={modelLabel ? `${providerId} · ${modelLabel}` : providerId}
>
<ProviderBrandLogo providerId={providerId} className="size-3 shrink-0" />
{modelLabel && <span className="truncate">{modelLabel}</span>}
</span>
);
};
export const SessionItem = ({
session,
isActive,
@ -321,6 +346,12 @@ export const SessionItem = ({
</span>
<span style={{ opacity: 0.5 }}>·</span>
<span className="tabular-nums">{formatShortTime(new Date(session.createdAt))}</span>
{session.model && (
<>
<span style={{ opacity: 0.5 }}>·</span>
<SessionRuntimeBadge model={session.model} />
</>
)}
{session.contextConsumption != null && session.contextConsumption > 0 && (
<>
<span style={{ opacity: 0.5 }}>·</span>

View file

@ -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';
}

View file

@ -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 ? (
<span className="inline-flex items-center gap-1 rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] text-amber-300">
@ -473,7 +474,12 @@ export const MessageComposer = ({
onDrop={handleDropWrapper}
onPaste={handlePasteWrapper}
>
<div className={cn('mb-1', isCompactLayout ? 'space-y-1.5' : 'space-y-2')}>
<div
className={cn(
shouldDockRecipientSelector ? 'mb-0' : 'mb-1',
isCompactLayout ? 'space-y-1.5' : 'space-y-2'
)}
>
<div className="flex items-center gap-2">
{isLeadRecipient ? (
<>
@ -522,7 +528,10 @@ export const MessageComposer = ({
{/* Combined team + member selector */}
<div
className={cn(
'inline-flex items-center rounded-full border text-xs transition-colors',
'mr-[15px] inline-flex items-center border text-xs transition-colors',
shouldDockRecipientSelector
? 'relative z-10 -mb-px overflow-hidden rounded-b-none rounded-t-[1.35rem] border-b-0 bg-[var(--color-surface-raised)]'
: 'rounded-full',
isCrossTeam ? 'border-[var(--cross-team-border)]' : 'border-[var(--color-border)]'
)}
>
@ -531,7 +540,10 @@ export const MessageComposer = ({
<button
type="button"
className={cn(
'inline-flex items-center gap-1.5 rounded-l-full border-r border-r-[var(--color-border)] px-2.5 py-1 text-xs transition-colors',
'inline-flex items-center gap-1.5 border-r border-r-[var(--color-border)] px-2.5 py-1 text-xs transition-colors',
shouldDockRecipientSelector
? 'rounded-bl-none rounded-tl-[1.35rem]'
: 'rounded-l-full',
isCrossTeam
? 'hover:bg-[var(--cross-team-bg)]/80 bg-[var(--cross-team-bg)] text-purple-400'
: 'hover:bg-[var(--color-surface-raised)]'
@ -675,7 +687,10 @@ export const MessageComposer = ({
<button
type="button"
className={cn(
'inline-flex items-center gap-1.5 rounded-r-full px-2.5 py-1 text-xs transition-colors',
'inline-flex items-center gap-1.5 px-2.5 py-1 text-xs transition-colors',
shouldDockRecipientSelector
? 'rounded-br-none rounded-tr-[1.35rem]'
: 'rounded-r-full',
isCrossTeam
? 'cursor-default bg-[var(--cross-team-bg)] opacity-60'
: 'hover:bg-[var(--color-surface-raised)]'

View file

@ -1,13 +1,23 @@
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '@renderer/api';
import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog';
import { asEnhancedChunkArray } from '@renderer/types/data';
import {
describeBoardTaskActivityLabel,
formatBoardTaskActivityTaskLabel,
} from '@shared/utils/boardTaskActivityLabels';
import { AlertCircle, Loader2 } from 'lucide-react';
import {
describeBoardTaskActivityActorLabel,
describeBoardTaskActivityContextLines,
} from '@shared/utils/boardTaskActivityPresentation';
import { AlertCircle, ChevronDown, ChevronRight, Loader2 } from 'lucide-react';
import type { BoardTaskActivityEntry, BoardTaskActivityTaskRef } from '@shared/types';
import type {
BoardTaskActivityDetail,
BoardTaskActivityEntry,
BoardTaskActivityTaskRef,
} from '@shared/types';
interface TaskActivitySectionProps {
teamName: string;
@ -54,78 +64,174 @@ function formatTaskLabel(task: BoardTaskActivityTaskRef | undefined): string | n
return formatBoardTaskActivityTaskLabel(task);
}
function relationshipContextLabel(entry: BoardTaskActivityEntry): string | null {
const peerTaskLabel = formatTaskLabel(entry.action?.peerTask);
if (!peerTaskLabel) return null;
switch (entry.action?.relationshipPerspective) {
case 'incoming':
return `from ${peerTaskLabel}`;
case 'outgoing':
return `to ${peerTaskLabel}`;
default:
return `with ${peerTaskLabel}`;
}
function describeCollapsedContext(entry: BoardTaskActivityEntry): string | null {
const contextLines = describeBoardTaskActivityContextLines(entry);
return contextLines.length > 0 ? contextLines.join(' - ') : null;
}
function describeContext(entry: BoardTaskActivityEntry): string | null {
const parts: string[] = [];
type ActivityDetailState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'missing' }
| { status: 'error'; error: string }
| { status: 'ok'; detail: BoardTaskActivityDetail };
const relationshipContext = relationshipContextLabel(entry);
if (relationshipContext) {
parts.push(relationshipContext);
function normalizeDetail(detail: BoardTaskActivityDetail): BoardTaskActivityDetail {
if (!detail.logDetail) {
return detail;
}
if (entry.actorContext.relation === 'other_active_task') {
const activeTaskLabel = formatTaskLabel(entry.actorContext.activeTask);
if (activeTaskLabel) {
parts.push(`while working on ${activeTaskLabel}`);
} else {
parts.push('while another task was active');
}
} else if (entry.actorContext.relation === 'ambiguous') {
parts.push('while multiple task scopes were active');
} else if (entry.actorContext.relation === 'idle' && entry.linkKind !== 'execution') {
parts.push('without an active task scope');
}
if (entry.task.resolution === 'deleted') {
parts.push('task is deleted');
} else if (entry.task.resolution === 'ambiguous') {
parts.push('task resolution is ambiguous');
} else if (entry.task.resolution === 'unresolved') {
parts.push('task could not be resolved');
}
return parts.length > 0 ? parts.join(' - ') : null;
return {
...detail,
logDetail: {
...detail.logDetail,
chunks: asEnhancedChunkArray(detail.logDetail.chunks),
},
};
}
function actorLabel(entry: BoardTaskActivityEntry): string {
if (entry.actor.memberName) {
return entry.actor.memberName;
function ActivityMetadata({
detail,
}: {
detail: BoardTaskActivityDetail;
}): React.JSX.Element | null {
const hasMetadata = detail.metadataRows.length > 0;
const hasContext = detail.contextLines.length > 0;
if (!hasMetadata && !hasContext) {
return null;
}
if (entry.actor.role === 'lead' || entry.actor.isSidechain === false) {
return 'lead session';
}
return 'unknown actor';
return (
<div className="space-y-3">
{hasContext ? (
<div className="space-y-1">
{detail.contextLines.map((line) => (
<p key={line} className="text-xs text-[var(--color-text-muted)]">
{line}
</p>
))}
</div>
) : null}
{hasMetadata ? (
<div className="grid gap-2 sm:grid-cols-2">
{detail.metadataRows.map((row) => (
<div
key={`${row.label}:${row.value}`}
className="border-[var(--color-border-muted)]/50 bg-[var(--color-bg-elevated)]/30 rounded-md border px-2.5 py-2"
>
<div className="text-[10px] font-semibold uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
{row.label}
</div>
<div className="mt-1 text-xs text-[var(--color-text)]">{row.value}</div>
</div>
))}
</div>
) : null}
</div>
);
}
const Row = ({ entry }: { entry: BoardTaskActivityEntry }): React.JSX.Element => {
const context = describeContext(entry);
function ActivityDetailPanel({
detailState,
}: {
detailState: ActivityDetailState;
}): React.JSX.Element {
if (detailState.status === 'loading') {
return (
<div className="border-[var(--color-border-muted)]/50 bg-[var(--color-bg-elevated)]/25 flex items-center gap-2 rounded-md border px-3 py-3 text-xs text-[var(--color-text-muted)]">
<Loader2 size={12} className="animate-spin" />
Loading activity details...
</div>
);
}
if (detailState.status === 'error') {
return (
<div className="flex items-center gap-2 rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-red-300">
<AlertCircle size={12} />
{detailState.error}
</div>
);
}
if (detailState.status === 'missing') {
return (
<div className="border-[var(--color-border-muted)]/50 bg-[var(--color-bg-elevated)]/25 rounded-md border px-3 py-3 text-xs text-[var(--color-text-muted)]">
Detailed transcript context is no longer available for this activity.
</div>
);
}
if (detailState.status !== 'ok') {
return <></>;
}
const { detail } = detailState;
return (
<div className="border-[var(--color-border-muted)]/50 bg-[var(--color-bg-elevated)]/25 space-y-3 rounded-md border px-3 py-3">
<div className="border-[var(--color-border-muted)]/50 bg-[var(--color-bg-elevated)]/35 rounded-md border px-3 py-2">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 text-sm text-[var(--color-text)]">
<span className="font-medium">{detail.actorLabel}</span>
<span className="text-[var(--color-text-muted)]"> - </span>
<span>{detail.summaryLabel}</span>
</div>
<div className="shrink-0 text-[10px] font-medium uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
{formatEntryTime(detail.timestamp)}
</div>
</div>
</div>
<ActivityMetadata detail={detail} />
{detail.logDetail ? (
<div className="border-[var(--chat-ai-border)]/50 border-l-2 pl-3">
<MemberExecutionLog
chunks={detail.logDetail.chunks}
memberName={detail.actorLabel === 'lead session' ? undefined : detail.actorLabel}
/>
</div>
) : null}
</div>
);
}
const Row = ({
detailState,
entry,
expanded,
onToggle,
}: {
detailState: ActivityDetailState;
entry: BoardTaskActivityEntry;
expanded: boolean;
onToggle: () => void;
}): React.JSX.Element => {
const context = describeCollapsedContext(entry);
const tone =
entry.task.resolution === 'resolved'
? 'text-[var(--color-text)]'
: 'text-[var(--color-text-muted)]';
return (
<div className="border-[var(--color-border-muted)]/60 bg-[var(--color-bg-elevated)]/40 rounded-md border px-3 py-2">
<div className="flex items-start gap-3">
<div className="border-[var(--color-border-muted)]/60 bg-[var(--color-bg-elevated)]/40 rounded-md border">
<button
type="button"
className="hover:bg-[var(--color-bg-elevated)]/35 flex w-full items-start gap-3 px-3 py-2 text-left transition-colors"
onClick={onToggle}
>
<div className="pt-0.5 text-[var(--color-text-muted)]">
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</div>
<div className="min-w-12 pt-0.5 text-[10px] font-medium uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
{formatEntryTime(entry.timestamp)}
</div>
<div className="min-w-0 flex-1">
<div className={`text-sm ${tone}`}>
<span className="font-medium">{actorLabel(entry)}</span>
<span className="font-medium">{describeBoardTaskActivityActorLabel(entry.actor)}</span>
<span className="text-[var(--color-text-muted)]"> - </span>
<span>{describeBoardTaskActivityLabel(entry)}</span>
</div>
@ -133,7 +239,13 @@ const Row = ({ entry }: { entry: BoardTaskActivityEntry }): React.JSX.Element =>
<p className="mt-1 text-xs text-[var(--color-text-muted)]">{context}</p>
) : null}
</div>
</div>
</button>
{expanded ? (
<div className="px-3 pb-3">
<ActivityDetailPanel detailState={detailState} />
</div>
) : null}
</div>
);
};
@ -142,16 +254,79 @@ export const TaskActivitySection = ({
teamName,
taskId,
}: TaskActivitySectionProps): React.JSX.Element => {
const [detailStates, setDetailStates] = useState<Record<string, ActivityDetailState>>({});
const [entries, setEntries] = useState<BoardTaskActivityEntry[]>([]);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchDetail = useCallback(
async (entry: BoardTaskActivityEntry): Promise<void> => {
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<void> => {
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<void> => {
setEntries([]);
setExpandedId(null);
setDetailStates({});
setLoading(true);
setError(null);
const load = async (showSpinner: boolean): Promise<void> => {
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 (
<div className="space-y-2">
{visibleEntries.map((entry) => (
<Row key={entry.id} entry={entry} />
<Row
key={entry.id}
detailState={detailStates[entry.id] ?? { status: 'idle' }}
entry={entry}
expanded={expandedId === entry.id}
onToggle={() => void handleToggle(entry)}
/>
))}
</div>
);
}, [error, hasOnlyLowSignalExecution, loading, visibleEntries]);
}, [
detailStates,
error,
expandedId,
handleToggle,
hasOnlyLowSignalExecution,
loading,
visibleEntries,
]);
return (
<div className="space-y-2">

View file

@ -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[]

View file

@ -39,6 +39,7 @@ import type {
import type {
AddMemberRequest,
AddTaskCommentRequest,
BoardTaskActivityDetailResult,
AttachmentFileData,
BoardTaskActivityEntry,
BoardTaskExactLogDetailResult,
@ -482,6 +483,11 @@ export interface TeamsAPI {
}
) => Promise<MemberLogSummary[]>;
getTaskActivity: (teamName: string, taskId: string) => Promise<BoardTaskActivityEntry[]>;
getTaskActivityDetail: (
teamName: string,
taskId: string,
activityId: string
) => Promise<BoardTaskActivityDetailResult>;
getTaskLogStream: (teamName: string, taskId: string) => Promise<BoardTaskLogStreamResponse>;
getTaskExactLogSummaries: (
teamName: string,

View file

@ -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';

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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<BoardTaskActivityEntry[]>>(async () => []),
};
const boardTaskActivityDetailService = {
getTaskActivityDetail:
vi.fn<() => Promise<BoardTaskActivityDetailResult>>(async () => ({ status: 'missing' })),
};
const boardTaskLogStreamService = {
getTaskLogStream:
vi.fn<() => Promise<BoardTaskLogStreamResponse>>(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)!;

View file

@ -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> = {}): 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',
});
});
});

View file

@ -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<BoardTaskActivityEntry[]>>(),
getTaskActivityDetail:
vi.fn<
(teamName: string, taskId: string, activityId: string) => Promise<BoardTaskActivityDetailResult>
>(),
};
vi.mock('@renderer/api', () => ({
@ -13,10 +20,59 @@ vi.mock('@renderer/api', () => ({
teams: {
getTaskActivity: (...args: Parameters<typeof apiState.getTaskActivity>) =>
apiState.getTaskActivity(...args),
getTaskActivityDetail: (...args: Parameters<typeof apiState.getTaskActivityDetail>) =>
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<void> {
@ -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();
});
});
});