diff --git a/packages/agent-graph/src/ui/GraphControls.tsx b/packages/agent-graph/src/ui/GraphControls.tsx index 3b5914cc..415b13fd 100644 --- a/packages/agent-graph/src/ui/GraphControls.tsx +++ b/packages/agent-graph/src/ui/GraphControls.tsx @@ -4,6 +4,7 @@ */ import { useCallback, useEffect, useRef, useState } from 'react'; +import * as Tooltip from '@radix-ui/react-tooltip'; import { Columns3, Expand, @@ -45,6 +46,9 @@ export interface GraphControlsProps { isAlive?: boolean; } +const TOPBAR_BUTTON_SIZE = 25; +const TOPBAR_ICON_SIZE = 10; + export function GraphControls({ filters, onFiltersChange, @@ -56,9 +60,7 @@ export function GraphControls({ onRequestFullscreen, onOpenTeamPage, onCreateTask, - teamName, teamColor, - isAlive, }: GraphControlsProps): React.JSX.Element { const [isSettingsOpen, setIsSettingsOpen] = useState(false); const settingsRef = useRef(null); @@ -97,40 +99,44 @@ export function GraphControls({ return ( <> -
+
{onOpenTeamPage ? (
- } mini title="Team page" /> - {onCreateTask ? ( - } mini title="Create task" /> - ) : null} + } + toolbar + title="Open team page" + /> +
+ ) : null} + {onCreateTask ? ( +
+ } + toolbar + title="Create task" + />
) : null} -
- {isAlive && ( -
- )} - - {teamName} - -
toggle('paused')} - icon={filters.paused ? : } - mini + icon={filters.paused ? : } + toolbar + title={filters.paused ? 'Resume animation' : 'Pause animation'} />
setIsSettingsOpen((value) => !value)} - icon={} + icon={} active={isSettingsOpen} - mini + toolbar + title="Graph settings" />
@@ -193,23 +201,36 @@ export function GraphControls({
{onRequestPinAsTab && ( - } mini /> + } + toolbar + title="Pin as tab" + /> )} {onRequestFullscreen && ( } - mini + icon={} + toolbar + title="Fullscreen" + /> + )} + {onRequestClose && ( + } + toolbar + title="Close graph" /> )} - {onRequestClose && } mini />}
@@ -239,6 +260,7 @@ function ToolbarButton({ active = false, compact = false, mini = false, + toolbar = false, title, }: { onClick?: () => void; @@ -247,18 +269,40 @@ function ToolbarButton({ active?: boolean; compact?: boolean; mini?: boolean; + toolbar?: boolean; title?: string; }): React.JSX.Element { - return ( + const button = ( ); + + if (!title) { + return button; + } + + return ( + + {button} + + + {title} + + + + + ); } function ToolbarToggle({ diff --git a/src/main/services/team/taskLogs/activity/BoardTaskActivityDetailService.ts b/src/main/services/team/taskLogs/activity/BoardTaskActivityDetailService.ts index d055b4f1..dd5805cc 100644 --- a/src/main/services/team/taskLogs/activity/BoardTaskActivityDetailService.ts +++ b/src/main/services/team/taskLogs/activity/BoardTaskActivityDetailService.ts @@ -549,31 +549,6 @@ function sanitizeDetailMessages( ); } -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); } @@ -621,11 +596,7 @@ export class BoardTaskActivityDetailService { record.source.toolUseId ); const chunks = this.chunkBuilder.buildBundleChunks(filteredMessages); - if ( - chunks.length > 0 && - hasUsefulLinkedToolMessages(filteredMessages) && - hasUsefulLinkedToolChunks(chunks) - ) { + if (chunks.length > 0 && hasUsefulLinkedToolChunks(chunks)) { detail.logDetail = { id: detailCandidate.id, chunks, diff --git a/src/renderer/components/dashboard/TmuxStatusBanner.tsx b/src/renderer/components/dashboard/TmuxStatusBanner.tsx index c36be67b..e3baa475 100644 --- a/src/renderer/components/dashboard/TmuxStatusBanner.tsx +++ b/src/renderer/components/dashboard/TmuxStatusBanner.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { api, isElectronMode } from '@renderer/api'; import { AlertTriangle, ExternalLink, RefreshCw, Wrench } from 'lucide-react'; -import type { TmuxStatus } from '@shared/types'; +import type { TmuxPlatform, TmuxStatus } from '@shared/types'; const OFFICIAL_TMUX_INSTALL_URL = 'https://github.com/tmux/tmux/wiki/Installing'; const TMUX_README_URL = 'https://github.com/tmux/tmux/blob/master/README'; @@ -16,6 +16,18 @@ interface SourceLink { url: string; } +interface PlatformInstallGuideStep { + kind: 'text' | 'code'; + value: string; +} + +interface PlatformInstallGuide { + platform: Exclude; + title: string; + steps: PlatformInstallGuideStep[]; + sources: SourceLink[]; +} + type BannerState = | { loading: true; status: null; error: null } | { loading: false; status: TmuxStatus; error: null } @@ -23,6 +35,56 @@ type BannerState = const INITIAL_STATE: BannerState = { loading: true, status: null, error: null }; +const PLATFORM_INSTALL_GUIDES: readonly PlatformInstallGuide[] = [ + { + platform: 'darwin', + title: 'macOS', + steps: [ + { kind: 'text', value: 'Recommended: Homebrew' }, + { kind: 'code', value: 'brew install tmux' }, + { kind: 'text', value: 'Alternative: MacPorts' }, + { kind: 'code', value: 'sudo port install tmux' }, + ], + sources: [ + { label: 'tmux guide', url: OFFICIAL_TMUX_INSTALL_URL }, + { label: 'Homebrew', url: HOMEBREW_TMUX_URL }, + { label: 'MacPorts', url: MACPORTS_TMUX_URL }, + ], + }, + { + platform: 'linux', + title: 'Linux', + steps: [ + { kind: 'text', value: 'Use your distro package manager:' }, + { kind: 'code', value: 'sudo apt install tmux' }, + { kind: 'code', value: 'sudo dnf install tmux' }, + { kind: 'code', value: 'sudo yum install tmux' }, + { kind: 'code', value: 'sudo zypper install tmux' }, + { kind: 'code', value: 'sudo pacman -S tmux' }, + ], + sources: [{ label: 'tmux guide', url: OFFICIAL_TMUX_INSTALL_URL }], + }, + { + platform: 'win32', + title: 'Windows', + steps: [ + { + kind: 'text', + value: 'The tmux docs do not provide an official native Windows install command.', + }, + { kind: 'text', value: '1. Install WSL' }, + { kind: 'code', value: 'wsl --install' }, + { kind: 'text', value: '2. Inside Ubuntu or another distro' }, + { kind: 'code', value: 'sudo apt install tmux' }, + ], + sources: [ + { label: 'tmux README', url: TMUX_README_URL }, + { label: 'tmux guide', url: OFFICIAL_TMUX_INSTALL_URL }, + { label: 'Microsoft WSL', url: MICROSOFT_WSL_INSTALL_URL }, + ], + }, +] as const; + const SourceLinks = ({ links }: { links: SourceLink[] }): React.JSX.Element => { return (
@@ -50,91 +112,65 @@ const SourceLinks = ({ links }: { links: SourceLink[] }): React.JSX.Element => { ); }; -const PlatformInstallMatrix = (): React.JSX.Element => { +function getPlatformLabel(platform: TmuxPlatform): string { + if (platform === 'darwin') return 'macOS'; + if (platform === 'linux') return 'Linux'; + if (platform === 'win32') return 'Windows'; + return 'your OS'; +} + +const PlatformInstallCard = ({ guide }: { guide: PlatformInstallGuide }): React.JSX.Element => { return ( -
-
-
- macOS -
-
-
Recommended: Homebrew
- brew install tmux -
Alternative: MacPorts
- - sudo port install tmux - - -
+
+
+ {guide.title}
- -
-
- Linux -
-
-
Use your distro package manager:
- - sudo apt install tmux - - - sudo dnf install tmux - - - sudo yum install tmux - - - sudo zypper install tmux - - sudo pacman -S tmux - -
+
+ {guide.steps.map((step) => + step.kind === 'code' ? ( + + {step.value} + + ) : ( +
{step.value}
+ ) + )} +
+
+ ); +}; -
-
- Windows -
-
-

The tmux docs do not provide an official native Windows install command.

-
1. Install WSL
- wsl --install -
2. Inside Ubuntu or another distro
- - sudo apt install tmux - - +const PlatformInstallMatrix = ({ platform }: { platform: TmuxPlatform }): React.JSX.Element => { + const guides = + platform === 'unknown' + ? PLATFORM_INSTALL_GUIDES + : PLATFORM_INSTALL_GUIDES.filter((guide) => guide.platform === platform); + const singleGuide = guides.length === 1; + + return ( +
+ {singleGuide && ( +
+ Detected OS: {getPlatformLabel(platform)}
+ )} +
+ {guides.map((guide) => ( + + ))}
); @@ -305,7 +341,7 @@ export const TmuxStatusBanner = (): React.JSX.Element | null => {
- +
); }; diff --git a/src/renderer/components/team/dialogs/CreateTaskDialog.tsx b/src/renderer/components/team/dialogs/CreateTaskDialog.tsx index 57d551f6..04cb17b6 100644 --- a/src/renderer/components/team/dialogs/CreateTaskDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTaskDialog.tsx @@ -20,6 +20,7 @@ import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions'; import { useStore } from '@renderer/store'; +import { selectTeamDataForName } from '@renderer/store/slices/teamSlice'; import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; @@ -77,7 +78,9 @@ export const CreateTaskDialog = ({ submitting = false, }: CreateTaskDialogProps): React.JSX.Element => { const colorMap = useMemo(() => buildMemberColorMap(members), [members]); - const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null); + const projectPath = useStore( + (s) => selectTeamDataForName(s, teamName)?.config.projectPath ?? null + ); const { suggestions: taskSuggestions } = useTaskSuggestions(teamName); const [subject, setSubject] = useState(defaultSubject); const descriptionDraft = useDraftPersistence({ diff --git a/src/renderer/components/team/taskLogs/TaskActivityLinkedToolCard.tsx b/src/renderer/components/team/taskLogs/TaskActivityLinkedToolCard.tsx new file mode 100644 index 00000000..6eaf0cef --- /dev/null +++ b/src/renderer/components/team/taskLogs/TaskActivityLinkedToolCard.tsx @@ -0,0 +1,31 @@ +import { useMemo } from 'react'; + +import { DisplayItemList } from '@renderer/components/chat/DisplayItemList'; + +import type { AIGroupDisplayItem, LinkedToolItem } from '@renderer/types/groups'; + +interface TaskActivityLinkedToolCardProps { + linkedTool: LinkedToolItem; +} + +export const TaskActivityLinkedToolCard = ({ + linkedTool, +}: TaskActivityLinkedToolCardProps): React.JSX.Element => { + const items = useMemo( + () => [{ type: 'tool', tool: linkedTool }], + [linkedTool] + ); + const expandedItemIds = useMemo(() => new Set([`tool-${linkedTool.id}-0`]), [linkedTool.id]); + + return ( +
+ {}} + expandedItemIds={expandedItemIds} + aiGroupId={`task-activity:${linkedTool.id}`} + order="chronological" + /> +
+ ); +}; diff --git a/src/renderer/components/team/taskLogs/TaskActivitySection.tsx b/src/renderer/components/team/taskLogs/TaskActivitySection.tsx index b299b879..3df66115 100644 --- a/src/renderer/components/team/taskLogs/TaskActivitySection.tsx +++ b/src/renderer/components/team/taskLogs/TaskActivitySection.tsx @@ -1,7 +1,6 @@ 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'; @@ -15,11 +14,14 @@ import { } from '@shared/utils/boardTaskActivityPresentation'; import { AlertCircle, ChevronDown, ChevronRight, Loader2 } from 'lucide-react'; +import { TaskActivityLinkedToolCard } from './TaskActivityLinkedToolCard'; + import type { BoardTaskActivityDetail, BoardTaskActivityEntry, BoardTaskActivityTaskRef, } from '@shared/types'; +import type { AIGroupDisplayItem, LinkedToolItem } from '@renderer/types/groups'; interface TaskActivitySectionProps { teamName: string; @@ -92,18 +94,27 @@ function normalizeDetail(detail: BoardTaskActivityDetail): BoardTaskActivityDeta }; } -function hasRenderableLinkedTool(detail: BoardTaskActivityDetail): boolean { +function getFirstRenderableLinkedTool(detail: BoardTaskActivityDetail): LinkedToolItem | null { if (!detail.logDetail || detail.logDetail.chunks.length === 0) { - return false; + return null; } const conversation = transformChunksToConversation(detail.logDetail.chunks, [], false); - return conversation.items.some((item) => { + for (const item of conversation.items) { if (item.type !== 'ai') { - return false; + continue; } - return enhanceAIGroup(item.group).displayItems.length > 0; - }); + + const linkedTool = enhanceAIGroup(item.group).displayItems.find( + (displayItem): displayItem is Extract => + displayItem.type === 'tool' + ); + if (linkedTool) { + return linkedTool.tool; + } + } + + return null; } function ActivityMetadata({ @@ -153,7 +164,7 @@ function ActivityDetailPanel({ }): React.JSX.Element { if (detailState.status === 'loading') { return ( -
+
Loading activity details...
@@ -171,7 +182,7 @@ function ActivityDetailPanel({ if (detailState.status === 'missing') { return ( -
+
Detailed transcript context is no longer available for this activity.
); @@ -182,20 +193,13 @@ function ActivityDetailPanel({ } const { detail } = detailState; - const hasRenderableLog = hasRenderableLinkedTool(detail); + const linkedTool = getFirstRenderableLinkedTool(detail); return ( -
+
- {detail.logDetail && hasRenderableLog ? ( -
- -
- ) : null} + {linkedTool ? : null}
); } @@ -218,10 +222,14 @@ const Row = ({ : 'text-[var(--color-text-muted)]'; return ( -
+
+ {createTaskDialog} {fullscreen && ( void; +} + +export function useGraphCreateTaskDialog(teamName: string): UseGraphCreateTaskDialogResult { + const [dialogState, setDialogState] = useState({ + open: false, + defaultOwner: '', + }); + const [submitting, setSubmitting] = useState(false); + + const { teamData, createTeamTask, isTeamProvisioning } = useStore( + useShallow((state) => ({ + teamData: selectTeamDataForName(state, teamName), + createTeamTask: state.createTeamTask, + isTeamProvisioning: isTeamProvisioningActive(state, teamName), + })) + ); + + const activeMembers = useMemo( + () => (teamData?.members ?? []).filter((member) => !member.removedAt), + [teamData?.members] + ); + + const openCreateTaskDialog = useCallback((owner = ''): void => { + setDialogState({ + open: true, + defaultOwner: owner, + }); + }, []); + + const closeCreateTaskDialog = useCallback((): void => { + setDialogState({ + open: false, + defaultOwner: '', + }); + }, []); + + const handleCreateTask = useCallback( + ( + subject: string, + description: string, + owner?: string, + blockedBy?: string[], + related?: string[], + prompt?: string, + startImmediately?: boolean, + descriptionTaskRefs?: TaskRef[], + promptTaskRefs?: TaskRef[] + ): void => { + setSubmitting(true); + void (async () => { + try { + await createTeamTask(teamName, { + subject, + description: description || undefined, + owner, + blockedBy, + related, + prompt, + descriptionTaskRefs, + promptTaskRefs, + startImmediately, + }); + + if ( + prompt && + owner && + teamData?.isAlive && + !isTeamProvisioning && + startImmediately !== false + ) { + const msg = `New task assigned to ${owner}: "${subject}". Instructions:\n${prompt}`; + try { + await api.teams.processSend(teamName, msg); + } catch { + // best-effort only + } + } + + closeCreateTaskDialog(); + } catch { + // store already exposes the error + } finally { + setSubmitting(false); + } + })(); + }, + [closeCreateTaskDialog, createTeamTask, isTeamProvisioning, teamData?.isAlive, teamName] + ); + + return { + openCreateTaskDialog, + dialog: ( + + ), + }; +} diff --git a/src/renderer/index.css b/src/renderer/index.css index 60a5262a..455760a8 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -1418,10 +1418,13 @@ body.theme-transitioning { stroke: rgba(138, 130, 118, 0.2); } -:root.light .team-graph-view > .absolute.left-20.top-3 { - display: none !important; +:root.light .team-graph-view > .absolute.left-3.top-3 > div { + background: rgba(248, 244, 237, 0.9) !important; + border-color: rgba(120, 113, 108, 0.18) !important; + box-shadow: 0 10px 24px rgba(120, 113, 108, 0.1); } +:root.light .team-graph-view > .absolute.left-3.top-3 > div, :root.light .team-graph-view > .absolute.right-3.top-3 > div:not(.relative), :root.light .team-graph-view > .absolute.right-3.top-3 > .relative > div:first-child, :root.light .team-graph-view > .absolute.bottom-3.right-3 > div, @@ -1437,34 +1440,38 @@ body.theme-transitioning { box-shadow: 0 16px 32px rgba(120, 113, 108, 0.12); } +.team-graph-view > .absolute.left-3.top-3 > div, .team-graph-view > .absolute.right-3.top-3 > div:not(.relative), .team-graph-view > .absolute.right-3.top-3 > .relative > div:first-child { - width: 32px !important; - min-width: 32px !important; - height: 32px !important; - min-height: 32px !important; + width: 25px !important; + min-width: 25px !important; + height: 25px !important; + min-height: 25px !important; padding: 0 !important; justify-content: center !important; - border-radius: 10px !important; + border-radius: 6px !important; } +.team-graph-view > .absolute.left-3.top-3 > div > button, .team-graph-view > .absolute.right-3.top-3 > div:not(.relative) > button, .team-graph-view > .absolute.right-3.top-3 > .relative > div:first-child > button { width: 100% !important; height: 100% !important; min-width: 100% !important; min-height: 100% !important; - padding: 6px !important; + padding: 0 !important; justify-content: center !important; gap: 0 !important; } +.team-graph-view > .absolute.left-3.top-3 > div > button svg, .team-graph-view > .absolute.right-3.top-3 > div:not(.relative) > button svg, .team-graph-view > .absolute.right-3.top-3 > .relative > div:first-child > button svg { - width: 100% !important; - height: 100% !important; + width: 10px !important; + height: 10px !important; } +.team-graph-view > .absolute.left-3.top-3 > div > button span, .team-graph-view > .absolute.right-3.top-3 > div:not(.relative) > button span, .team-graph-view > .absolute.right-3.top-3 > .relative > div:first-child > button span { display: none !important; diff --git a/src/renderer/utils/displayItemBuilder.ts b/src/renderer/utils/displayItemBuilder.ts index ddaf1f89..d6ee8102 100644 --- a/src/renderer/utils/displayItemBuilder.ts +++ b/src/renderer/utils/displayItemBuilder.ts @@ -107,8 +107,14 @@ export function buildDisplayItems( subagents.map((s) => s.parentTaskId).filter((id): id is string => !!id) ); - // Find the step ID of lastOutput to skip it - let lastOutputStepId: string | undefined; + // Find the exact lastOutput step to skip it without accidentally + // dropping the paired tool_call, which shares the same step id. + let lastOutputStepRef: + | { + id: string; + type: SemanticStep['type']; + } + | undefined; if (lastOutput) { for (let i = steps.length - 1; i >= 0; i--) { const step = steps[i]; @@ -117,7 +123,7 @@ export function buildDisplayItems( step.type === 'output' && step.content.outputText === lastOutput.text ) { - lastOutputStepId = step.id; + lastOutputStepRef = { id: step.id, type: step.type }; break; } if ( @@ -125,7 +131,7 @@ export function buildDisplayItems( step.type === 'tool_result' && step.content.toolResultContent === lastOutput.toolResult ) { - lastOutputStepId = step.id; + lastOutputStepRef = { id: step.id, type: step.type }; break; } if ( @@ -133,7 +139,7 @@ export function buildDisplayItems( step.type === 'interruption' && step.content.interruptionText === lastOutput.interruptionMessage ) { - lastOutputStepId = step.id; + lastOutputStepRef = { id: step.id, type: step.type }; break; } } @@ -142,7 +148,11 @@ export function buildDisplayItems( // Build display items for (const step of steps) { // Skip the last output step - if (lastOutputStepId && step.id === lastOutputStepId) { + if ( + lastOutputStepRef && + step.id === lastOutputStepRef.id && + step.type === lastOutputStepRef.type + ) { continue; } diff --git a/test/main/services/team/BoardTaskActivityDetailService.test.ts b/test/main/services/team/BoardTaskActivityDetailService.test.ts index 5f7708a6..7d5ecfb3 100644 --- a/test/main/services/team/BoardTaskActivityDetailService.test.ts +++ b/test/main/services/team/BoardTaskActivityDetailService.test.ts @@ -136,6 +136,104 @@ describe('BoardTaskActivityDetailService', () => { ); }); + it('keeps lifecycle tool-backed activity renderable when focused detail contains a tool execution', async () => { + const record = makeRecord({ + id: 'record-complete', + linkKind: 'lifecycle', + action: { + canonicalToolName: 'task_complete', + toolUseId: 'tool-complete', + category: 'status', + }, + source: { + filePath: '/tmp/task.jsonl', + messageUuid: 'msg-complete', + toolUseId: 'tool-complete', + sourceOrder: 9, + }, + }); + + 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-complete', + timestamp: record.timestamp, + actor: record.actor, + source: record.source, + records: [record], + filteredMessages: [ + { + uuid: 'msg-complete-assistant', + parentUuid: null, + type: 'assistant', + timestamp: new Date(record.timestamp), + role: 'assistant', + content: [{ type: 'tool_use', id: 'tool-complete', name: 'task_complete', input: {} }], + isSidechain: true, + isMeta: false, + toolCalls: [{ id: 'tool-complete', name: 'task_complete', input: {}, isTask: false }], + toolResults: [], + } as never, + { + uuid: 'msg-complete-user', + parentUuid: 'msg-complete-assistant', + type: 'user', + timestamp: new Date(record.timestamp), + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'tool-complete', content: '' }], + isSidechain: true, + isMeta: true, + toolCalls: [], + toolResults: [{ toolUseId: 'tool-complete', content: '', isError: false }], + toolUseResult: { content: '' }, + } as never, + ], + })), + } as never, + { + buildBundleChunks: vi.fn(() => [ + { + id: 'chunk-complete', + chunkType: 'ai', + toolExecutions: [ + { + toolCall: { + id: 'tool-complete', + name: 'task_complete', + input: {}, + isTask: false, + }, + startTime: new Date(record.timestamp), + }, + ], + semanticSteps: [ + { id: 'step-complete-call', type: 'tool_call' }, + { id: 'step-complete-result', type: 'tool_result' }, + ], + }, + ]), + } as never + ); + + const result = await service.getTaskActivityDetail('demo', 'task-a', 'record-complete'); + + expect(result.status).toBe('ok'); + if (result.status !== 'ok') { + throw new Error('expected ok detail'); + } + expect(result.detail.summaryLabel).toBe('Completed task'); + expect(result.detail.logDetail?.chunks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'chunk-complete', + chunkType: 'ai', + }), + ]) + ); + }); + it('returns metadata only for non-tool-backed activity without parsing transcript content', async () => { const record = makeRecord({ id: 'record-2', diff --git a/test/renderer/components/team/taskLogs/TaskActivitySection.test.ts b/test/renderer/components/team/taskLogs/TaskActivitySection.test.ts index 38c70fda..b36a0196 100644 --- a/test/renderer/components/team/taskLogs/TaskActivitySection.test.ts +++ b/test/renderer/components/team/taskLogs/TaskActivitySection.test.ts @@ -17,6 +17,7 @@ const apiState = { const renderabilityState = { hasDisplayItems: true, + toolName: 'task_add_comment', }; vi.mock('@renderer/api', () => ({ @@ -30,18 +31,16 @@ vi.mock('@renderer/api', () => ({ }, })); -vi.mock('@renderer/components/team/members/MemberExecutionLog', () => ({ - MemberExecutionLog: ({ - memberName, - chunks, +vi.mock('@renderer/components/chat/DisplayItemList', () => ({ + DisplayItemList: ({ + items, }: { - memberName?: string; - chunks: { id: string }[]; + items: Array<{ type: string; tool?: { name?: string } }>; }) => React.createElement( 'div', - { 'data-testid': 'member-execution-log' }, - `${memberName ?? 'lead'}:${chunks.length}` + { 'data-testid': 'linked-tool-card' }, + items.map((item) => `${item.type}:${item.tool?.name ?? 'unknown'}`).join(',') ), })); @@ -57,7 +56,21 @@ vi.mock('@renderer/utils/groupTransformer', () => ({ vi.mock('@renderer/utils/aiGroupEnhancer', () => ({ enhanceAIGroup: () => ({ - displayItems: renderabilityState.hasDisplayItems ? [{ id: 'tool-1' }] : [], + displayItems: renderabilityState.hasDisplayItems + ? [ + { + type: 'tool', + tool: { + id: 'tool-1', + name: renderabilityState.toolName, + input: {}, + inputPreview: '', + startTime: new Date('2026-04-13T10:35:00.000Z'), + isOrphaned: false, + }, + }, + ] + : [], }), })); @@ -143,6 +156,7 @@ describe('TaskActivitySection', () => { apiState.getTaskActivity.mockReset(); apiState.getTaskActivityDetail.mockReset(); renderabilityState.hasDisplayItems = true; + renderabilityState.toolName = 'task_add_comment'; vi.unstubAllGlobals(); }); @@ -227,7 +241,7 @@ describe('TaskActivitySection', () => { }); }); - it('loads inline detail lazily and renders metadata plus a focused log snippet', async () => { + it('loads inline detail lazily and renders metadata plus a linked tool card', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); apiState.getTaskActivity.mockResolvedValue([ makeEntry({ @@ -309,7 +323,9 @@ describe('TaskActivitySection', () => { expect(host.textContent).toContain('Comment'); expect(host.textContent).toContain('42'); expect(host.textContent).toContain('while working on #peer12345'); - expect(host.querySelector('[data-testid="member-execution-log"]')?.textContent).toBe('bob:1'); + expect(host.querySelector('[data-testid="linked-tool-card"]')?.textContent).toBe( + 'tool:task_add_comment' + ); expect(host.textContent?.match(/Added a comment/g)?.length).toBe(1); await act(async () => { @@ -317,7 +333,7 @@ describe('TaskActivitySection', () => { await flushMicrotasks(); }); - expect(host.querySelector('[data-testid="member-execution-log"]')).toBeNull(); + expect(host.querySelector('[data-testid="linked-tool-card"]')).toBeNull(); await act(async () => { root.unmount(); @@ -379,7 +395,76 @@ describe('TaskActivitySection', () => { expect(host.textContent).toContain('Viewed task'); expect(host.textContent).toContain('task_get'); - expect(host.querySelector('[data-testid="member-execution-log"]')).toBeNull(); + expect(host.querySelector('[data-testid="linked-tool-card"]')).toBeNull(); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); + + it('shows a linked tool card for lifecycle activity when shared pipeline returns a renderable tool', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + renderabilityState.toolName = 'task_start'; + apiState.getTaskActivity.mockResolvedValue([ + makeEntry({ + id: 'start-live', + timestamp: '2026-04-13T10:37:00.000Z', + linkKind: 'lifecycle', + action: { + canonicalToolName: 'task_start', + category: 'status', + toolUseId: 'tool-start', + }, + source: { + messageUuid: 'start-live-message', + filePath: '/tmp/transcript.jsonl', + toolUseId: 'tool-start', + sourceOrder: 7, + }, + }), + ]); + apiState.getTaskActivityDetail.mockResolvedValue({ + status: 'ok', + detail: { + entryId: 'start-live', + 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' }, + ], + logDetail: { + id: 'activity:start-live', + chunks: [{ id: 'chunk-start' }] 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_start'); + expect(host.querySelector('[data-testid="linked-tool-card"]')?.textContent).toBe( + 'tool:task_start' + ); await act(async () => { root.unmount(); @@ -449,7 +534,7 @@ describe('TaskActivitySection', () => { }); expect(host.textContent).toContain('task_add_comment'); - expect(host.querySelector('[data-testid="member-execution-log"]')).toBeNull(); + expect(host.querySelector('[data-testid="linked-tool-card"]')).toBeNull(); await act(async () => { root.unmount(); @@ -512,7 +597,7 @@ describe('TaskActivitySection', () => { expect(host.textContent).toContain('Started work'); expect(host.textContent).toContain('task_start'); - expect(host.querySelector('[data-testid="member-execution-log"]')).toBeNull(); + expect(host.querySelector('[data-testid="linked-tool-card"]')).toBeNull(); await act(async () => { root.unmount(); diff --git a/test/renderer/components/team/taskLogs/TaskLogsPanel.test.ts b/test/renderer/components/team/taskLogs/TaskLogsPanel.test.ts new file mode 100644 index 00000000..3ce7aa11 --- /dev/null +++ b/test/renderer/components/team/taskLogs/TaskLogsPanel.test.ts @@ -0,0 +1,201 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { TaskLogsPanel } from '../../../../../src/renderer/components/team/taskLogs/TaskLogsPanel'; + +import type { TeamTaskWithKanban } from '../../../../../src/shared/types'; + +const featureGateState = { + activityEnabled: true, + exactLogsEnabled: true, +}; + +vi.mock('../../../../../src/renderer/components/team/taskLogs/TaskActivitySection', () => ({ + TaskActivitySection: () => React.createElement('div', { 'data-testid': 'task-activity' }, 'activity'), +})); + +vi.mock('../../../../../src/renderer/components/team/taskLogs/TaskLogStreamSection', () => ({ + TaskLogStreamSection: () => + React.createElement('div', { 'data-testid': 'task-log-stream' }, 'stream'), +})); + +vi.mock('../../../../../src/renderer/components/team/taskLogs/ExecutionSessionsSection', () => ({ + ExecutionSessionsSection: () => + React.createElement('div', { 'data-testid': 'execution-sessions' }, 'sessions'), +})); + +vi.mock('../../../../../src/renderer/components/team/taskLogs/featureGates', () => ({ + isBoardTaskActivityUiEnabled: () => featureGateState.activityEnabled, + isBoardTaskExactLogsUiEnabled: () => featureGateState.exactLogsEnabled, +})); + +vi.mock('../../../../../src/renderer/components/ui/tabs', async () => { + const ReactModule = await import('react'); + const TabsContext = ReactModule.createContext<{ + value: string; + onValueChange: (value: string) => void; + } | null>(null); + + return { + Tabs: ({ + value, + onValueChange, + children, + }: { + value: string; + onValueChange: (value: string) => void; + children: React.ReactNode; + }) => + ReactModule.createElement( + TabsContext.Provider, + { value: { value, onValueChange } }, + children + ), + TabsList: ({ children }: { children: React.ReactNode }) => + ReactModule.createElement('div', null, children), + TabsTrigger: ({ + value, + children, + }: { + value: string; + children: React.ReactNode; + }) => { + const context = ReactModule.useContext(TabsContext); + return ReactModule.createElement( + 'button', + { + type: 'button', + 'data-state': context?.value === value ? 'active' : 'inactive', + onClick: () => context?.onValueChange(value), + }, + children + ); + }, + TabsContent: ({ + value, + children, + }: { + value: string; + children: React.ReactNode; + className?: string; + }) => { + const context = ReactModule.useContext(TabsContext); + if (context?.value !== value) { + return null; + } + return ReactModule.createElement('div', { 'data-state': 'active' }, children); + }, + }; +}); + +function flushMicrotasks(): Promise { + return Promise.resolve(); +} + +function findTabButton(host: HTMLElement, label: string): HTMLButtonElement | null { + return ( + Array.from(host.querySelectorAll('button')).find((button) => button.textContent?.includes(label)) ?? + null + ) as HTMLButtonElement | null; +} + +function makeTask(overrides: Partial = {}): TeamTaskWithKanban { + return { + id: 'task-1', + displayId: 'abc12345', + teamName: 'demo', + subject: 'Test task', + description: '', + status: 'in_progress', + owner: 'bob', + createdAt: '2026-04-13T10:00:00.000Z', + updatedAt: '2026-04-13T10:05:00.000Z', + reviewState: 'none', + reviewNotes: [], + blockedBy: [], + blocks: [], + comments: [], + attachments: [], + workIntervals: [], + kanbanColumnId: null, + ...overrides, + } as TeamTaskWithKanban; +} + +describe('TaskLogsPanel', () => { + afterEach(() => { + document.body.innerHTML = ''; + featureGateState.activityEnabled = true; + featureGateState.exactLogsEnabled = true; + vi.unstubAllGlobals(); + }); + + it('defaults to Task Log Stream and switches between the three tabs', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(TaskLogsPanel, { teamName: 'demo', task: makeTask() })); + await flushMicrotasks(); + }); + + expect(host.textContent).toContain('Task Log Stream'); + expect(host.textContent).toContain('Task Activity'); + expect(host.textContent).toContain('Execution Sessions'); + expect(findTabButton(host, 'Task Log Stream')?.getAttribute('data-state')).toBe('active'); + expect(host.querySelector('[data-testid="task-log-stream"]')).not.toBeNull(); + + const activityTab = findTabButton(host, 'Task Activity'); + expect(activityTab).not.toBeNull(); + + await act(async () => { + activityTab?.click(); + await flushMicrotasks(); + }); + + expect(findTabButton(host, 'Task Activity')?.getAttribute('data-state')).toBe('active'); + expect(host.querySelector('[data-testid="task-activity"]')).not.toBeNull(); + + const sessionsTab = findTabButton(host, 'Execution Sessions'); + expect(sessionsTab).not.toBeNull(); + + await act(async () => { + sessionsTab?.click(); + await flushMicrotasks(); + }); + + expect(findTabButton(host, 'Execution Sessions')?.getAttribute('data-state')).toBe('active'); + expect(host.querySelector('[data-testid="execution-sessions"]')).not.toBeNull(); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); + + it('falls back to Task Activity when Task Log Stream is disabled', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + featureGateState.exactLogsEnabled = false; + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(TaskLogsPanel, { teamName: 'demo', task: makeTask() })); + await flushMicrotasks(); + }); + + expect(host.querySelector('[data-testid="task-log-stream"]')).toBeNull(); + expect(findTabButton(host, 'Task Activity')?.getAttribute('data-state')).toBe('active'); + expect(host.querySelector('[data-testid="task-activity"]')).not.toBeNull(); + expect(host.textContent).not.toContain('Task Log Stream'); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); +}); diff --git a/test/renderer/utils/displayItemBuilder.test.ts b/test/renderer/utils/displayItemBuilder.test.ts index 1a843713..08bed85b 100644 --- a/test/renderer/utils/displayItemBuilder.test.ts +++ b/test/renderer/utils/displayItemBuilder.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { buildDisplayItemsFromMessages } from '../../../src/renderer/utils/displayItemBuilder'; +import { + buildDisplayItems, + buildDisplayItemsFromMessages, +} from '../../../src/renderer/utils/displayItemBuilder'; import type { ParsedMessage } from '../../../src/main/types/messages'; +import type { SemanticStep } from '../../../src/main/types/chunks'; +import type { AIGroupLastOutput } from '../../../src/renderer/types/groups'; /** * Helper to create a minimal ParsedMessage for testing. @@ -97,3 +102,53 @@ describe('buildDisplayItemsFromMessages', () => { }); }); }); + +describe('buildDisplayItems', () => { + it('keeps the linked tool item when the last output is the paired tool_result', () => { + const steps: SemanticStep[] = [ + { + id: 'tool-1', + type: 'tool_call', + startTime: new Date('2025-01-01T00:00:00Z'), + durationMs: 0, + content: { + toolName: 'mcp__agent-teams__task_add_comment', + toolInput: { text: 'hello' }, + }, + context: 'main', + } as SemanticStep, + { + id: 'tool-1', + type: 'tool_result', + startTime: new Date('2025-01-01T00:00:01Z'), + durationMs: 0, + content: { + toolResultContent: 'comment posted', + isError: false, + }, + context: 'main', + } as SemanticStep, + ]; + + const lastOutput: AIGroupLastOutput = { + type: 'tool_result', + toolResult: 'comment posted', + isError: false, + timestamp: new Date('2025-01-01T00:00:01Z'), + }; + + const items = buildDisplayItems(steps, lastOutput, []); + + expect(items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'tool', + tool: expect.objectContaining({ + id: 'tool-1', + name: 'mcp__agent-teams__task_add_comment', + }), + }), + ]) + ); + }); +});