feat(session-search): add search functionality and provider filtering to DateGroupedSessions component; enhance task activity detail rendering logic

This commit is contained in:
777genius 2026-04-13 20:00:18 +03:00
parent 804e92419f
commit ce0eb75429
8 changed files with 1103 additions and 40 deletions

View file

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

View file

@ -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<string, unknown>;
if (toolName === 'task_add_comment' || toolName === 'task_get_comment') {
const comment = payload.comment as Record<string, unknown> | 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<ContentBlock, { type: 'text' }> =>
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<T extends ContentBlock>(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<ContentBlock, { type: 'tool_result' }> =>
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<string, unknown> & {
content?: unknown;
message?: unknown;
} = { ...(rawToolUseResult as Record<string, unknown>) };
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,

View file

@ -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<HTMLDivElement>(null);
const countRef = useRef<HTMLSpanElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const [showCountTooltip, setShowCountTooltip] = useState(false);
const [isWorktreeDropdownOpen, setIsWorktreeDropdownOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [selectedProviderIds, setSelectedProviderIds] = useState<Set<TeamProviderId>>(
() => new Set<TeamProviderId>(SESSION_PROVIDER_IDS)
);
const worktreeDropdownRef = useRef<HTMLDivElement>(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<Record<TeamProviderId, number>>(() => {
const counts: Record<TeamProviderId, number> = {
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 => {
)}
</div>
)}
<div
className="mb-[5px] flex shrink-0 items-center gap-1.5 border-b px-2 py-1"
style={{ borderColor: 'var(--color-border)' }}
>
<Search className="size-3 shrink-0 text-text-muted" />
<input
ref={searchInputRef}
type="text"
placeholder="Search sessions..."
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
className="min-w-0 flex-1 bg-transparent text-[12px] text-text placeholder:text-text-muted focus:outline-none"
/>
{searchQuery && (
<button
type="button"
className="shrink-0 text-text-muted hover:text-text-secondary"
onClick={() => {
setSearchQuery('');
searchInputRef.current?.focus();
}}
aria-label="Clear session search"
>
<X className="size-3" />
</button>
)}
<SessionFiltersPopover
selectedProviderIds={selectedProviderIds}
providerCounts={providerCounts}
onProviderIdsChange={setSelectedProviderIds}
/>
</div>
</div>
);
@ -733,6 +835,25 @@ export const DateGroupedSessions = (): React.JSX.Element => {
);
}
if (filteredSessions.length === 0 && !sessionsHasMore) {
return (
<div className="flex h-full flex-col">
{projectSelector}
<div className="flex flex-1 items-center justify-center p-4">
<div className="text-center text-sm" style={{ color: 'var(--color-text-muted)' }}>
<Search className="mx-auto mb-2 size-8 opacity-50" />
<p className="mb-2">No matching sessions</p>
<p className="text-xs opacity-70">
{hasActiveSearch || hasActiveProviderFilter
? 'Try another query or reset the provider filter.'
: 'This project has no matching sessions yet.'}
</p>
</div>
</div>
</div>
);
}
return (
<div className="flex h-full flex-col overflow-hidden">
{projectSelector}
@ -752,7 +873,7 @@ export const DateGroupedSessions = (): React.JSX.Element => {
onMouseEnter={() => setShowCountTooltip(true)}
onMouseLeave={() => setShowCountTooltip(false)}
>
({sessions.length}
({filteredSessions.length}
{sessionsHasMore ? '+' : ''})
</span>
{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.'
: ''}
</div>,
document.body
)}

View file

@ -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<TeamProviderId>;
providerCounts: Record<TeamProviderId, number>;
onProviderIdsChange: (next: Set<TeamProviderId>) => 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<TeamProviderId>(SESSION_PROVIDER_IDS));
};
return (
<Popover>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="relative h-7 px-2 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
aria-label="Filter sessions"
>
<Filter size={14} />
{activeCount > 0 && (
<span className="absolute -right-1 -top-1 flex size-4 items-center justify-center rounded-full bg-blue-500 text-[10px] font-medium text-white">
{activeCount}
</span>
)}
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">Filter sessions</TooltipContent>
</Tooltip>
<PopoverContent align="end" className="w-72 p-0">
<div className="border-b border-[var(--color-border)] p-3">
<div className="mb-2 flex items-center justify-between">
<p className="text-[11px] font-medium uppercase tracking-wider text-[var(--color-text-muted)]">
Provider
</p>
<button
type="button"
className="text-[10px] text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-text)]"
disabled={activeCount === 0}
onClick={handleReset}
>
Reset
</button>
</div>
<div className="space-y-1">
{SESSION_PROVIDER_IDS.map((providerId) => (
<label
key={providerId}
className="flex cursor-pointer items-center gap-2 rounded-md p-1 text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]"
>
<Checkbox
checked={selectedProviderIds.has(providerId)}
onCheckedChange={() => toggleProvider(providerId)}
/>
<ProviderBrandLogo providerId={providerId} className="size-3.5 shrink-0" />
<span className="flex-1 truncate">
{getTeamProviderLabel(providerId) ?? providerId}
</span>
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
{providerCounts[providerId]}
</span>
</label>
))}
</div>
</div>
</PopoverContent>
</Popover>
);
};

View file

@ -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 ? (
<div className="grid gap-2 sm:grid-cols-2">
<div className="grid gap-x-4 gap-y-2 sm:grid-cols-[max-content_1fr] sm:items-start">
{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"
>
<Fragment key={`${row.label}:${row.value}`}>
<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 className="text-sm text-[var(--color-text)]">{row.value}</div>
</Fragment>
))}
</div>
) : null}
@ -169,26 +182,14 @@ function ActivityDetailPanel({
}
const { detail } = detailState;
const hasRenderableLog = hasRenderableLinkedTool(detail);
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>
<div className="border-[var(--color-border-muted)]/50 space-y-3 border-t pt-3">
<ActivityMetadata detail={detail} />
{detail.logDetail ? (
<div className="border-[var(--chat-ai-border)]/50 border-l-2 pl-3">
{detail.logDetail && hasRenderableLog ? (
<div className="pt-1">
<MemberExecutionLog
chunks={detail.logDetail.chunks}
memberName={detail.actorLabel === 'lead session' ? undefined : detail.actorLabel}

View file

@ -4,7 +4,8 @@ module.exports = {
content: [
'./src/renderer/index.html',
'./src/renderer/**/*.{js,ts,jsx,tsx}',
'./src/shared/**/*.{js,ts,jsx,tsx}'
'./src/shared/**/*.{js,ts,jsx,tsx}',
'./packages/agent-graph/src/**/*.{js,ts,jsx,tsx}'
],
theme: {
extend: {

View file

@ -67,14 +67,47 @@ describe('BoardTaskActivityDetailService', () => {
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,

View file

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