mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
feat: group activity agent threads (#1815)
This commit is contained in:
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<AgentStatusState, 'done' | 'blocked' | 'waiting'>
|
||||
type ActivityLiveAgentState = Extract<AgentStatusState, 'working' | 'blocked' | 'waiting'>
|
||||
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<string, number>()
|
||||
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 (
|
||||
<div className="sticky top-0 z-10 flex items-center gap-2 border-b border-border bg-background/95 px-3 py-1.5 backdrop-blur supports-[backdrop-filter]:bg-background/80">
|
||||
<span className="inline-flex size-4 shrink-0 items-center justify-center">
|
||||
<AgentStateDot state={group.state} size="sm" />
|
||||
</span>
|
||||
{group.state ? (
|
||||
<span className="inline-flex size-4 shrink-0 items-center justify-center">
|
||||
<AgentStateDot state={group.state} size="sm" />
|
||||
</span>
|
||||
) : null}
|
||||
<span className="min-w-0 flex-1 truncate text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
|
||||
{group.label}
|
||||
</span>
|
||||
@@ -1008,7 +1059,7 @@ function ThreadRow({
|
||||
|
||||
export default function ActivityPrototypePage(): React.JSX.Element {
|
||||
const [readFilter, setReadFilter] = useState<ThreadReadFilter>('all')
|
||||
const [groupMode, setGroupMode] = useState<ActivityGroupMode>('recent')
|
||||
const [groupBy, setGroupBy] = useState<ActivityGroupBy>('status')
|
||||
const [query, setQuery] = useState('')
|
||||
const [compactMode, setCompactMode] = useState(false)
|
||||
const [selectedPaneKey, setSelectedPaneKey] = useState<string | null>(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"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={groupBy}
|
||||
onValueChange={(value) => setGroupBy(value as ActivityGroupBy)}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="h-8 w-[128px] shrink-0 px-2 text-xs"
|
||||
aria-label="Group agent activity by"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectItem value="status">Status</SelectItem>
|
||||
<SelectItem value="project">Project</SelectItem>
|
||||
<SelectItem value="worktree">Worktree</SelectItem>
|
||||
<SelectItem value="agent">Agent</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Toggle
|
||||
@@ -1342,26 +1411,6 @@ export default function ActivityPrototypePage(): React.JSX.Element {
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Show unread threads only</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Toggle
|
||||
pressed={groupMode === 'status'}
|
||||
onPressedChange={(pressed) => 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"
|
||||
>
|
||||
<ListTree className="size-3.5" />
|
||||
</Toggle>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Group by status</TooltipContent>
|
||||
</Tooltip>
|
||||
{/* 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 {
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto scrollbar-sleek">
|
||||
{groupMode === 'status'
|
||||
? visibleThreadGroups.map((group) => (
|
||||
<section key={group.id} aria-label={`${group.label} activity`}>
|
||||
<ActivityStatusGroupHeader group={group} />
|
||||
{group.threads.map((thread) => (
|
||||
<ThreadRow
|
||||
key={thread.paneKey}
|
||||
thread={thread}
|
||||
selected={thread.paneKey === selectedThread?.paneKey}
|
||||
onSelect={() => selectThread(thread)}
|
||||
onJump={() => jumpToWorkspace(thread)}
|
||||
onMarkUnread={() => markThreadUnread(thread)}
|
||||
compactMode={compactMode}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
))
|
||||
: visibleThreads.map((thread) => (
|
||||
{visibleThreadGroups.map((group) => (
|
||||
<section key={group.key} aria-label={`${group.label} activity`}>
|
||||
<ActivityStatusGroupHeader group={group} />
|
||||
{group.threads.map((thread) => (
|
||||
<ThreadRow
|
||||
key={thread.paneKey}
|
||||
thread={thread}
|
||||
@@ -1432,6 +1467,8 @@ export default function ActivityPrototypePage(): React.JSX.Element {
|
||||
compactMode={compactMode}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
{visibleThreads.length === 0 ? (
|
||||
<div className="px-3 py-8 text-sm text-muted-foreground">
|
||||
No agent activity matches these filters.
|
||||
|
||||
Reference in New Issue
Block a user