diff --git a/src/renderer/features/agents/components/AgentContextPanel.test.tsx b/src/renderer/features/agents/components/AgentContextPanel.test.tsx index c1b0c8b..9671c7c 100644 --- a/src/renderer/features/agents/components/AgentContextPanel.test.tsx +++ b/src/renderer/features/agents/components/AgentContextPanel.test.tsx @@ -2,8 +2,16 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { describe, expect, it, vi } from 'vitest'; +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; +import { resetAppStore } from '@/renderer/app/store/use-app-store'; import { AgentContextPanel } from '@/renderer/features/agents/components/AgentContextPanel'; import { createEmptyAgentCustomizationDraft } from '@/renderer/features/agents/model/agent-customization'; import type { PresentedAgent } from '@/renderer/features/agents/types'; @@ -78,6 +86,14 @@ function createAgent( } describe('AgentContextPanel', () => { + beforeEach(() => { + resetAppStore(); + }); + + afterEach(() => { + resetAppStore(); + }); + it('surfaces agent identity while preserving connection details and filtered cards', () => { render( { it('lets the inspector switch a Telegram agent back to Dune chat', async () => { const user = userEvent.setup(); - const onUpdateChannel = vi.fn(async () => undefined); + const onUpdateChannel = vi.fn(() => Promise.resolve(undefined)); render( { agent={createAgent()} onClose={vi.fn()} onOpenTelegramSetup={onOpenTelegramSetup} - onUpdateChannel={vi.fn(async () => undefined)} + onUpdateChannel={vi.fn(() => Promise.resolve(undefined))} />, ); @@ -265,7 +281,7 @@ describe('AgentContextPanel', () => { it('saves a paired Telegram channel from the inspector', async () => { const user = userEvent.setup(); - const onUpdateChannel = vi.fn(async () => undefined); + const onUpdateChannel = vi.fn(() => Promise.resolve(undefined)); render( { telegramSetupSessionId: 'telegram-session-1', }); }); + + it('switches between overview and timeline tabs', async () => { + const user = userEvent.setup(); + + render( + , + ); + + expect(screen.getByText('Connection')).toBeInTheDocument(); + + await user.click(screen.getByRole('tab', { name: /Timeline/i })); + + expect(screen.getByTestId('agent-timeline')).toBeInTheDocument(); + expect(screen.queryByText('Connection')).not.toBeInTheDocument(); + expect(screen.getByText('No timeline events are recorded yet for this agent.')).toBeInTheDocument(); + }); }); diff --git a/src/renderer/features/agents/components/AgentContextPanel.tsx b/src/renderer/features/agents/components/AgentContextPanel.tsx index 4d29cf5..410e657 100644 --- a/src/renderer/features/agents/components/AgentContextPanel.tsx +++ b/src/renderer/features/agents/components/AgentContextPanel.tsx @@ -17,6 +17,7 @@ import { type AgentCustomizationDraft, hasAgentCustomization, } from '@/renderer/features/agents/model/agent-customization'; +import { AgentTimeline } from '@/renderer/features/agents/components/AgentTimeline'; import { createAgentChannelOptions, formatChannelStatus, @@ -207,6 +208,7 @@ export function AgentContextPanel({ const [isUpdatingChannel, setUpdatingChannel] = useState(false); const [isDeleteAgentOpen, setDeleteAgentOpen] = useState(false); const [isDeletingAgent, setDeletingAgent] = useState(false); + const [view, setView] = useState<'overview' | 'timeline'>('overview'); const channelStatusLabel = formatChannelStatus(agent.channel.status); const visibleContextCards = agent.contextCards .filter((card) => !isSuppressedContextCard(card)) @@ -315,256 +317,287 @@ export function AgentContextPanel({ ) : null} -
- -
- {getAgentArchetypeLabel(agent.definition.archetype)} - - {agent.statusLabel} -
- - - - - -
+
+ {[ + { label: 'Overview', value: 'overview' as const }, + { label: 'Timeline', value: 'timeline' as const }, + ].map((option) => ( + + ))}
- -
-
+ {view === 'overview' ? ( + <> +
-
- Connection +
+ {getAgentArchetypeLabel(agent.definition.archetype)} + + {agent.statusLabel}
- - {agent.channel.target ? ( - <> - - - - ) : null} + - - - {onUpdateChannel ? ( - <> - -
-
+ +
+ +
+
+ +
+ Connection +
+ + + {agent.channel.target ? ( + <> + + + + ) : null} + + + + {onUpdateChannel ? ( + <> + +
+ + +

+ {hasPendingChannelChange + ? channelDraftId === 'telegram' + ? matchedTelegramChat + ? `Telegram is paired with ${matchedTelegramChat.name}. Save it from the popup.` + : `Open ${selectedChannel.label} setup in a popup to finish pairing and save there.` + : `Save to move this agent back into ${selectedChannel.label}.` + : agent.channel.id === 'telegram' + ? 'Use the popup to re-pair or update this Telegram binding.' + : 'Channel changes apply to the live agent workspace.'} +

+ {isTelegramDraftSelected && matchedTelegramChat ? ( +

+ Matched chat ready: {matchedTelegramChat.name} +

+ ) : null} + {channelError ? ( +

+ {channelError} +

+ ) : null} +
+ + ) : null} +
+ + {onOpenTelegramSetup && (isTelegramDraftSelected || agent.channel.id === 'telegram') ? ( +
+ ) : null} + + {visibleContextCards.map((card) => ( + +
+

{card.title}

+

{card.body}

+
+
+ ))} + + {canDeleteAgent ? ( + +
+

Delete this agent

+

+ Remove this agent workspace and clear any work item assignments that point + to it.

- {isTelegramDraftSelected && matchedTelegramChat ? ( -

- Matched chat ready: {matchedTelegramChat.name} -

- ) : null} - {channelError ? ( -

- {channelError} -

- ) : null} +
- +
) : null} - - - {onOpenTelegramSetup && (isTelegramDraftSelected || agent.channel.id === 'telegram') ? ( - - ) : null} - - {onUpdateChannel && hasPendingChannelChange ? ( -
- - -
- ) : null} - -
- -
- -
- Customization
- - -
-

- {hasCustomization ? 'Session draft active' : 'Inherited defaults'} -

-

- {hasCustomization - ? 'Saved locally in renderer memory for this session.' - : 'No local draft is attached to this agent yet.'} -

-
- -
- - 0 ? String(skillCount) : 'None'} - /> - 0 ? String(mcpCount) : 'None'} - /> -
-
- - {onEditCustomization ? ( - - ) : null} -
-
- - {codingEngines.length > 0 ? ( -
- -
- Coding Engines -
- - - {codingEngines.map((engine) => ( - - ))} - -
-
- ) : null} - - {visibleContextCards.map((card) => ( - -
-

{card.title}

-

{card.body}

-
-
- ))} - - {canDeleteAgent ? ( - -
-

Delete this agent

-

- Remove this agent workspace and clear any work item assignments that point - to it. -

- -
-
- ) : null} -
-
+ +
+ + ) : ( + + )} diff --git a/src/renderer/features/agents/components/AgentTimeline.test.tsx b/src/renderer/features/agents/components/AgentTimeline.test.tsx new file mode 100644 index 0000000..982c81d --- /dev/null +++ b/src/renderer/features/agents/components/AgentTimeline.test.tsx @@ -0,0 +1,293 @@ +// Agent timeline tests. + +import { fireEvent, render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { resetAppStore, useAppStore } from '@/renderer/app/store/use-app-store'; +import { AgentTimeline } from '@/renderer/features/agents/components/AgentTimeline'; +import type { PresentedAgent } from '@/renderer/features/agents/types'; +import type { WorkflowItem } from '@/renderer/features/workflow/types'; + +/** Creates an agent fixture. */ +function createAgent(overrides: Partial = {}): PresentedAgent { + return { + activityEvents: [], + channel: { + canCompose: true, + id: 'dune-chat', + kind: 'built-in', + label: 'Dune chat', + status: 'ready', + }, + codingEngineEvents: [], + contextCards: [], + definition: { archetype: 'custom', responsibilities: [] }, + id: 'agent-1', + messages: [], + name: 'Navigator', + note: '', + preview: 'Ready', + projectId: 'project-1', + status: 'ready', + statusLabel: 'Ready', + telegram: null, + transcript: { + archivedMessageCount: 0, + hasOlderMessages: false, + rollingSummary: null, + totalMessageCount: 0, + }, + updatedAt: Date.now(), + updatedLabel: 'Now', + workspace: 'Workspace', + ...overrides, + }; +} + +/** Creates a workflow item fixture. */ +function createItem(overrides: Partial = {}): WorkflowItem { + return { + activity: { + archivedEventCount: 0, + hasOlderEvents: false, + rollingSummary: null, + totalEventCount: 0, + }, + artifactFolderName: 'item-1', + brief: 'Brief', + createdAt: Date.parse('2026-04-10T08:00:00.000Z'), + id: 'item-1', + primaryAgentId: 'agent-1', + projectId: 'project-1', + scheduledTaskId: null, + sortOrder: 0, + status: 'active', + tasks: [], + title: 'Refactor auth', + updatedAt: Date.parse('2026-04-10T08:00:00.000Z'), + workProducts: [], + workflowEvents: [], + ...overrides, + }; +} + +describe('AgentTimeline', () => { + beforeEach(() => { + resetAppStore(); + }); + + afterEach(() => { + resetAppStore(); + }); + + it('aggregates workflow and activity events in reverse chronological order', () => { + useAppStore.setState((state) => ({ + ...state, + items: [ + createItem({ + workflowEvents: [ + { + actor: 'Navigator', + createdAt: Date.parse('2026-04-12T09:00:00.000Z'), + description: 'Picked up the auth refactor.', + id: 'workflow-1', + kind: 'assignment', + }, + { + actor: 'Human PM', + createdAt: Date.parse('2026-04-11T09:00:00.000Z'), + description: 'Assigned the follow-up to Navigator.', + id: 'workflow-human-assignment', + kind: 'assignment', + }, + { + actor: 'Human reviewer', + createdAt: Date.parse('2026-04-11T10:00:00.000Z'), + description: 'Reviewer asked for clearer auth copy.', + id: 'workflow-human-feedback', + kind: 'feedback', + }, + ], + }), + createItem({ + id: 'item-2', + projectId: 'project-2', + title: 'Ship billing UI', + workflowEvents: [ + { + actor: 'Navigator', + createdAt: Date.parse('2026-04-13T11:00:00.000Z'), + description: 'Closed the billing checklist.', + id: 'workflow-2', + kind: 'task', + }, + ], + }), + createItem({ + id: 'item-3', + primaryAgentId: 'agent-2', + title: 'Owned by another agent', + workflowEvents: [ + { + actor: 'Navigator', + createdAt: Date.parse('2026-04-15T09:00:00.000Z'), + description: 'This belongs to another agent-owned item.', + id: 'workflow-other-owned', + kind: 'task', + }, + ], + }), + ], + })); + + render( + , + ); + + const events = screen.getAllByTestId('agent-timeline-event'); + expect(events).toHaveLength(5); + const firstEvent = events[0]; + const secondEvent = events[1]; + const thirdEvent = events[2]; + const fourthEvent = events[3]; + const fifthEvent = events[4]; + + if (!firstEvent || !secondEvent || !thirdEvent || !fourthEvent || !fifthEvent) { + throw new Error('Expected five timeline events.'); + } + + expect(within(firstEvent).getByText('Executed pnpm test')).toBeInTheDocument(); + expect(within(secondEvent).getByText('Closed the billing checklist.')).toBeInTheDocument(); + expect(screen.getByText('Assigned the follow-up to Navigator.')).toBeInTheDocument(); + expect(screen.getByText('Reviewer asked for clearer auth copy.')).toBeInTheDocument(); + expect(within(thirdEvent).getByText('Picked up the auth refactor.')).toBeInTheDocument(); + expect(within(fourthEvent).getByText('Reviewer asked for clearer auth copy.')).toBeInTheDocument(); + expect(within(fifthEvent).getByText('Assigned the follow-up to Navigator.')).toBeInTheDocument(); + expect(screen.queryByText('This belongs to another agent-owned item.')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Ship billing UI/i })).toBeInTheDocument(); + }); + + it('filters the timeline by type and date range', async () => { + const user = userEvent.setup(); + + useAppStore.setState((state) => ({ + ...state, + items: [ + createItem({ + workflowEvents: [ + { + actor: 'Navigator', + createdAt: Date.parse('2026-04-10T09:00:00.000Z'), + description: 'Captured reviewer feedback.', + id: 'workflow-1', + kind: 'feedback', + }, + ], + }), + ], + })); + + render( + , + ); + + const toolFilter = screen.getByRole('button', { name: /^Tool$/i }); + expect(toolFilter).toHaveAttribute('aria-pressed', 'true'); + + await user.click(toolFilter); + expect(toolFilter).toHaveAttribute('aria-pressed', 'false'); + expect(screen.queryByText('Executed pnpm test')).not.toBeInTheDocument(); + expect(screen.getByText('Marked the handoff complete')).toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText('From'), { target: { value: '2026-04-15' } }); + expect(screen.queryByText('Captured reviewer feedback.')).not.toBeInTheDocument(); + expect(screen.getByText('Marked the handoff complete')).toBeInTheDocument(); + }); + + it('exports the filtered timeline as markdown and includes collapsed detail', async () => { + const user = userEvent.setup(); + const copyText = vi.fn((value: string) => { + void value; + return Promise.resolve(undefined); + }); + + window.duneDesktop = { + ...window.duneDesktop, + copyText, + platform: window.duneDesktop?.platform ?? 'darwin', + }; + + useAppStore.setState((state) => ({ + ...state, + items: [ + createItem({ + workflowEvents: [ + { + actor: 'Navigator', + createdAt: Date.parse('2026-04-12T09:00:00.000Z'), + description: 'Picked up the auth refactor.', + id: 'workflow-1', + kind: 'assignment', + }, + ], + }), + ], + })); + + render( + , + ); + + await user.click(screen.getByRole('button', { name: /Export as Markdown/i })); + + expect(copyText).toHaveBeenCalledTimes(1); + const markdown = copyText.mock.calls[0]?.[0] ?? ''; + expect(markdown).toContain('# Agent Timeline: Navigator'); + expect(markdown).toContain('**Actor:** Navigator'); + expect(markdown).toContain('**Work item:** Refactor auth'); + expect(markdown).toContain('Executed pnpm test'); + expect(markdown).toContain('> Ran pnpm test and copied the failures into the work log.'); + }); +}); diff --git a/src/renderer/features/agents/components/AgentTimeline.tsx b/src/renderer/features/agents/components/AgentTimeline.tsx new file mode 100644 index 0000000..e57fdc3 --- /dev/null +++ b/src/renderer/features/agents/components/AgentTimeline.tsx @@ -0,0 +1,513 @@ +// Agent timeline inspector view. + +import { useState } from 'react'; +import { + ChevronDown, + ChevronRight, + ExternalLink, +} from 'lucide-react'; + +import { useAppStore } from '@/renderer/app/store/use-app-store'; +import { formatMessageTimestamp } from '@/renderer/features/agents/model/time'; +import type { + AgentActivityEvent, + PresentedAgent, +} from '@/renderer/features/agents/types'; +import type { WorkflowEvent } from '@/renderer/features/workflow/types'; +import { cn } from '@/renderer/shared/lib/utils'; +import { Button } from '@/renderer/shared/ui/button'; + +type TimelineEventKind = WorkflowEvent['kind'] | AgentActivityEvent['kind']; + +interface TimelineEvent { + actor?: string; + description: string; + detail?: string; + eventId: string; + kind: TimelineEventKind; + itemId?: string; + itemTitle?: string; + projectId?: string; + source: 'activity' | 'workflow'; + timestamp: number; +} + +const timelineEventKinds: TimelineEventKind[] = [ + 'assignment', + 'feedback', + 'item', + 'note', + 'task', + 'tool', + 'status', + 'subagent', +]; + +const timelineKindLabels: Record = { + assignment: 'Assignment', + feedback: 'Feedback', + item: 'Item', + note: 'Note', + status: 'Status', + subagent: 'Subagent', + task: 'Task', + tool: 'Tool', +}; + +const detailedTimestampFormatter = new Intl.DateTimeFormat('en', { + day: 'numeric', + hour: '2-digit', + hour12: false, + minute: '2-digit', + month: 'short', + year: 'numeric', +}); + +/** Parses a local date input value into a timestamp. */ +function parseDateInput(value: string, endOfDay = false) { + if (!value) { + return null; + } + + const parts = value.split('-').map((part) => Number(part)); + + if (parts.length !== 3) { + return null; + } + + const year = parts[0]; + const month = parts[1]; + const day = parts[2]; + + if ( + year === undefined + || month === undefined + || day === undefined + || Number.isNaN(year) + || Number.isNaN(month) + || Number.isNaN(day) + ) { + return null; + } + + return new Date( + year, + month - 1, + day, + endOfDay ? 23 : 0, + endOfDay ? 59 : 0, + endOfDay ? 59 : 0, + endOfDay ? 999 : 0, + ).getTime(); +} + +/** Returns whether filters are active. */ +function hasActiveFilters( + fromDate: string, + toDate: string, + selectedKinds: TimelineEventKind[], +) { + return Boolean(fromDate || toDate || selectedKinds.length !== timelineEventKinds.length); +} + +/** Formats a detailed timeline timestamp. */ +function formatDetailedTimestamp(timestamp: number) { + return detailedTimestampFormatter.format(timestamp); +} + +/** Builds export markdown. */ +function buildExportMarkdown( + agentName: string, + events: TimelineEvent[], +) { + const lines = [ + `# Agent Timeline: ${agentName}`, + `_Exported ${formatDetailedTimestamp(Date.now())}_`, + ]; + + for (const event of events) { + lines.push(''); + lines.push(`## ${formatDetailedTimestamp(event.timestamp)} — ${timelineKindLabels[event.kind]}`); + + if (event.itemTitle) { + lines.push(`**Work item:** ${event.itemTitle}`); + } + + if (event.actor) { + lines.push(`**Actor:** ${event.actor}`); + } + + lines.push(event.description); + + if (event.detail) { + lines.push(''); + lines.push(`> ${event.detail.replace(/\n/g, '\n> ')}`); + } + } + + return lines.join('\n'); +} + +/** Renders the agent timeline UI. */ +export function AgentTimeline({ agent }: { agent: PresentedAgent }) { + const items = useAppStore((state) => state.items); + const [expandedEventIds, setExpandedEventIds] = useState([]); + const [selectedKinds, setSelectedKinds] = useState(timelineEventKinds); + const [fromDate, setFromDate] = useState(''); + const [toDate, setToDate] = useState(''); + const [exportState, setExportState] = useState<'idle' | 'copied' | 'error'>('idle'); + const [exportFeedback, setExportFeedback] = useState(null); + + const allEvents = [ + ...items.flatMap((item) => { + if (item.primaryAgentId !== agent.id) { + return []; + } + + return item.workflowEvents.map((event) => ({ + actor: event.actor ?? agent.name, + description: event.description, + eventId: `workflow:${event.id}`, + kind: event.kind, + itemId: item.id, + itemTitle: item.title, + projectId: item.projectId, + source: 'workflow', + timestamp: event.createdAt, + })); + }), + ...agent.activityEvents.map((event) => ({ + actor: agent.name, + description: event.label, + eventId: `activity:${event.id}`, + kind: event.kind, + source: 'activity', + timestamp: event.timestamp, + ...(event.detail?.trim() + ? { detail: event.detail } + : {}), + })), + ].sort((left, right) => { + if (left.timestamp !== right.timestamp) { + return right.timestamp - left.timestamp; + } + + return left.eventId.localeCompare(right.eventId); + }); + + const fromTimestamp = parseDateInput(fromDate); + const toTimestamp = parseDateInput(toDate, true); + const filteredEvents = allEvents.filter((event) => { + if (!selectedKinds.includes(event.kind)) { + return false; + } + + if (fromTimestamp !== null && event.timestamp < fromTimestamp) { + return false; + } + + if (toTimestamp !== null && event.timestamp > toTimestamp) { + return false; + } + + return true; + }); + const expandedEventIdSet = new Set(expandedEventIds); + const filtersActive = hasActiveFilters(fromDate, toDate, selectedKinds); + + /** Toggles an event kind filter. */ + const handleToggleKind = (kind: TimelineEventKind) => { + setSelectedKinds((current) => + current.includes(kind) + ? current.filter((candidate) => candidate !== kind) + : [...current, kind], + ); + }; + + /** Toggles an expanded event. */ + const handleToggleExpanded = (eventId: string) => { + setExpandedEventIds((current) => + current.includes(eventId) + ? current.filter((candidate) => candidate !== eventId) + : [...current, eventId], + ); + }; + + /** Resets timeline filters. */ + const handleResetFilters = () => { + setFromDate(''); + setToDate(''); + setSelectedKinds(timelineEventKinds); + }; + + /** Opens the linked workflow item. */ + const handleOpenItem = (event: TimelineEvent) => { + if (!event.itemId || !event.projectId) { + return; + } + + const state = useAppStore.getState(); + state.selectProject(event.projectId); + state.selectItem(event.itemId); + state.selectProjectView('board'); + state.setRoute('workflow'); + }; + + /** Copies the current timeline report. */ + const handleExport = async () => { + if (filteredEvents.length === 0) { + return; + } + + const markdown = buildExportMarkdown(agent.name, filteredEvents); + + try { + if (typeof window.duneDesktop?.copyText === 'function') { + await window.duneDesktop.copyText(markdown); + } else if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(markdown); + } else { + throw new Error('Clipboard is unavailable.'); + } + + setExportState('copied'); + setExportFeedback('Markdown report copied to clipboard.'); + } catch (error) { + setExportState('error'); + setExportFeedback(error instanceof Error ? error.message : 'Failed to copy markdown report.'); + } + }; + + return ( +
+
+
+
+
Timeline
+

{agent.name}

+

+ Every workflow event and runtime activity recorded for this agent, newest first. +

+
+ +
+ + + {filteredEvents.length} event{filteredEvents.length === 1 ? '' : 's'} + + {exportFeedback ? ( +

+ {exportFeedback} +

+ ) : null} +
+
+ +
+
+
Event types
+
+ {timelineEventKinds.map((kind) => { + const isActive = selectedKinds.includes(kind); + + return ( + + ); + })} +
+
+ +
+ + +
+
+ + {filtersActive ? ( +
+ +
+ ) : null} +
+ +
+ {filteredEvents.length === 0 ? ( +
+
+
Timeline
+

+ {allEvents.length === 0 + ? 'No timeline events are recorded yet for this agent.' + : 'No timeline events match the current filters.'} +

+
+
+ ) : ( +
+
+ {filteredEvents.map((event, index) => { + const isExpanded = expandedEventIdSet.has(event.eventId); + + return ( +
+ {index < filteredEvents.length - 1 ? ( +
+ ) : null} + +
+ +
+ +
+ + + {event.itemTitle ? ( +
+ +
+ ) : null} + + {isExpanded ? ( +
+ {event.detail ? ( +
+                              {event.detail}
+                            
+ ) : ( +
+ No additional detail recorded for this event. +
+ )} + +
+
+ Recorded +

{formatDetailedTimestamp(event.timestamp)}

+
+
+ Source +

{event.source}

+
+
+ Event ID +

{event.eventId}

+
+ {event.itemId ? ( +
+ Item ID +

{event.itemId}

+
+ ) : null} +
+
+ ) : null} +
+
+ ); + })} +
+
+ )} +
+
+ ); +}