diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.test.ts b/src/renderer/src/components/activity/ActivityPrototypePage.test.ts index c18680f9837..cec15d11cbf 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.test.ts +++ b/src/renderer/src/components/activity/ActivityPrototypePage.test.ts @@ -6,12 +6,15 @@ import { type AgentStatusEntry } from '../../../../shared/agent-status-types' import type { Repo, TerminalTab, Worktree } from '../../../../shared/types' +import { formatAgentTypeLabel } from '@/lib/agent-status' import type { RetainedAgentEntry } from '@/store/slices/agent-status' import { activityThreadResponseRenderPreview, activityThreadMatchesSearchQuery, + buildActivityThreadGroups, buildActivityEvents, buildAgentPaneThreads, + getActivityThreadGroup, groupActivityThreadsByStatus } from './ActivityPrototypePage' @@ -60,6 +63,26 @@ function makeTab(): TerminalTab { } } +function makeWorktreeWithId(id: string, repoId = 'repo-1', displayName = id): Worktree { + return { + ...makeWorktree(), + id, + repoId, + path: `/repo/${id}`, + displayName + } +} + +function makeTabWithIds(id: string, worktreeId: string, title = id): TerminalTab { + return { + ...makeTab(), + id, + ptyId: `pty-${id}`, + worktreeId, + title + } +} + function makeWorkingEntryWithPriorDone(): AgentStatusEntry { return { state: 'working', @@ -383,3 +406,163 @@ describe('buildActivityEvents', () => { ]) }) }) + +describe('activity thread grouping', () => { + it('status grouping separates interrupted done from normal done and keeps Interrupted label', () => { + const repo = makeRepo() + const worktree = makeWorktree() + const tab1 = makeTabWithIds('tab-1', worktree.id) + const tab2 = makeTabWithIds('tab-2', worktree.id) + const sharedDone: Omit< + AgentStatusEntry, + 'paneKey' | 'interrupted' | 'updatedAt' | 'stateStartedAt' + > = { + state: 'done', + prompt: 'Prompt', + terminalTitle: 'Claude', + stateHistory: [], + agentType: 'claude' + } + const { events, liveAgentByPaneKey } = buildActivityEvents({ + agentStatusByPaneKey: { + 'tab-1:1': { + ...sharedDone, + paneKey: 'tab-1:1', + interrupted: true, + updatedAt: 3_000, + stateStartedAt: 3_000 + }, + 'tab-2:1': { + ...sharedDone, + paneKey: 'tab-2:1', + interrupted: false, + updatedAt: 2_000, + stateStartedAt: 2_000 + } + }, + retainedAgentsByPaneKey: {}, + tabsByWorktree: { [worktree.id]: [tab1, tab2] }, + worktreeMap: new Map([[worktree.id, worktree]]), + repoMap: new Map([[repo.id, repo]]), + acknowledgedAgentsByPaneKey: {}, + now: 3_000 + }) + const threads = buildAgentPaneThreads({ events, liveAgentByPaneKey }) + const groups = buildActivityThreadGroups(threads, 'status') + + expect(groups).toHaveLength(2) + expect(groups[0].key).toBe('done:interrupted') + expect(groups[0].label).toBe('Interrupted') + expect(groups[1].key).toBe('done') + expect(groups[1].label).toBe('Done') + }) + + it('project grouping falls back to unknown project when repo is missing', () => { + const worktree = makeWorktreeWithId('wt-unknown', 'missing-repo', 'unknown-wt') + const tab = makeTabWithIds('tab-unknown', worktree.id) + const { events, liveAgentByPaneKey } = buildActivityEvents({ + agentStatusByPaneKey: { + 'tab-unknown:1': { + state: 'done', + prompt: 'Prompt', + updatedAt: 1_000, + stateStartedAt: 1_000, + paneKey: 'tab-unknown:1', + terminalTitle: 'Claude', + stateHistory: [], + agentType: 'claude' + } + }, + retainedAgentsByPaneKey: {}, + tabsByWorktree: { [worktree.id]: [tab] }, + worktreeMap: new Map([[worktree.id, worktree]]), + repoMap: new Map(), + acknowledgedAgentsByPaneKey: {}, + now: 1_000 + }) + const threads = buildAgentPaneThreads({ events, liveAgentByPaneKey }) + const group = getActivityThreadGroup(threads[0], 'project') + + expect(group).toEqual({ key: 'project:unknown', label: 'Unknown project' }) + }) + + it('worktree and agent grouping use expected keys and labels', () => { + const result = makeActivityResult({ + entries: { + 'tab-1:1': makeWorkingEntryWithoutHistory() + } + }) + const threads = makeThreads(result) + + expect(getActivityThreadGroup(threads[0], 'worktree')).toEqual({ + key: 'worktree:wt-1', + label: 'feature' + }) + expect(getActivityThreadGroup(threads[0], 'agent')).toEqual({ + key: 'agent:claude', + label: formatAgentTypeLabel('claude') + }) + }) + + it('keeps first-appearance group order and preserves intra-group thread order', () => { + const repo = makeRepo() + const wtA = makeWorktreeWithId('wt-a', repo.id, 'alpha') + const wtB = makeWorktreeWithId('wt-b', repo.id, 'beta') + const tabA1 = makeTabWithIds('tab-a1', wtA.id) + const tabB1 = makeTabWithIds('tab-b1', wtB.id) + const tabA2 = makeTabWithIds('tab-a2', wtA.id) + const { events, liveAgentByPaneKey } = buildActivityEvents({ + agentStatusByPaneKey: { + 'tab-a1:1': { + state: 'done', + prompt: 'A1', + updatedAt: 3_000, + stateStartedAt: 3_000, + paneKey: 'tab-a1:1', + terminalTitle: 'Claude', + stateHistory: [], + agentType: 'claude' + }, + 'tab-b1:1': { + state: 'done', + prompt: 'B1', + updatedAt: 2_000, + stateStartedAt: 2_000, + paneKey: 'tab-b1:1', + terminalTitle: 'Claude', + stateHistory: [], + agentType: 'claude' + }, + 'tab-a2:1': { + state: 'done', + prompt: 'A2', + updatedAt: 1_000, + stateStartedAt: 1_000, + paneKey: 'tab-a2:1', + terminalTitle: 'Claude', + stateHistory: [], + agentType: 'claude' + } + }, + retainedAgentsByPaneKey: {}, + tabsByWorktree: { [wtA.id]: [tabA1, tabA2], [wtB.id]: [tabB1] }, + worktreeMap: new Map([ + [wtA.id, wtA], + [wtB.id, wtB] + ]), + repoMap: new Map([[repo.id, repo]]), + acknowledgedAgentsByPaneKey: {}, + now: 3_000 + }) + const threads = buildAgentPaneThreads({ events, liveAgentByPaneKey }) + const groups = buildActivityThreadGroups(threads, 'worktree') + + expect(groups.map((group) => group.key)).toEqual(['worktree:wt-a', 'worktree:wt-b']) + expect(groups[0].threads.map((thread) => thread.paneKey)).toEqual(['tab-a1:1', 'tab-a2:1']) + expect(groups[1].threads.map((thread) => thread.paneKey)).toEqual(['tab-b1:1']) + }) + + it('returns no groups for empty thread input', () => { + expect(buildActivityThreadGroups([], 'status')).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.tsx b/src/renderer/src/components/activity/ActivityPrototypePage.tsx index c49b01980c4..001fb45bff9 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.tsx +++ b/src/renderer/src/components/activity/ActivityPrototypePage.tsx @@ -7,7 +7,6 @@ import { Bell, BellDot, ExternalLink, - ListTree, MessageSquareText, MoreVertical, Search, @@ -37,6 +36,13 @@ import { DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { Input } from '@/components/ui/input' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select' import { Toggle } from '@/components/ui/toggle' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' @@ -56,7 +62,7 @@ import { } from '../../../../shared/agent-status-types' type ThreadReadFilter = 'all' | 'unread' -type ActivityGroupMode = 'recent' | 'status' +type ActivityGroupBy = 'status' | 'project' | 'worktree' | 'agent' type ActivityEventState = Extract type ActivityLiveAgentState = Extract type ActivityStatusGroupId = 'working' | 'blocked' | 'waiting' | 'done' | 'interrupted' @@ -104,9 +110,10 @@ type AgentPaneThread = { } type ActivityThreadGroup = { - id: ActivityStatusGroupId + key: string + id?: ActivityStatusGroupId label: string - state: AgentStatusState + state?: AgentStatusState threads: AgentPaneThread[] } @@ -716,6 +723,47 @@ function threadAgentStateLabel(thread: AgentPaneThread): string { return agentStateLabel(state) } +export function getActivityThreadGroup( + thread: AgentPaneThread, + groupBy: ActivityGroupBy +): { key: string; label: string } { + if (groupBy === 'status') { + const state = threadAgentState(thread) + if (!thread.currentAgentState && state === 'done' && thread.latestEvent?.entry.interrupted) { + return { key: 'done:interrupted', label: threadAgentStateLabel(thread) } + } + return { key: state, label: threadAgentStateLabel(thread) } + } + if (groupBy === 'project') { + return thread.repo + ? { key: `project:${thread.repo.id}`, label: thread.repo.displayName } + : { key: 'project:unknown', label: 'Unknown project' } + } + if (groupBy === 'worktree') { + return { key: `worktree:${thread.worktree.id}`, label: thread.worktree.displayName } + } + return { key: `agent:${thread.agentType}`, label: formatAgentTypeLabel(thread.agentType) } +} + +export function buildActivityThreadGroups( + threads: AgentPaneThread[], + groupBy: ActivityGroupBy +): ActivityThreadGroup[] { + const groups: ActivityThreadGroup[] = [] + const groupIndexByKey = new Map() + for (const thread of threads) { + const group = getActivityThreadGroup(thread, groupBy) + const existingIndex = groupIndexByKey.get(group.key) + if (existingIndex === undefined) { + groups.push({ key: group.key, label: group.label, threads: [thread] }) + groupIndexByKey.set(group.key, groups.length - 1) + continue + } + groups[existingIndex].threads.push(thread) + } + return groups +} + function threadStatusGroupId(thread: AgentPaneThread): ActivityStatusGroupId { const state = threadAgentState(thread) if (!thread.currentAgentState && state === 'done' && thread.latestEvent?.entry.interrupted) { @@ -748,6 +796,7 @@ export function groupActivityThreadsByStatus(threads: AgentPaneThread[]): Activi } return [ { + key: id, id, label: threadStatusGroupLabel(id), state: threadStatusGroupState(id), @@ -799,9 +848,11 @@ function ThreadAgentStateIndicator({ thread }: { thread: AgentPaneThread }): Rea function ActivityStatusGroupHeader({ group }: { group: ActivityThreadGroup }): React.JSX.Element { return (
- - - + {group.state ? ( + + + + ) : null} {group.label} @@ -1008,7 +1059,7 @@ function ThreadRow({ export default function ActivityPrototypePage(): React.JSX.Element { const [readFilter, setReadFilter] = useState('all') - const [groupMode, setGroupMode] = useState('recent') + const [groupBy, setGroupBy] = useState('status') const [query, setQuery] = useState('') const [compactMode, setCompactMode] = useState(false) const [selectedPaneKey, setSelectedPaneKey] = useState(null) @@ -1090,8 +1141,8 @@ export default function ActivityPrototypePage(): React.JSX.Element { }) }, [allThreads, readFilter, query, selectedPaneKey]) const visibleThreadGroups = useMemo( - () => groupActivityThreadsByStatus(visibleThreads), - [visibleThreads] + () => buildActivityThreadGroups(visibleThreads, groupBy), + [visibleThreads, groupBy] ) useEffect(() => { @@ -1322,6 +1373,24 @@ export default function ActivityPrototypePage(): React.JSX.Element { className="h-8 w-full pl-7 text-xs" />
+ Show unread threads only - - - setGroupMode(pressed ? 'status' : 'recent')} - variant="outline" - size="sm" - className={cn( - 'size-8 shrink-0 p-0', - groupMode === 'status' - ? '!border-primary !bg-primary !text-primary-foreground shadow-xs ring-2 ring-primary/35 hover:!bg-primary/90 hover:!text-primary-foreground' - : 'text-muted-foreground hover:text-foreground' - )} - aria-label="Group activity by status" - > - - - - Group by status - {/* Why (overflow menu): "Mark all read" is a low-frequency, destructive-feeling action — parking it behind a `…` keeps the toolbar focused on the high-frequency Filter + unread @@ -1404,24 +1453,10 @@ export default function ActivityPrototypePage(): React.JSX.Element {
- {groupMode === 'status' - ? visibleThreadGroups.map((group) => ( -
- - {group.threads.map((thread) => ( - selectThread(thread)} - onJump={() => jumpToWorkspace(thread)} - onMarkUnread={() => markThreadUnread(thread)} - compactMode={compactMode} - /> - ))} -
- )) - : visibleThreads.map((thread) => ( + {visibleThreadGroups.map((group) => ( +
+ + {group.threads.map((thread) => ( ))} +
+ ))} {visibleThreads.length === 0 ? (
No agent activity matches these filters.