Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
758 changes: 758 additions & 0 deletions docs/superpowers/specs/2026-07-23-agent-activity-legibility-design.md

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion packages/ui/src/components/ChatSubagentEntry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,9 @@ export function ChatSubagentEntry({
return <XCircleIcon className="size-icon-xs text-error" weight="fill" />;
}
if (isPending) {
return <CircleNotchIcon className="size-icon-xs text-low animate-spin" />;
return (
<CircleNotchIcon className="size-icon-xs text-low animate-spin motion-reduce:animate-none" />
);
}
return null;
}, [status]);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';

import { useEntries } from '../contexts/EntriesContext';
import {
partitionStrip,
selectSubagents,
type StripPartition,
type SubagentDescriptor,
} from '../subagent-strip-model';

export const MAX_ACTIVE_TABS = 4;
export const FINISHED_LINGER_MS = 5_000;

export type UseSubagentStripResult = StripPartition & {
hasAny: boolean;
};

export function useSubagentStrip(): UseSubagentStripResult {
const { entries } = useEntries();
const descriptors = useMemo(() => selectSubagents(entries), [entries]);
const phasesByKeyRef = useRef<Record<string, SubagentDescriptor['phase']>>(
{}
);
const doneAtByKeyRef = useRef<Record<string, number>>({});
const [doneAtByKey, setDoneAtByKey] = useState<Record<string, number>>({});
const [now, setNow] = useState(() => Date.now());

useLayoutEffect(() => {
const currentKeys = new Set(
descriptors.map((descriptor) => descriptor.key)
);
const previousDoneAtByKey = doneAtByKeyRef.current;
let nextDoneAtByKey = previousDoneAtByKey;
let hasDoneAtChanges = false;

const makeDoneAtMutable = () => {
if (!hasDoneAtChanges) {
nextDoneAtByKey = { ...previousDoneAtByKey };
hasDoneAtChanges = true;
}
};

for (const key of Object.keys(previousDoneAtByKey)) {
if (!currentKeys.has(key)) {
makeDoneAtMutable();
delete nextDoneAtByKey[key];
}
}

const nextPhasesByKey: Record<string, SubagentDescriptor['phase']> = {};
let transitionTime: number | null = null;

for (const descriptor of descriptors) {
const previousPhase = phasesByKeyRef.current[descriptor.key];
nextPhasesByKey[descriptor.key] = descriptor.phase;

if (
descriptor.phase !== 'active' &&
previousPhase === 'active' &&
nextDoneAtByKey[descriptor.key] === undefined
) {
transitionTime ??= Date.now();
makeDoneAtMutable();
nextDoneAtByKey[descriptor.key] = transitionTime;
}
}

phasesByKeyRef.current = nextPhasesByKey;

if (hasDoneAtChanges) {
doneAtByKeyRef.current = nextDoneAtByKey;
setDoneAtByKey(nextDoneAtByKey);
}
if (transitionTime !== null) {
setNow(transitionTime);
}
}, [descriptors]);

useEffect(() => {
let soonestExpiry: number | null = null;

for (const descriptor of descriptors) {
if (descriptor.phase === 'active') continue;

const doneAt = doneAtByKey[descriptor.key];
if (doneAt === undefined || now - doneAt >= FINISHED_LINGER_MS) {
continue;
}

const expiry = doneAt + FINISHED_LINGER_MS;
soonestExpiry =
soonestExpiry === null ? expiry : Math.min(soonestExpiry, expiry);
}

if (soonestExpiry === null) return;

let cancelled = false;
const timeoutId = setTimeout(
() => {
if (!cancelled) {
setNow(Date.now());
}
},
Math.max(0, soonestExpiry - Date.now())
);

return () => {
cancelled = true;
clearTimeout(timeoutId);
};
}, [descriptors, doneAtByKey, now]);

const partition = useMemo(
() =>
partitionStrip({
descriptors,
doneAtByKey,
now,
maxActiveTabs: MAX_ACTIVE_TABS,
lingerMs: FINISHED_LINGER_MS,
}),
[descriptors, doneAtByKey, now]
);

return {
...partition,
hasAny: descriptors.length > 0,
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
// @vitest-environment node

import { describe, expect, it } from 'vitest';
import type { ToolResult, ToolStatus } from 'shared/types';

import type { PatchTypeWithKey } from '@/shared/hooks/useConversationHistory/types';
import { selectSubagents } from './subagent-strip-model';

const markdownResult: ToolResult = {
type: { type: 'markdown' },
value: 'Finished result',
};

function taskCreateEntry({
key,
status,
name = 'researcher',
description = `Task ${key}`,
result = null,
}: {
key: string;
status: ToolStatus;
name?: string | null;
description?: string;
result?: ToolResult | null;
}): PatchTypeWithKey {
return {
type: 'NORMALIZED_ENTRY',
content: {
timestamp: null,
content: '',
entry_type: {
type: 'tool_use',
tool_name: 'Task',
action_type: {
action: 'task_create',
description,
subagent_type: name,
result,
},
status,
},
},
patchKey: key,
executionProcessId: 'process-1',
};
}

describe('selectSubagents', () => {
it('returns an empty projection for no entries', () => {
expect(selectSubagents([])).toEqual([]);
});

it('projects a single active task_create entry', () => {
const status: ToolStatus = { status: 'created' };

expect(
selectSubagents([
taskCreateEntry({
key: 'task-1',
status,
description: 'Inspect the test suite',
}),
])
).toEqual([
{
key: 'task-1',
name: 'researcher',
description: 'Inspect the test suite',
phase: 'active',
result: null,
status,
},
]);
});

it('maps active, done, and error statuses in spawn order', () => {
const entries = [
taskCreateEntry({
key: 'active',
status: {
status: 'pending_approval',
approval_id: 'approval-1',
},
}),
taskCreateEntry({
key: 'done',
status: { status: 'success' },
result: markdownResult,
}),
taskCreateEntry({
key: 'error',
status: { status: 'timed_out' },
}),
];

expect(
selectSubagents(entries).map(({ key, phase }) => [key, phase])
).toEqual([
['active', 'active'],
['done', 'done'],
['error', 'error'],
]);
expect(selectSubagents(entries)[1].result).toBe(markdownResult);
});

it('preserves a null subagent type for the localized view fallback', () => {
const [descriptor] = selectSubagents([
taskCreateEntry({
key: 'unnamed',
name: null,
status: { status: 'success' },
}),
]);

expect(descriptor.name).toBeNull();
});

it('ignores entries that are not task_create tool uses', () => {
const otherTool: PatchTypeWithKey = {
type: 'NORMALIZED_ENTRY',
content: {
timestamp: null,
content: '',
entry_type: {
type: 'tool_use',
tool_name: 'Read',
action_type: { action: 'file_read', path: 'README.md' },
status: { status: 'success' },
},
},
patchKey: 'read-1',
executionProcessId: 'process-1',
};
const stdout: PatchTypeWithKey = {
type: 'STDOUT',
content: 'not a normalized entry',
patchKey: 'stdout-1',
executionProcessId: 'process-1',
};

expect(selectSubagents([otherTool, stdout])).toEqual([]);
});
});
Loading
Loading