diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 2590dca8..67727de3 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -131,7 +131,8 @@ export default defineConfig({ }, renderer: { optimizeDeps: { - include: ['@codemirror/language-data'] + include: ['@codemirror/language-data'], + exclude: ['@claude-teams/agent-graph'] }, define: { __APP_VERSION__: JSON.stringify(pkg.version), @@ -142,7 +143,8 @@ export default defineConfig({ alias: { '@renderer': resolve(__dirname, 'src/renderer'), '@shared': resolve(__dirname, 'src/shared'), - '@main': resolve(__dirname, 'src/main') + '@main': resolve(__dirname, 'src/main'), + '@claude-teams/agent-graph': resolve(__dirname, 'packages/agent-graph/src/index.ts') } }, plugins: [react(), ...sentryPlugins], diff --git a/src/main/services/team/taskLogs/activity/BoardTaskActivityDetailService.ts b/src/main/services/team/taskLogs/activity/BoardTaskActivityDetailService.ts index 61846b5b..d055b4f1 100644 --- a/src/main/services/team/taskLogs/activity/BoardTaskActivityDetailService.ts +++ b/src/main/services/team/taskLogs/activity/BoardTaskActivityDetailService.ts @@ -6,6 +6,7 @@ import { describeBoardTaskActivityActorLabel, describeBoardTaskActivityContextLines, } from '@shared/utils/boardTaskActivityPresentation'; +import { isEnhancedAIChunk } from '@main/types'; import { BoardTaskActivityRecordSource } from './BoardTaskActivityRecordSource'; import { BoardTaskExactLogChunkBuilder } from '../exact/BoardTaskExactLogChunkBuilder'; @@ -19,6 +20,9 @@ import type { BoardTaskActivityDetailResult, } from '@shared/types'; import type { BoardTaskExactLogBundleCandidate } from '../exact/BoardTaskExactLogTypes'; +import type { ContentBlock, EnhancedChunk, ParsedMessage, ToolUseResultData } from '@main/types'; + +const READ_ONLY_TOOL_NAMES = new Set(['task_get', 'task_get_comment']); function scopeLabel(record: BoardTaskActivityRecord): string { switch (record.actorContext.relation) { @@ -144,6 +148,436 @@ function buildCandidate(record: BoardTaskActivityRecord): BoardTaskExactLogBundl }; } +function shouldIncludeLinkedTool(record: BoardTaskActivityRecord): boolean { + const toolName = record.action?.canonicalToolName; + if (!record.source.toolUseId || !toolName) { + return false; + } + + return !READ_ONLY_TOOL_NAMES.has(toolName); +} + +function looksLikeJsonPayload(value: string): boolean { + const trimmed = value.trim(); + return trimmed.startsWith('{') || trimmed.startsWith('['); +} + +function parseJsonLikeString(value: string): unknown { + if (!looksLikeJsonPayload(value)) { + return null; + } + + try { + return JSON.parse(value); + } catch { + return null; + } +} + +function extractBoardToolOutputText( + toolName: string | undefined, + parsedPayload: unknown +): string | null { + if (!toolName || !parsedPayload || typeof parsedPayload !== 'object') { + return null; + } + + const payload = parsedPayload as Record; + if (toolName === 'task_add_comment' || toolName === 'task_get_comment') { + const comment = payload.comment as Record | undefined; + if (typeof comment?.text === 'string' && comment.text.trim().length > 0) { + return comment.text; + } + } + + return null; +} + +function collectTextBlockText(value: unknown): string { + if (!Array.isArray(value)) { + return ''; + } + + return value + .filter( + (child): child is Extract => + typeof child === 'object' && + child !== null && + 'type' in child && + child.type === 'text' && + 'text' in child && + typeof child.text === 'string' + ) + .map((child) => child.text) + .join('\n'); +} + +function cloneBlock(block: T): T { + if (block.type === 'tool_use') { + return { + ...block, + input: { ...(block.input ?? {}) }, + } as T; + } + + if (block.type === 'tool_result') { + return { + ...block, + content: Array.isArray(block.content) + ? block.content.map((child) => cloneBlock(child)) + : block.content, + } as T; + } + + if (block.type === 'image') { + return { + ...block, + source: { ...block.source }, + } as T; + } + + return { ...block } as T; +} + +function sanitizeToolResultContent( + content: ContentBlock, + canonicalToolName?: string +): ContentBlock { + if (content.type !== 'tool_result') { + return cloneBlock(content); + } + + if (typeof content.content === 'string') { + const parsedPayload = parseJsonLikeString(content.content); + const extractedText = extractBoardToolOutputText(canonicalToolName, parsedPayload); + if (typeof extractedText === 'string') { + return { + ...content, + content: [{ type: 'text', text: extractedText }], + }; + } + return parsedPayload ? { ...content, content: '' } : cloneBlock(content); + } + + if (!Array.isArray(content.content)) { + return cloneBlock(content); + } + + const jsonText = collectTextBlockText(content.content); + const parsedPayload = parseJsonLikeString(jsonText); + const extractedText = extractBoardToolOutputText(canonicalToolName, parsedPayload); + if (typeof extractedText === 'string') { + return { + ...content, + content: extractedText, + }; + } + + const sanitizedChildren = content.content + .map((child) => { + if (child.type !== 'text') { + return cloneBlock(child); + } + + return looksLikeJsonPayload(child.text) ? null : cloneBlock(child); + }) + .filter((child): child is ContentBlock => child !== null); + + if (sanitizedChildren.length === 0) { + return { + ...content, + content: '', + }; + } + + return { + ...content, + content: sanitizedChildren, + }; +} + +function inferSingleToolUseId(message: ParsedMessage): string | undefined { + if (message.sourceToolUseID) { + return message.sourceToolUseID; + } + + if (message.toolResults.length === 1) { + return message.toolResults[0]?.toolUseId; + } + + if (!Array.isArray(message.content)) { + return undefined; + } + + const uniqueIds = new Set( + message.content + .filter( + (block): block is Extract => + block.type === 'tool_result' + ) + .map((block) => block.tool_use_id) + ); + + return uniqueIds.size === 1 ? uniqueIds.values().next().value : undefined; +} + +function hasMeaningfulToolUseResult(message: ParsedMessage): boolean { + const rawToolUseResult = message.toolUseResult as unknown; + if ( + !rawToolUseResult || + typeof rawToolUseResult !== 'object' || + Array.isArray(rawToolUseResult) + ) { + return false; + } + + const toolUseResult = rawToolUseResult as { + error?: unknown; + stderr?: unknown; + content?: unknown; + message?: unknown; + }; + if (typeof toolUseResult.error === 'string' && toolUseResult.error.trim().length > 0) { + return true; + } + if (typeof toolUseResult.stderr === 'string' && toolUseResult.stderr.trim().length > 0) { + return true; + } + if (typeof toolUseResult.content === 'string' && toolUseResult.content.trim().length > 0) { + return true; + } + if (Array.isArray(toolUseResult.content) && toolUseResult.content.length > 0) { + return true; + } + if (typeof toolUseResult.message === 'string' && toolUseResult.message.trim().length > 0) { + return true; + } + if (Array.isArray(toolUseResult.message) && toolUseResult.message.length > 0) { + return true; + } + return false; +} + +function isEmptyToolPayload(value: unknown): boolean { + if (value == null) { + return true; + } + if (typeof value === 'string') { + return value.trim().length === 0; + } + if (Array.isArray(value)) { + return value.length === 0; + } + return false; +} + +function sanitizeJsonLikeToolResultPayloads( + messages: ParsedMessage[], + canonicalToolName?: string +): ParsedMessage[] { + return messages.map((message) => { + let nextMessage = message; + const rawToolUseResult = message.toolUseResult as unknown; + + if ( + rawToolUseResult && + typeof rawToolUseResult === 'object' && + !Array.isArray(rawToolUseResult) + ) { + const nextToolUseResult: Record & { + content?: unknown; + message?: unknown; + } = { ...(rawToolUseResult as Record) }; + let toolUseResultChanged = false; + const extractedFromContent = + typeof nextToolUseResult.content === 'string' + ? extractBoardToolOutputText( + canonicalToolName, + parseJsonLikeString(nextToolUseResult.content) + ) + : null; + const extractedFromMessage = + typeof nextToolUseResult.message === 'string' + ? extractBoardToolOutputText( + canonicalToolName, + parseJsonLikeString(nextToolUseResult.message) + ) + : null; + + if (typeof extractedFromContent === 'string') { + nextToolUseResult.content = extractedFromContent; + toolUseResultChanged = true; + } + + if ( + typeof nextToolUseResult.content === 'string' && + looksLikeJsonPayload(nextToolUseResult.content) + ) { + nextToolUseResult.content = ''; + toolUseResultChanged = true; + } + + if (typeof extractedFromMessage === 'string') { + nextToolUseResult.message = extractedFromMessage; + toolUseResultChanged = true; + } + + if ( + typeof nextToolUseResult.message === 'string' && + looksLikeJsonPayload(nextToolUseResult.message) + ) { + nextToolUseResult.message = ''; + toolUseResultChanged = true; + } + + if (toolUseResultChanged) { + nextMessage = { + ...nextMessage, + toolUseResult: nextToolUseResult as ToolUseResultData, + }; + } + } else if (Array.isArray(rawToolUseResult)) { + const toolUseId = inferSingleToolUseId(message); + const jsonText = collectTextBlockText(rawToolUseResult); + const parsedPayload = parseJsonLikeString(jsonText); + const extractedText = extractBoardToolOutputText(canonicalToolName, parsedPayload); + if (typeof extractedText === 'string' || parsedPayload) { + nextMessage = { + ...nextMessage, + toolUseResult: { + ...(toolUseId ? { toolUseId } : {}), + content: typeof extractedText === 'string' ? extractedText : '', + }, + }; + } + } + + if (typeof message.content === 'string') { + return nextMessage; + } + + let changed = false; + const nextContent = message.content.map((block) => { + if (block.type !== 'tool_result') { + return block; + } + + const sanitized = sanitizeToolResultContent(block, canonicalToolName); + if (JSON.stringify(sanitized) !== JSON.stringify(block)) { + changed = true; + } + return sanitized; + }); + + if (!changed) { + return nextMessage; + } + + return { + ...nextMessage, + content: nextContent, + }; + }); +} + +function pruneEmptyInternalToolResultMessages(messages: ParsedMessage[]): ParsedMessage[] { + return messages.filter((message) => { + if ( + message.type !== 'user' || + message.toolResults.length === 0 || + typeof message.content === 'string' + ) { + return true; + } + + const hasNonToolResultContent = message.content.some((block) => block.type !== 'tool_result'); + if (hasNonToolResultContent) { + return true; + } + + const allToolResultsEmpty = message.toolResults.every((toolResult) => + isEmptyToolPayload(toolResult.content) + ); + if (!allToolResultsEmpty) { + return true; + } + + return hasMeaningfulToolUseResult(message); + }); +} + +function hasToolUseBlock( + content: ParsedMessage['content'], + toolUseId: string | undefined +): boolean { + if (!toolUseId || typeof content === 'string') { + return false; + } + + return content.some((block) => block.type === 'tool_use' && block.id === toolUseId); +} + +function pruneToolAnchoredAssistantOutputMessages( + messages: ParsedMessage[], + toolUseId: string | undefined +): ParsedMessage[] { + if (!toolUseId) { + return messages; + } + + return messages.filter((message) => { + if (message.type !== 'assistant') { + return true; + } + if (message.sourceToolUseID !== toolUseId) { + return true; + } + return hasToolUseBlock(message.content, toolUseId); + }); +} + +function sanitizeDetailMessages( + messages: ParsedMessage[], + canonicalToolName: string | undefined, + toolUseId: string | undefined +): ParsedMessage[] { + return pruneEmptyInternalToolResultMessages( + pruneToolAnchoredAssistantOutputMessages( + sanitizeJsonLikeToolResultPayloads(messages, canonicalToolName), + toolUseId + ) + ); +} + +function hasMeaningfulText(value: string): boolean { + const trimmed = value.trim(); + return trimmed.length > 0 && !looksLikeJsonPayload(trimmed); +} + +function hasUsefulLinkedToolMessages(messages: ParsedMessage[]): boolean { + return messages.some((message) => { + if (hasMeaningfulToolUseResult(message)) { + return true; + } + + if (typeof message.content === 'string') { + return hasMeaningfulText(message.content); + } + + return message.content.some((block) => { + if (block.type !== 'text') { + return false; + } + + return hasMeaningfulText(block.text); + }); + }); +} + +function hasUsefulLinkedToolChunks(chunks: EnhancedChunk[]): boolean { + return chunks.some((chunk) => isEnhancedAIChunk(chunk) && chunk.toolExecutions.length > 0); +} + export class BoardTaskActivityDetailService { constructor( private readonly recordSource: BoardTaskActivityRecordSource = new BoardTaskActivityRecordSource(), @@ -172,7 +606,7 @@ export class BoardTaskActivityDetailService { metadataRows: buildMetadataRows(record), }; - if (record.source.toolUseId) { + if (shouldIncludeLinkedTool(record)) { const parsedMessagesByFile = await this.strictParser.parseFiles([record.source.filePath]); const detailCandidate = this.detailSelector.selectDetail({ candidate: buildCandidate(record), @@ -181,8 +615,17 @@ export class BoardTaskActivityDetailService { }); if (detailCandidate) { - const chunks = this.chunkBuilder.buildBundleChunks(detailCandidate.filteredMessages); - if (chunks.length > 0) { + const filteredMessages = sanitizeDetailMessages( + detailCandidate.filteredMessages, + record.action?.canonicalToolName, + record.source.toolUseId + ); + const chunks = this.chunkBuilder.buildBundleChunks(filteredMessages); + if ( + chunks.length > 0 && + hasUsefulLinkedToolMessages(filteredMessages) && + hasUsefulLinkedToolChunks(chunks) + ) { detail.logDetail = { id: detailCandidate.id, chunks, diff --git a/src/renderer/components/sidebar/DateGroupedSessions.tsx b/src/renderer/components/sidebar/DateGroupedSessions.tsx index 51a86b62..22f8d572 100644 --- a/src/renderer/components/sidebar/DateGroupedSessions.tsx +++ b/src/renderer/components/sidebar/DateGroupedSessions.tsx @@ -14,7 +14,9 @@ import { groupSessionsByDate, separatePinnedSessions, } from '@renderer/utils/dateGrouping'; +import { parseSessionTitle } from '@renderer/utils/sessionTitleParser'; import { truncateMiddle } from '@renderer/utils/stringUtils'; +import { inferTeamProviderIdFromModel } from '@shared/utils/teamProvider'; import { useVirtualizer } from '@tanstack/react-virtual'; import { ArrowDownWideNarrow, @@ -28,6 +30,7 @@ import { Loader2, MessageSquareOff, Pin, + Search, X, } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; @@ -35,10 +38,12 @@ import { useShallow } from 'zustand/react/shallow'; import { WorktreeBadge } from '../common/WorktreeBadge'; import { Combobox, type ComboboxOption } from '../ui/combobox'; +import { SESSION_PROVIDER_IDS, SessionFiltersPopover } from './SessionFiltersPopover'; import { SessionItem } from './SessionItem'; import type { Session, Worktree, WorktreeSource } from '@renderer/types/data'; import type { DateCategory } from '@renderer/types/tabs'; +import type { TeamProviderId } from '@shared/types'; // --------------------------------------------------------------------------- // Worktree grouping helpers (moved from SidebarHeader) @@ -154,6 +159,29 @@ const SESSION_HEIGHT = 54; // Must match h-[54px] in SessionItem.tsx const LOADER_HEIGHT = 36; const OVERSCAN = 5; +function matchesSessionSearch(session: Session, query: string): boolean { + if (!query) { + return true; + } + + const parsedTitle = parseSessionTitle(session.firstMessage); + const providerId = inferTeamProviderIdFromModel(session.model); + const haystack = [ + parsedTitle.displayText, + parsedTitle.projectName, + session.firstMessage, + session.projectPath, + session.gitBranch, + session.model, + providerId, + ] + .filter(Boolean) + .join('\n') + .toLowerCase(); + + return haystack.includes(query); +} + export const DateGroupedSessions = (): React.JSX.Element => { const { sessions, @@ -233,8 +261,13 @@ export const DateGroupedSessions = (): React.JSX.Element => { const parentRef = useRef(null); const countRef = useRef(null); + const searchInputRef = useRef(null); const [showCountTooltip, setShowCountTooltip] = useState(false); const [isWorktreeDropdownOpen, setIsWorktreeDropdownOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedProviderIds, setSelectedProviderIds] = useState>( + () => new Set(SESSION_PROVIDER_IDS) + ); const worktreeDropdownRef = useRef(null); // Fetch project data on mount or when viewMode changes. @@ -318,6 +351,9 @@ export const DateGroupedSessions = (): React.JSX.Element => { const hiddenSet = useMemo(() => new Set(hiddenSessionIds), [hiddenSessionIds]); const hasHiddenSessions = hiddenSessionIds.length > 0; + const normalizedSearchQuery = searchQuery.trim().toLowerCase(); + const hasActiveProviderFilter = selectedProviderIds.size !== SESSION_PROVIDER_IDS.length; + const hasActiveSearch = normalizedSearchQuery.length > 0; // Filter out hidden sessions unless showHiddenSessions is on const visibleSessions = useMemo(() => { @@ -325,10 +361,43 @@ export const DateGroupedSessions = (): React.JSX.Element => { return sessions.filter((s) => !hiddenSet.has(s.id)); }, [sessions, hiddenSet, showHiddenSessions]); + const searchedSessions = useMemo( + () => visibleSessions.filter((session) => matchesSessionSearch(session, normalizedSearchQuery)), + [visibleSessions, normalizedSearchQuery] + ); + + const providerCounts = useMemo>(() => { + const counts: Record = { + anthropic: 0, + codex: 0, + gemini: 0, + }; + + for (const session of searchedSessions) { + const providerId = inferTeamProviderIdFromModel(session.model); + if (providerId) { + counts[providerId] += 1; + } + } + + return counts; + }, [searchedSessions]); + + const filteredSessions = useMemo(() => { + if (!hasActiveProviderFilter) { + return searchedSessions; + } + + return searchedSessions.filter((session) => { + const providerId = inferTeamProviderIdFromModel(session.model); + return providerId ? selectedProviderIds.has(providerId) : false; + }); + }, [searchedSessions, hasActiveProviderFilter, selectedProviderIds]); + // Separate pinned sessions from unpinned const { pinned: pinnedSessions, unpinned: unpinnedSessions } = useMemo( - () => separatePinnedSessions(visibleSessions, pinnedSessionIds), - [visibleSessions, pinnedSessionIds] + () => separatePinnedSessions(filteredSessions, pinnedSessionIds), + [filteredSessions, pinnedSessionIds] ); // Group only unpinned sessions by date @@ -343,10 +412,10 @@ export const DateGroupedSessions = (): React.JSX.Element => { // Sessions sorted by context consumption (for most-context sort mode) const contextSortedSessions = useMemo(() => { if (sessionSortMode !== 'most-context') return []; - return [...visibleSessions].sort( + return [...filteredSessions].sort( (a, b) => (b.contextConsumption ?? 0) - (a.contextConsumption ?? 0) ); - }, [visibleSessions, sessionSortMode]); + }, [filteredSessions, sessionSortMode]); // Flatten sessions with date headers into virtual list items const virtualItems = useMemo((): VirtualItem[] => { @@ -647,6 +716,39 @@ export const DateGroupedSessions = (): React.JSX.Element => { )} )} + +
+ + setSearchQuery(event.target.value)} + className="min-w-0 flex-1 bg-transparent text-[12px] text-text placeholder:text-text-muted focus:outline-none" + /> + {searchQuery && ( + + )} + +
); @@ -733,6 +835,25 @@ export const DateGroupedSessions = (): React.JSX.Element => { ); } + if (filteredSessions.length === 0 && !sessionsHasMore) { + return ( +
+ {projectSelector} +
+
+ +

No matching sessions

+

+ {hasActiveSearch || hasActiveProviderFilter + ? 'Try another query or reset the provider filter.' + : 'This project has no matching sessions yet.'} +

+
+
+
+ ); + } + return (
{projectSelector} @@ -752,7 +873,7 @@ export const DateGroupedSessions = (): React.JSX.Element => { onMouseEnter={() => setShowCountTooltip(true)} onMouseLeave={() => setShowCountTooltip(false)} > - ({sessions.length} + ({filteredSessions.length} {sessionsHasMore ? '+' : ''}) {showCountTooltip && @@ -772,8 +893,10 @@ export const DateGroupedSessions = (): React.JSX.Element => { color: 'var(--color-text-secondary)', }} > - {sessions.length} loaded so far — scroll down to load more. Context sorting only ranks - loaded sessions. + {filteredSessions.length} matching sessions loaded so far — scroll down to load more. + {sessionSortMode === 'most-context' + ? ' Context sorting only ranks loaded sessions.' + : ''}
, document.body )} diff --git a/src/renderer/components/sidebar/SessionFiltersPopover.tsx b/src/renderer/components/sidebar/SessionFiltersPopover.tsx new file mode 100644 index 00000000..f0fe0290 --- /dev/null +++ b/src/renderer/components/sidebar/SessionFiltersPopover.tsx @@ -0,0 +1,114 @@ +import { useMemo } from 'react'; + +import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; +import { Button } from '@renderer/components/ui/button'; +import { Checkbox } from '@renderer/components/ui/checkbox'; +import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; +import { getTeamProviderLabel } from '@renderer/utils/teamModelCatalog'; +import { Filter } from 'lucide-react'; + +import type { TeamProviderId } from '@shared/types'; + +export const SESSION_PROVIDER_IDS = [ + 'anthropic', + 'codex', + 'gemini', +] as const satisfies readonly TeamProviderId[]; + +interface SessionFiltersPopoverProps { + selectedProviderIds: Set; + providerCounts: Record; + onProviderIdsChange: (next: Set) => void; +} + +export const SessionFiltersPopover = ({ + selectedProviderIds, + providerCounts, + onProviderIdsChange, +}: SessionFiltersPopoverProps): React.JSX.Element => { + const activeCount = useMemo( + () => (selectedProviderIds.size === SESSION_PROVIDER_IDS.length ? 0 : 1), + [selectedProviderIds] + ); + + const toggleProvider = (providerId: TeamProviderId): void => { + const next = new Set(selectedProviderIds); + if (next.has(providerId)) { + if (next.size === 1) { + return; + } + next.delete(providerId); + } else { + next.add(providerId); + } + onProviderIdsChange(next); + }; + + const handleReset = (): void => { + onProviderIdsChange(new Set(SESSION_PROVIDER_IDS)); + }; + + return ( + + + + + + + + Filter sessions + + +
+
+

+ Provider +

+ +
+
+ {SESSION_PROVIDER_IDS.map((providerId) => ( + + ))} +
+
+
+
+ ); +}; diff --git a/src/renderer/components/team/taskLogs/TaskActivitySection.tsx b/src/renderer/components/team/taskLogs/TaskActivitySection.tsx index ac8e0e67..b299b879 100644 --- a/src/renderer/components/team/taskLogs/TaskActivitySection.tsx +++ b/src/renderer/components/team/taskLogs/TaskActivitySection.tsx @@ -1,8 +1,10 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Fragment, 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 { enhanceAIGroup } from '@renderer/utils/aiGroupEnhancer'; +import { transformChunksToConversation } from '@renderer/utils/groupTransformer'; import { describeBoardTaskActivityLabel, formatBoardTaskActivityTaskLabel, @@ -90,6 +92,20 @@ function normalizeDetail(detail: BoardTaskActivityDetail): BoardTaskActivityDeta }; } +function hasRenderableLinkedTool(detail: BoardTaskActivityDetail): boolean { + if (!detail.logDetail || detail.logDetail.chunks.length === 0) { + return false; + } + + const conversation = transformChunksToConversation(detail.logDetail.chunks, [], false); + return conversation.items.some((item) => { + if (item.type !== 'ai') { + return false; + } + return enhanceAIGroup(item.group).displayItems.length > 0; + }); +} + function ActivityMetadata({ detail, }: { @@ -115,17 +131,14 @@ function ActivityMetadata({ ) : null} {hasMetadata ? ( -
+
{detail.metadataRows.map((row) => ( -
+
{row.label}
-
{row.value}
-
+
{row.value}
+ ))}
) : null} @@ -169,26 +182,14 @@ function ActivityDetailPanel({ } const { detail } = detailState; + const hasRenderableLog = hasRenderableLinkedTool(detail); return ( -
-
-
-
- {detail.actorLabel} - - - {detail.summaryLabel} -
-
- {formatEntryTime(detail.timestamp)} -
-
-
- +
- {detail.logDetail ? ( -
+ {detail.logDetail && hasRenderableLog ? ( +
{ actor: record.actor, source: record.source, records: [record], - filteredMessages: [], + filteredMessages: [ + { + uuid: 'msg-1', + parentUuid: null, + type: 'user', + timestamp: new Date(record.timestamp), + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'tool-1', content: 'Posted comment' }], + isSidechain: true, + isMeta: true, + toolCalls: [], + toolResults: [{ toolUseId: 'tool-1', content: 'Posted comment', isError: false }], + toolUseResult: { content: 'Posted comment' }, + } as never, + ], }; 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 + { + buildBundleChunks: vi.fn(() => [ + { + id: 'chunk-1', + chunkType: 'ai', + toolExecutions: [ + { + toolCall: { + id: 'tool-1', + name: 'task_add_comment', + input: {}, + isTask: false, + }, + startTime: new Date(record.timestamp), + }, + ], + semanticSteps: [{ id: 'step-1', type: 'tool_call' }], + }, + ]), + } as never ); const result = await service.getTaskActivityDetail('demo', 'task-a', 'record-1'); @@ -93,7 +126,14 @@ describe('BoardTaskActivityDetailService', () => { { label: 'Comment', value: '42' }, ]) ); - expect(result.detail.logDetail?.chunks).toEqual([{ id: 'chunk-1' }]); + expect(result.detail.logDetail?.chunks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'chunk-1', + chunkType: 'ai', + }), + ]) + ); }); it('returns metadata only for non-tool-backed activity without parsing transcript content', async () => { @@ -133,6 +173,132 @@ describe('BoardTaskActivityDetailService', () => { expect(strictParser.parseFiles).not.toHaveBeenCalled(); }); + it('keeps read-only task activity metadata-only even when toolUseId exists', async () => { + const record = makeRecord({ + id: 'record-read', + action: { + canonicalToolName: 'task_get', + category: 'read', + }, + source: { + filePath: '/tmp/task.jsonl', + messageUuid: 'msg-read', + toolUseId: 'tool-read', + sourceOrder: 3, + }, + }); + 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-read'); + + expect(result.status).toBe('ok'); + if (result.status !== 'ok') { + throw new Error('expected ok detail'); + } + expect(result.detail.summaryLabel).toBe('Viewed task'); + expect(result.detail.logDetail).toBeUndefined(); + expect(strictParser.parseFiles).not.toHaveBeenCalled(); + }); + + it('drops log detail when focused chunks degrade into empty success snapshots', async () => { + const record = makeRecord({ + id: 'record-start', + action: { + canonicalToolName: 'task_start', + category: 'status', + }, + source: { + filePath: '/tmp/task.jsonl', + messageUuid: 'msg-start', + toolUseId: 'tool-start', + sourceOrder: 4, + }, + }); + + 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(() => ({ + id: 'activity:record-start', + timestamp: record.timestamp, + actor: record.actor, + source: record.source, + records: [record], + filteredMessages: [ + { + uuid: 'msg-start-assistant', + parentUuid: null, + type: 'assistant', + timestamp: new Date(record.timestamp), + role: 'assistant', + content: [{ type: 'tool_use', id: 'tool-start', name: 'task_start', input: {} }], + isSidechain: true, + isMeta: false, + toolCalls: [{ id: 'tool-start', name: 'task_start', input: {}, isTask: false }], + toolResults: [], + sourceToolUseID: 'tool-start', + } as never, + { + uuid: 'msg-start-user', + parentUuid: 'msg-start-assistant', + type: 'user', + timestamp: new Date(record.timestamp), + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-start', + content: + '[{\"type\":\"text\",\"text\":\"{\\n \\\"id\\\": \\\"task-a\\\",\\n \\\"status\\\": \\\"in_progress\\\"\\n}\"}]', + }, + ], + isSidechain: true, + isMeta: true, + toolCalls: [], + toolResults: [ + { + toolUseId: 'tool-start', + content: + '[{\"type\":\"text\",\"text\":\"{\\n \\\"id\\\": \\\"task-a\\\",\\n \\\"status\\\": \\\"in_progress\\\"\\n}\"}]', + isError: false, + }, + ], + toolUseResult: { + content: + '[{\"type\":\"text\",\"text\":\"{\\n \\\"id\\\": \\\"task-a\\\",\\n \\\"status\\\": \\\"in_progress\\\"\\n}\"}]', + }, + } as never, + ], + })), + } as never, + { + buildBundleChunks: vi.fn(() => [ + { + chunkType: 'ai', + toolExecutions: [], + semanticSteps: [], + }, + ]), + } as never + ); + + const result = await service.getTaskActivityDetail('demo', 'task-a', 'record-start'); + + expect(result.status).toBe('ok'); + if (result.status !== 'ok') { + throw new Error('expected ok detail'); + } + expect(result.detail.summaryLabel).toBe('Started work'); + expect(result.detail.logDetail).toBeUndefined(); + }); + it('returns missing when the activity id does not exist', async () => { const service = new BoardTaskActivityDetailService( { getTaskRecords: vi.fn(async () => [makeRecord()]) } as never, diff --git a/test/renderer/components/team/taskLogs/TaskActivitySection.test.ts b/test/renderer/components/team/taskLogs/TaskActivitySection.test.ts index a202b190..38c70fda 100644 --- a/test/renderer/components/team/taskLogs/TaskActivitySection.test.ts +++ b/test/renderer/components/team/taskLogs/TaskActivitySection.test.ts @@ -15,6 +15,10 @@ const apiState = { >(), }; +const renderabilityState = { + hasDisplayItems: true, +}; + vi.mock('@renderer/api', () => ({ api: { teams: { @@ -45,6 +49,18 @@ vi.mock('@renderer/types/data', () => ({ asEnhancedChunkArray: (value: unknown) => value, })); +vi.mock('@renderer/utils/groupTransformer', () => ({ + transformChunksToConversation: () => ({ + items: [{ type: 'ai', group: { id: 'ai-group' } }], + }), +})); + +vi.mock('@renderer/utils/aiGroupEnhancer', () => ({ + enhanceAIGroup: () => ({ + displayItems: renderabilityState.hasDisplayItems ? [{ id: 'tool-1' }] : [], + }), +})); + vi.mock('@shared/utils/boardTaskActivityPresentation', () => ({ describeBoardTaskActivityActorLabel: (actor: { memberName?: string }) => actor.memberName ?? 'lead session', @@ -126,6 +142,7 @@ describe('TaskActivitySection', () => { document.body.innerHTML = ''; apiState.getTaskActivity.mockReset(); apiState.getTaskActivityDetail.mockReset(); + renderabilityState.hasDisplayItems = true; vi.unstubAllGlobals(); }); @@ -293,6 +310,7 @@ describe('TaskActivitySection', () => { 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'); + expect(host.textContent?.match(/Added a comment/g)?.length).toBe(1); await act(async () => { button?.dispatchEvent(new MouseEvent('click', { bubbles: true })); @@ -306,4 +324,199 @@ describe('TaskActivitySection', () => { await flushMicrotasks(); }); }); + + it('shows metadata-only detail for read activity without embedding a linked tool log', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + apiState.getTaskActivity.mockResolvedValue([ + makeEntry({ + id: 'view-1', + timestamp: '2026-04-13T10:36:00.000Z', + linkKind: 'board_action', + action: { + canonicalToolName: 'task_get', + category: 'read', + toolUseId: 'tool-read', + }, + source: { + messageUuid: 'view-1-message', + filePath: '/tmp/transcript.jsonl', + toolUseId: 'tool-read', + sourceOrder: 6, + }, + }), + ]); + apiState.getTaskActivityDetail.mockResolvedValue({ + status: 'ok', + detail: { + entryId: 'view-1', + summaryLabel: 'Viewed task', + actorLabel: 'bob', + timestamp: '2026-04-13T10:36:00.000Z', + contextLines: ['without an active task scope'], + metadataRows: [ + { label: 'Task', value: '#abc12345' }, + { label: 'Tool', value: 'task_get' }, + ], + }, + }); + + 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(host.textContent).toContain('Viewed task'); + expect(host.textContent).toContain('task_get'); + expect(host.querySelector('[data-testid="member-execution-log"]')).toBeNull(); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); + + it('hides embedded linked tool detail when the shared execution-log pipeline finds no display items', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + renderabilityState.hasDisplayItems = false; + apiState.getTaskActivity.mockResolvedValue([ + makeEntry({ + id: 'comment-quiet', + timestamp: '2026-04-13T10:38:00.000Z', + linkKind: 'board_action', + action: { + canonicalToolName: 'task_add_comment', + category: 'comment', + toolUseId: 'tool-quiet', + details: { + commentId: '7', + }, + }, + source: { + messageUuid: 'comment-quiet-message', + filePath: '/tmp/transcript.jsonl', + toolUseId: 'tool-quiet', + sourceOrder: 8, + }, + }), + ]); + apiState.getTaskActivityDetail.mockResolvedValue({ + status: 'ok', + detail: { + entryId: 'comment-quiet', + summaryLabel: 'Added a comment', + actorLabel: 'bob', + timestamp: '2026-04-13T10:38:00.000Z', + contextLines: ['without an active task scope'], + metadataRows: [ + { label: 'Task', value: '#abc12345' }, + { label: 'Tool', value: 'task_add_comment' }, + { label: 'Comment', value: '7' }, + ], + logDetail: { + id: 'activity:comment-quiet', + chunks: [{ id: 'chunk-quiet' }] 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(host.textContent).toContain('task_add_comment'); + expect(host.querySelector('[data-testid="member-execution-log"]')).toBeNull(); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); + + it('keeps lifecycle activity metadata-only when the focused detail has no linked tool execution', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + apiState.getTaskActivity.mockResolvedValue([ + makeEntry({ + id: 'start-1', + timestamp: '2026-04-13T10:37:00.000Z', + linkKind: 'lifecycle', + action: { + canonicalToolName: 'task_start', + category: 'status', + toolUseId: 'tool-start', + }, + source: { + messageUuid: 'start-1-message', + filePath: '/tmp/transcript.jsonl', + toolUseId: 'tool-start', + sourceOrder: 7, + }, + }), + ]); + apiState.getTaskActivityDetail.mockResolvedValue({ + status: 'ok', + detail: { + entryId: 'start-1', + summaryLabel: 'Started work', + actorLabel: 'bob', + timestamp: '2026-04-13T10:37:00.000Z', + contextLines: ['without an active task scope'], + metadataRows: [ + { label: 'Task', value: '#abc12345' }, + { label: 'Tool', value: 'task_start' }, + { label: 'Scope', value: 'idle' }, + ], + }, + }); + + 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(host.textContent).toContain('Started work'); + expect(host.textContent).toContain('task_start'); + expect(host.querySelector('[data-testid="member-execution-log"]')).toBeNull(); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); });