Merge branch 'copilot/precious-seahorse'

This commit is contained in:
David Kaya
2026-04-07 08:49:30 +02:00
7 changed files with 366 additions and 41 deletions
+26
View File
@@ -25,6 +25,7 @@ import {
pruneSessionUsage,
pruneSessionRequestUsage,
pruneTurnEventLogs,
purgeCompletedActivity,
type SessionActivityMap,
type SessionUsageMap,
type SessionRequestUsageMap,
@@ -149,6 +150,7 @@ export default function App() {
const [sessionRequestUsage, setSessionRequestUsage] = useState<SessionRequestUsageMap>({});
const [turnEventLogs, setTurnEventLogs] = useState<TurnEventLogMap>({});
const [activeSubagents, setActiveSubagents] = useState<ActiveSubagentMap>({});
const activityPurgeTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
const [showSettings, setShowSettings] = useState(false);
const [settingsSection, setSettingsSection] = useState<SettingsSection>();
@@ -226,12 +228,35 @@ export default function App() {
setSessionRequestUsage((current) => applyAssistantUsageEvent(current, event));
setTurnEventLogs((current) => applyTurnEventLog(current, event));
setActiveSubagents((current) => applySubagentEvent(current, event));
// Schedule purge of completed activity labels after grace period
if (event.kind === 'status' && event.status === 'idle') {
const existing = activityPurgeTimers.current.get(event.sessionId);
if (existing) clearTimeout(existing);
activityPurgeTimers.current.set(
event.sessionId,
setTimeout(() => {
setSessionActivities((current) => purgeCompletedActivity(current, event.sessionId));
activityPurgeTimers.current.delete(event.sessionId);
}, 1500),
);
}
// Cancel pending purge if a new run starts
if (event.kind === 'status' && event.status === 'running') {
const existing = activityPurgeTimers.current.get(event.sessionId);
if (existing) {
clearTimeout(existing);
activityPurgeTimers.current.delete(event.sessionId);
}
}
});
return () => {
disposed = true;
offWorkspace();
offSessionEvent();
for (const timer of activityPurgeTimers.current.values()) clearTimeout(timer);
activityPurgeTimers.current.clear();
};
}, [api]);
@@ -705,6 +730,7 @@ export default function App() {
runtimeTools={sidecarCapabilities?.runtimeTools}
session={selectedSession}
sessionUsage={usageForSession}
sessionActivity={activityForSession}
activeSubagents={subagentsForSession}
terminalOpen={bottomPanelOpen && bottomPanelTab === 'terminal'}
terminalRunning={terminalRunning}
+63 -16
View File
@@ -19,7 +19,8 @@ import type { ApprovalDecision } from '@shared/domain/approval';
import type { InteractionMode, MessageMode } from '@shared/contracts/sidecar';
import type { ChatMessageAttachment } from '@shared/domain/attachment';
import { getAttachmentDisplayName, isImageAttachment } from '@shared/domain/attachment';
import type { SessionUsageState } from '@renderer/lib/sessionActivity';
import type { SessionUsageState, SessionActivityState } from '@renderer/lib/sessionActivity';
import { summarizeSessionActivity } from '@renderer/lib/sessionActivity';
import type { ActiveSubagent } from '@renderer/lib/subagentTracker';
import {
findModel,
@@ -55,6 +56,7 @@ interface ChatPaneProps {
mcpProbingServerIds?: string[];
runtimeTools?: ReadonlyArray<RuntimeToolDefinition>;
sessionUsage?: SessionUsageState;
sessionActivity?: SessionActivityState;
activeSubagents?: ReadonlyArray<ActiveSubagent>;
terminalOpen?: boolean;
terminalRunning?: boolean;
@@ -92,6 +94,7 @@ export function ChatPane({
mcpProbingServerIds,
runtimeTools,
sessionUsage,
sessionActivity,
activeSubagents,
terminalOpen,
terminalRunning,
@@ -133,20 +136,44 @@ export function ChatPane({
const items: DisplayItem[] = [];
let pendingThinking: ChatMessageRecord[] = [];
let lastUserMessageId: string | undefined;
const messages = session.messages;
const busy = session.status === 'running';
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
const isLast = i === messages.length - 1;
for (const message of session.messages) {
if (message.messageKind === 'thinking') {
pendingThinking.push(message);
} else {
if (pendingThinking.length > 0) {
const run = lastUserMessageId ? runsByTrigger.get(lastUserMessageId) : undefined;
items.push({ type: 'thinking-group', messages: pendingThinking, turnStartedAt: run?.startedAt });
pendingThinking = [];
}
items.push({ type: 'message', message });
if (message.role === 'user') {
lastUserMessageId = message.id;
}
continue;
}
// Optimistic thinking classification: fold a pending assistant
// message into the current thinking group when it immediately follows
// thinking messages during an active turn. This prevents the brief
// flash of content that occurs before a message-reclassified event
// arrives. Only the very last message qualifies — earlier messages
// have already been classified definitively.
if (
busy
&& isLast
&& pendingThinking.length > 0
&& message.role === 'assistant'
&& message.pending
&& !message.messageKind
) {
pendingThinking.push(message);
continue;
}
if (pendingThinking.length > 0) {
const run = lastUserMessageId ? runsByTrigger.get(lastUserMessageId) : undefined;
items.push({ type: 'thinking-group', messages: pendingThinking, turnStartedAt: run?.startedAt });
pendingThinking = [];
}
items.push({ type: 'message', message });
if (message.role === 'user') {
lastUserMessageId = message.id;
}
}
@@ -156,7 +183,7 @@ export function ChatPane({
}
return items;
}, [session.messages, session.runs]);
}, [session.messages, session.runs, session.status]);
const lastThinkingGroupIndex = useMemo(() => {
for (let i = displayItems.length - 1; i >= 0; i--) {
@@ -165,6 +192,19 @@ export function ChatPane({
return -1;
}, [displayItems]);
// A thinking group is active when the session is running AND the group
// contains at least one pending message (currently streaming). This is
// more robust than relying purely on array position, which can be stale
// during reclassification gaps.
const isThinkingGroupActive = useCallback(
(group: { messages: ChatMessageRecord[] }, groupIndex: number): boolean => {
if (!isSessionBusy) return false;
if (groupIndex !== lastThinkingGroupIndex) return false;
return group.messages.some((m) => m.pending);
},
[isSessionBusy, lastThinkingGroupIndex],
);
const lastAssistantId = useMemo(() => {
for (let i = session.messages.length - 1; i >= 0; i--) {
const m = session.messages[i];
@@ -423,7 +463,15 @@ export function ChatPane({
Awaiting your input
</div>
)}
{isSessionBusy && !pendingApproval && !pendingUserInput && <span className="size-2 animate-pulse rounded-full bg-[var(--color-accent-sky)]" />}
{isSessionBusy && !pendingApproval && !pendingUserInput && (() => {
const label = summarizeSessionActivity(sessionActivity);
return (
<div className="flex items-center gap-1.5 text-[12px] text-[var(--color-accent-sky)]">
<span className="size-2 animate-pulse rounded-full bg-[var(--color-accent-sky)]" />
<span className="transition-opacity duration-200">{label ?? 'Working…'}</span>
</div>
);
})()}
{session.status === 'error' && (
<div className="flex items-center gap-1.5 text-[12px] text-[var(--color-status-error)]">
<AlertCircle className="size-3.5" />
@@ -471,12 +519,11 @@ export function ChatPane({
<div className="space-y-1">
{displayItems.map((item, itemIndex) => {
if (item.type === 'thinking-group') {
const isLastThinkingGroup = itemIndex === lastThinkingGroupIndex;
return (
<div key={`thinking-${item.messages[0].id}`} className="py-2">
<ThinkingProcess
messages={item.messages}
isActive={isSessionBusy && isLastThinkingGroup}
isActive={isThinkingGroupActive(item, itemIndex)}
turnStartedAt={item.turnStartedAt}
/>
</div>
@@ -1,8 +1,10 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Bot, CheckCircle2, Loader2, XCircle } from 'lucide-react';
import type { ActiveSubagent } from '@renderer/lib/subagentTracker';
const COMPLETION_GRACE_MS = 3000;
function formatElapsed(startedAt: string): string {
const seconds = Math.floor((Date.now() - new Date(startedAt).getTime()) / 1000);
if (seconds < 60) return `${seconds}s`;
@@ -37,9 +39,10 @@ function ElapsedTimer({ startedAt }: { startedAt: string }) {
interface SubagentActivityCardProps {
subagent: ActiveSubagent;
fading?: boolean;
}
function SubagentActivityCard({ subagent }: SubagentActivityCardProps) {
function SubagentActivityCard({ subagent, fading }: SubagentActivityCardProps) {
const borderClass =
subagent.status === 'running'
? 'border-[var(--color-accent-sky)]/20'
@@ -49,7 +52,7 @@ function SubagentActivityCard({ subagent }: SubagentActivityCardProps) {
return (
<div
className={`flex items-center gap-2 rounded-lg border bg-[var(--color-glass)] px-3 py-1.5 transition-all duration-200 ${borderClass}`}
className={`flex items-center gap-2 rounded-lg border bg-[var(--color-glass)] px-3 py-1.5 transition-all duration-300 ${borderClass} ${fading ? 'opacity-0' : 'opacity-100'}`}
role="status"
aria-label={`Sub-agent ${subagent.name}: ${subagent.activityLabel}`}
>
@@ -73,16 +76,68 @@ interface SubagentActivityListProps {
}
export function SubagentActivityList({ subagents }: SubagentActivityListProps) {
// Track recently-completed subagent IDs so we can show them briefly
const [recentlyDone, setRecentlyDone] = useState<Set<string>>(new Set());
const [fading, setFading] = useState<Set<string>>(new Set());
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
useEffect(() => {
for (const sub of subagents) {
if (sub.status !== 'running' && !recentlyDone.has(sub.toolCallId) && !timersRef.current.has(sub.toolCallId)) {
// Newly completed — track it
setRecentlyDone((prev) => new Set(prev).add(sub.toolCallId));
const fadeTimer = setTimeout(() => {
setFading((prev) => new Set(prev).add(sub.toolCallId));
}, COMPLETION_GRACE_MS - 300);
const removeTimer = setTimeout(() => {
setRecentlyDone((prev) => {
const next = new Set(prev);
next.delete(sub.toolCallId);
return next;
});
setFading((prev) => {
const next = new Set(prev);
next.delete(sub.toolCallId);
return next;
});
timersRef.current.delete(sub.toolCallId);
}, COMPLETION_GRACE_MS);
timersRef.current.set(sub.toolCallId, removeTimer);
// Store fade timer for cleanup
timersRef.current.set(`fade-${sub.toolCallId}`, fadeTimer);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [subagents]);
// Cleanup timers on unmount
useEffect(() => {
const timers = timersRef.current;
return () => {
for (const timer of timers.values()) clearTimeout(timer);
timers.clear();
};
}, []);
if (subagents.length === 0) return null;
// Only show running subagents in the chat stream
const visible = subagents.filter((s) => s.status === 'running');
// Show running subagents + recently-completed ones within the grace period
const visible = subagents.filter(
(s) => s.status === 'running' || recentlyDone.has(s.toolCallId),
);
if (visible.length === 0) return null;
return (
<div className="flex flex-col gap-1 py-1" aria-label="Active sub-agents">
{visible.map((subagent) => (
<SubagentActivityCard key={subagent.toolCallId} subagent={subagent} />
<SubagentActivityCard
key={subagent.toolCallId}
subagent={subagent}
fading={fading.has(subagent.toolCallId)}
/>
))}
</div>
);
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Brain, ChevronDown, ChevronRight } from 'lucide-react';
import { useElapsedTimer } from '@renderer/hooks/useElapsedTimer';
import type { ChatMessageRecord } from '@shared/domain/session';
interface ThinkingProcessProps {
@@ -26,24 +27,22 @@ export function ThinkingProcess({ messages, isActive, turnStartedAt }: ThinkingP
const toggle = useCallback(() => setExpanded((prev) => !prev), []);
const elapsed = useMemo(() => {
if (!turnStartedAt || messages.length === 0) return undefined;
const start = new Date(turnStartedAt).getTime();
const lastMessage = messages[messages.length - 1];
const end = isActive ? Date.now() : new Date(lastMessage.createdAt).getTime();
const seconds = Math.max(0, Math.round((end - start) / 1000));
if (seconds < 2) return undefined;
return seconds >= 60 ? `${Math.floor(seconds / 60)}m ${seconds % 60}s` : `${seconds}s`;
}, [turnStartedAt, messages, isActive]);
const elapsed = useElapsedTimer(
messages.length > 0 ? turnStartedAt : undefined,
isActive,
);
if (messages.length === 0) {
return null;
}
const stepCount = messages.length;
// Don't count empty pending messages (optimistically folded in) toward step count
const stepCount = messages.filter((m) => m.content).length;
const summaryParts: string[] = [];
if (elapsed) summaryParts.push(`${elapsed}`);
summaryParts.push(`${stepCount} ${stepCount === 1 ? 'step' : 'steps'}`);
if (stepCount > 0) {
summaryParts.push(`${stepCount} ${stepCount === 1 ? 'step' : 'steps'}`);
}
return (
<div className="thinking-process-enter mb-2 overflow-hidden rounded-lg border border-[var(--color-border)]/50 bg-[var(--color-surface-1)]/60">
@@ -87,6 +86,11 @@ export function ThinkingProcess({ messages, isActive, turnStartedAt }: ThinkingP
function ThinkingStep({ message }: { message: ChatMessageRecord }) {
const preview = useMemo(() => truncatePreview(message.content, 180), [message.content]);
// Pending message folded into thinking group with no content yet
if (message.pending && !message.content) {
return null;
}
return (
<div className="flex gap-2 text-[12px] leading-relaxed">
<span className="mt-0.5 shrink-0 text-[var(--color-text-muted)]"></span>
+39
View File
@@ -0,0 +1,39 @@
import { useEffect, useState } from 'react';
function formatElapsed(startMs: number): string {
const seconds = Math.max(0, Math.floor((Date.now() - startMs) / 1000));
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const remainder = seconds % 60;
return `${minutes}m ${remainder}s`;
}
/**
* Returns a live-ticking elapsed-time string while `active` is true.
* Freezes the display when `active` becomes false.
*/
export function useElapsedTimer(startedAt: string | undefined, active: boolean): string | undefined {
const startMs = startedAt ? new Date(startedAt).getTime() : undefined;
const [elapsed, setElapsed] = useState<string | undefined>(() => {
if (!startMs) return undefined;
return formatElapsed(startMs);
});
useEffect(() => {
if (!startMs) {
setElapsed(undefined);
return;
}
// Always sync to current value immediately
setElapsed(formatElapsed(startMs));
if (!active) return;
const id = setInterval(() => setElapsed(formatElapsed(startMs)), 1000);
return () => clearInterval(id);
}, [startMs, active]);
return elapsed;
}
+62 -8
View File
@@ -166,12 +166,16 @@ function clearActiveSessionActivity(
return current;
}
// Transition active entries to 'completed' instead of removing them.
// The UI layer uses a grace timer to eventually clear these.
let changed = false;
const nextSessionActivity = Object.fromEntries(
Object.entries(sessionActivity).filter(([, activity]) => {
const keep = !isAgentActivityActive(activity);
changed ||= !keep;
return keep;
Object.entries(sessionActivity).map(([key, activity]) => {
if (isAgentActivityActive(activity)) {
changed = true;
return [key, { ...activity, activityType: 'completed' as const }];
}
return [key, activity];
}),
);
@@ -179,10 +183,6 @@ function clearActiveSessionActivity(
return current;
}
if (Object.keys(nextSessionActivity).length === 0) {
return removeSessionActivity(current, sessionId);
}
return {
...current,
[sessionId]: nextSessionActivity,
@@ -466,6 +466,60 @@ export function pruneSessionRequestUsage(
/* ── Formatting helpers ─────────────────────────────────────── */
/**
* Remove all completed activity entries for a session.
* Called after the UI grace period to finish clearing stale labels.
*/
export function purgeCompletedActivity(
current: SessionActivityMap,
sessionId: string,
): SessionActivityMap {
const sessionActivity = current[sessionId];
if (!sessionActivity) return current;
const nextSessionActivity = Object.fromEntries(
Object.entries(sessionActivity).filter(([, a]) => !isAgentActivityCompleted(a)),
);
if (Object.keys(nextSessionActivity).length === Object.keys(sessionActivity).length) {
return current;
}
if (Object.keys(nextSessionActivity).length === 0) {
return removeSessionActivity(current, sessionId);
}
return { ...current, [sessionId]: nextSessionActivity };
}
/**
* Returns a single summary label for the most relevant activity in a session.
* Prefers active states (thinking → tool-calling → handoff) over completed.
*/
export function summarizeSessionActivity(
sessionActivity: SessionActivityState | undefined,
): string | undefined {
if (!sessionActivity) return undefined;
const entries = Object.values(sessionActivity);
if (entries.length === 0) return undefined;
// Prefer the first active entry by priority
const active = entries.find((a) => a.activityType === 'thinking')
?? entries.find((a) => a.activityType === 'tool-calling')
?? entries.find((a) => a.activityType === 'handoff');
if (active) {
return formatAgentActivityLabel(active);
}
// If everything is completed, show that
const completed = entries.find((a) => a.activityType === 'completed');
if (completed) return 'Completed';
return undefined;
}
export function formatTokenCount(tokens: number): string {
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}k`;
+100
View File
@@ -13,6 +13,8 @@ import {
isAgentActivityCompleted,
pruneSessionActivities,
pruneSessionRequestUsage,
purgeCompletedActivity,
summarizeSessionActivity,
type SessionActivityMap,
type SessionRequestUsageMap,
} from '@renderer/lib/sessionActivity';
@@ -209,6 +211,7 @@ describe('session activity helpers', () => {
},
};
// Active agents transition to 'completed' on idle (grace period before purge)
expect(
applySessionEventActivity(current, {
sessionId: 'session-1',
@@ -223,6 +226,11 @@ describe('session activity helpers', () => {
agentName: 'Architect',
activityType: 'completed',
},
reviewer: {
agentId: 'reviewer',
agentName: 'Reviewer',
activityType: 'completed',
},
},
});
});
@@ -243,6 +251,7 @@ describe('session activity helpers', () => {
},
};
// Active agents transition to 'completed' on cancel (grace period before purge)
expect(
applySessionEventActivity(current, {
sessionId: 'session-1',
@@ -272,6 +281,11 @@ describe('session activity helpers', () => {
agentName: 'Architect',
activityType: 'completed',
},
reviewer: {
agentId: 'reviewer',
agentName: 'Reviewer',
activityType: 'completed',
},
},
});
});
@@ -392,6 +406,92 @@ describe('session activity helpers', () => {
'session-2': current['session-2'],
});
});
test('purgeCompletedActivity removes completed entries after grace period', () => {
const current: SessionActivityMap = {
'session-1': {
architect: {
agentId: 'architect',
agentName: 'Architect',
activityType: 'completed',
},
reviewer: {
agentId: 'reviewer',
agentName: 'Reviewer',
activityType: 'completed',
},
},
};
const result = purgeCompletedActivity(current, 'session-1');
expect(result['session-1']).toBeUndefined();
});
test('purgeCompletedActivity is a no-op when nothing is completed', () => {
const current: SessionActivityMap = {
'session-1': {
architect: {
agentId: 'architect',
agentName: 'Architect',
activityType: 'thinking',
},
},
};
expect(purgeCompletedActivity(current, 'session-1')).toBe(current);
});
test('summarizeSessionActivity returns the most relevant label', () => {
expect(summarizeSessionActivity(undefined)).toBeUndefined();
expect(summarizeSessionActivity({})).toBeUndefined();
expect(
summarizeSessionActivity({
architect: {
agentId: 'architect',
agentName: 'Architect',
activityType: 'thinking',
},
}),
).toBe('Thinking…');
expect(
summarizeSessionActivity({
architect: {
agentId: 'architect',
agentName: 'Architect',
activityType: 'tool-calling',
toolName: 'bash',
},
}),
).toBe('Using bash…');
// Thinking takes priority over tool-calling
expect(
summarizeSessionActivity({
architect: {
agentId: 'architect',
agentName: 'Architect',
activityType: 'thinking',
},
reviewer: {
agentId: 'reviewer',
agentName: 'Reviewer',
activityType: 'tool-calling',
toolName: 'read_file',
},
}),
).toBe('Thinking…');
expect(
summarizeSessionActivity({
architect: {
agentId: 'architect',
agentName: 'Architect',
activityType: 'completed',
},
}),
).toBe('Completed');
});
});
describe('assistant usage accumulator', () => {