feat(session-search): add search functionality and provider filtering to DateGroupedSessions component; enhance task activity detail rendering logic
This commit is contained in:
parent
804e92419f
commit
ce0eb75429
8 changed files with 1103 additions and 40 deletions
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)}
|
||||
|
|
|
|||
114
src/renderer/components/sidebar/SessionFiltersPopover.tsx
Normal file
114
src/renderer/components/sidebar/SessionFiltersPopover.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue