feat(activity-detail): implement task activity detail retrieval and UI integration
This commit is contained in:
parent
5b1f369950
commit
804e92419f
24 changed files with 1084 additions and 85 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)]'
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
75
src/shared/utils/boardTaskActivityPresentation.ts
Normal file
75
src/shared/utils/boardTaskActivityPresentation.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)!;
|
||||
|
|
|
|||
148
test/main/services/team/BoardTaskActivityDetailService.test.ts
Normal file
148
test/main/services/team/BoardTaskActivityDetailService.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue