mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-09 13:18:46 +02:00
Merge branch 'copilot/precious-seahorse'
This commit is contained in:
@@ -25,6 +25,7 @@ import {
|
|||||||
pruneSessionUsage,
|
pruneSessionUsage,
|
||||||
pruneSessionRequestUsage,
|
pruneSessionRequestUsage,
|
||||||
pruneTurnEventLogs,
|
pruneTurnEventLogs,
|
||||||
|
purgeCompletedActivity,
|
||||||
type SessionActivityMap,
|
type SessionActivityMap,
|
||||||
type SessionUsageMap,
|
type SessionUsageMap,
|
||||||
type SessionRequestUsageMap,
|
type SessionRequestUsageMap,
|
||||||
@@ -149,6 +150,7 @@ export default function App() {
|
|||||||
const [sessionRequestUsage, setSessionRequestUsage] = useState<SessionRequestUsageMap>({});
|
const [sessionRequestUsage, setSessionRequestUsage] = useState<SessionRequestUsageMap>({});
|
||||||
const [turnEventLogs, setTurnEventLogs] = useState<TurnEventLogMap>({});
|
const [turnEventLogs, setTurnEventLogs] = useState<TurnEventLogMap>({});
|
||||||
const [activeSubagents, setActiveSubagents] = useState<ActiveSubagentMap>({});
|
const [activeSubagents, setActiveSubagents] = useState<ActiveSubagentMap>({});
|
||||||
|
const activityPurgeTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||||
|
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
const [settingsSection, setSettingsSection] = useState<SettingsSection>();
|
const [settingsSection, setSettingsSection] = useState<SettingsSection>();
|
||||||
@@ -226,12 +228,35 @@ export default function App() {
|
|||||||
setSessionRequestUsage((current) => applyAssistantUsageEvent(current, event));
|
setSessionRequestUsage((current) => applyAssistantUsageEvent(current, event));
|
||||||
setTurnEventLogs((current) => applyTurnEventLog(current, event));
|
setTurnEventLogs((current) => applyTurnEventLog(current, event));
|
||||||
setActiveSubagents((current) => applySubagentEvent(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 () => {
|
return () => {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
offWorkspace();
|
offWorkspace();
|
||||||
offSessionEvent();
|
offSessionEvent();
|
||||||
|
for (const timer of activityPurgeTimers.current.values()) clearTimeout(timer);
|
||||||
|
activityPurgeTimers.current.clear();
|
||||||
};
|
};
|
||||||
}, [api]);
|
}, [api]);
|
||||||
|
|
||||||
@@ -705,6 +730,7 @@ export default function App() {
|
|||||||
runtimeTools={sidecarCapabilities?.runtimeTools}
|
runtimeTools={sidecarCapabilities?.runtimeTools}
|
||||||
session={selectedSession}
|
session={selectedSession}
|
||||||
sessionUsage={usageForSession}
|
sessionUsage={usageForSession}
|
||||||
|
sessionActivity={activityForSession}
|
||||||
activeSubagents={subagentsForSession}
|
activeSubagents={subagentsForSession}
|
||||||
terminalOpen={bottomPanelOpen && bottomPanelTab === 'terminal'}
|
terminalOpen={bottomPanelOpen && bottomPanelTab === 'terminal'}
|
||||||
terminalRunning={terminalRunning}
|
terminalRunning={terminalRunning}
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ import type { ApprovalDecision } from '@shared/domain/approval';
|
|||||||
import type { InteractionMode, MessageMode } from '@shared/contracts/sidecar';
|
import type { InteractionMode, MessageMode } from '@shared/contracts/sidecar';
|
||||||
import type { ChatMessageAttachment } from '@shared/domain/attachment';
|
import type { ChatMessageAttachment } from '@shared/domain/attachment';
|
||||||
import { getAttachmentDisplayName, isImageAttachment } 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 type { ActiveSubagent } from '@renderer/lib/subagentTracker';
|
||||||
import {
|
import {
|
||||||
findModel,
|
findModel,
|
||||||
@@ -55,6 +56,7 @@ interface ChatPaneProps {
|
|||||||
mcpProbingServerIds?: string[];
|
mcpProbingServerIds?: string[];
|
||||||
runtimeTools?: ReadonlyArray<RuntimeToolDefinition>;
|
runtimeTools?: ReadonlyArray<RuntimeToolDefinition>;
|
||||||
sessionUsage?: SessionUsageState;
|
sessionUsage?: SessionUsageState;
|
||||||
|
sessionActivity?: SessionActivityState;
|
||||||
activeSubagents?: ReadonlyArray<ActiveSubagent>;
|
activeSubagents?: ReadonlyArray<ActiveSubagent>;
|
||||||
terminalOpen?: boolean;
|
terminalOpen?: boolean;
|
||||||
terminalRunning?: boolean;
|
terminalRunning?: boolean;
|
||||||
@@ -92,6 +94,7 @@ export function ChatPane({
|
|||||||
mcpProbingServerIds,
|
mcpProbingServerIds,
|
||||||
runtimeTools,
|
runtimeTools,
|
||||||
sessionUsage,
|
sessionUsage,
|
||||||
|
sessionActivity,
|
||||||
activeSubagents,
|
activeSubagents,
|
||||||
terminalOpen,
|
terminalOpen,
|
||||||
terminalRunning,
|
terminalRunning,
|
||||||
@@ -133,20 +136,44 @@ export function ChatPane({
|
|||||||
const items: DisplayItem[] = [];
|
const items: DisplayItem[] = [];
|
||||||
let pendingThinking: ChatMessageRecord[] = [];
|
let pendingThinking: ChatMessageRecord[] = [];
|
||||||
let lastUserMessageId: string | undefined;
|
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') {
|
if (message.messageKind === 'thinking') {
|
||||||
pendingThinking.push(message);
|
pendingThinking.push(message);
|
||||||
} else {
|
continue;
|
||||||
if (pendingThinking.length > 0) {
|
}
|
||||||
const run = lastUserMessageId ? runsByTrigger.get(lastUserMessageId) : undefined;
|
|
||||||
items.push({ type: 'thinking-group', messages: pendingThinking, turnStartedAt: run?.startedAt });
|
// Optimistic thinking classification: fold a pending assistant
|
||||||
pendingThinking = [];
|
// message into the current thinking group when it immediately follows
|
||||||
}
|
// thinking messages during an active turn. This prevents the brief
|
||||||
items.push({ type: 'message', message });
|
// flash of content that occurs before a message-reclassified event
|
||||||
if (message.role === 'user') {
|
// arrives. Only the very last message qualifies — earlier messages
|
||||||
lastUserMessageId = message.id;
|
// 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;
|
return items;
|
||||||
}, [session.messages, session.runs]);
|
}, [session.messages, session.runs, session.status]);
|
||||||
|
|
||||||
const lastThinkingGroupIndex = useMemo(() => {
|
const lastThinkingGroupIndex = useMemo(() => {
|
||||||
for (let i = displayItems.length - 1; i >= 0; i--) {
|
for (let i = displayItems.length - 1; i >= 0; i--) {
|
||||||
@@ -165,6 +192,19 @@ export function ChatPane({
|
|||||||
return -1;
|
return -1;
|
||||||
}, [displayItems]);
|
}, [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(() => {
|
const lastAssistantId = useMemo(() => {
|
||||||
for (let i = session.messages.length - 1; i >= 0; i--) {
|
for (let i = session.messages.length - 1; i >= 0; i--) {
|
||||||
const m = session.messages[i];
|
const m = session.messages[i];
|
||||||
@@ -423,7 +463,15 @@ export function ChatPane({
|
|||||||
Awaiting your input
|
Awaiting your input
|
||||||
</div>
|
</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' && (
|
{session.status === 'error' && (
|
||||||
<div className="flex items-center gap-1.5 text-[12px] text-[var(--color-status-error)]">
|
<div className="flex items-center gap-1.5 text-[12px] text-[var(--color-status-error)]">
|
||||||
<AlertCircle className="size-3.5" />
|
<AlertCircle className="size-3.5" />
|
||||||
@@ -471,12 +519,11 @@ export function ChatPane({
|
|||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{displayItems.map((item, itemIndex) => {
|
{displayItems.map((item, itemIndex) => {
|
||||||
if (item.type === 'thinking-group') {
|
if (item.type === 'thinking-group') {
|
||||||
const isLastThinkingGroup = itemIndex === lastThinkingGroupIndex;
|
|
||||||
return (
|
return (
|
||||||
<div key={`thinking-${item.messages[0].id}`} className="py-2">
|
<div key={`thinking-${item.messages[0].id}`} className="py-2">
|
||||||
<ThinkingProcess
|
<ThinkingProcess
|
||||||
messages={item.messages}
|
messages={item.messages}
|
||||||
isActive={isSessionBusy && isLastThinkingGroup}
|
isActive={isThinkingGroupActive(item, itemIndex)}
|
||||||
turnStartedAt={item.turnStartedAt}
|
turnStartedAt={item.turnStartedAt}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 { Bot, CheckCircle2, Loader2, XCircle } from 'lucide-react';
|
||||||
|
|
||||||
import type { ActiveSubagent } from '@renderer/lib/subagentTracker';
|
import type { ActiveSubagent } from '@renderer/lib/subagentTracker';
|
||||||
|
|
||||||
|
const COMPLETION_GRACE_MS = 3000;
|
||||||
|
|
||||||
function formatElapsed(startedAt: string): string {
|
function formatElapsed(startedAt: string): string {
|
||||||
const seconds = Math.floor((Date.now() - new Date(startedAt).getTime()) / 1000);
|
const seconds = Math.floor((Date.now() - new Date(startedAt).getTime()) / 1000);
|
||||||
if (seconds < 60) return `${seconds}s`;
|
if (seconds < 60) return `${seconds}s`;
|
||||||
@@ -37,9 +39,10 @@ function ElapsedTimer({ startedAt }: { startedAt: string }) {
|
|||||||
|
|
||||||
interface SubagentActivityCardProps {
|
interface SubagentActivityCardProps {
|
||||||
subagent: ActiveSubagent;
|
subagent: ActiveSubagent;
|
||||||
|
fading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SubagentActivityCard({ subagent }: SubagentActivityCardProps) {
|
function SubagentActivityCard({ subagent, fading }: SubagentActivityCardProps) {
|
||||||
const borderClass =
|
const borderClass =
|
||||||
subagent.status === 'running'
|
subagent.status === 'running'
|
||||||
? 'border-[var(--color-accent-sky)]/20'
|
? 'border-[var(--color-accent-sky)]/20'
|
||||||
@@ -49,7 +52,7 @@ function SubagentActivityCard({ subagent }: SubagentActivityCardProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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"
|
role="status"
|
||||||
aria-label={`Sub-agent ${subagent.name}: ${subagent.activityLabel}`}
|
aria-label={`Sub-agent ${subagent.name}: ${subagent.activityLabel}`}
|
||||||
>
|
>
|
||||||
@@ -73,16 +76,68 @@ interface SubagentActivityListProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SubagentActivityList({ subagents }: 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;
|
if (subagents.length === 0) return null;
|
||||||
|
|
||||||
// Only show running subagents in the chat stream
|
// Show running subagents + recently-completed ones within the grace period
|
||||||
const visible = subagents.filter((s) => s.status === 'running');
|
const visible = subagents.filter(
|
||||||
|
(s) => s.status === 'running' || recentlyDone.has(s.toolCallId),
|
||||||
|
);
|
||||||
if (visible.length === 0) return null;
|
if (visible.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1 py-1" aria-label="Active sub-agents">
|
<div className="flex flex-col gap-1 py-1" aria-label="Active sub-agents">
|
||||||
{visible.map((subagent) => (
|
{visible.map((subagent) => (
|
||||||
<SubagentActivityCard key={subagent.toolCallId} subagent={subagent} />
|
<SubagentActivityCard
|
||||||
|
key={subagent.toolCallId}
|
||||||
|
subagent={subagent}
|
||||||
|
fading={fading.has(subagent.toolCallId)}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Brain, ChevronDown, ChevronRight } from 'lucide-react';
|
import { Brain, ChevronDown, ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useElapsedTimer } from '@renderer/hooks/useElapsedTimer';
|
||||||
import type { ChatMessageRecord } from '@shared/domain/session';
|
import type { ChatMessageRecord } from '@shared/domain/session';
|
||||||
|
|
||||||
interface ThinkingProcessProps {
|
interface ThinkingProcessProps {
|
||||||
@@ -26,24 +27,22 @@ export function ThinkingProcess({ messages, isActive, turnStartedAt }: ThinkingP
|
|||||||
|
|
||||||
const toggle = useCallback(() => setExpanded((prev) => !prev), []);
|
const toggle = useCallback(() => setExpanded((prev) => !prev), []);
|
||||||
|
|
||||||
const elapsed = useMemo(() => {
|
const elapsed = useElapsedTimer(
|
||||||
if (!turnStartedAt || messages.length === 0) return undefined;
|
messages.length > 0 ? turnStartedAt : undefined,
|
||||||
const start = new Date(turnStartedAt).getTime();
|
isActive,
|
||||||
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]);
|
|
||||||
|
|
||||||
if (messages.length === 0) {
|
if (messages.length === 0) {
|
||||||
return null;
|
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[] = [];
|
const summaryParts: string[] = [];
|
||||||
if (elapsed) summaryParts.push(`${elapsed}`);
|
if (elapsed) summaryParts.push(`${elapsed}`);
|
||||||
summaryParts.push(`${stepCount} ${stepCount === 1 ? 'step' : 'steps'}`);
|
if (stepCount > 0) {
|
||||||
|
summaryParts.push(`${stepCount} ${stepCount === 1 ? 'step' : 'steps'}`);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="thinking-process-enter mb-2 overflow-hidden rounded-lg border border-[var(--color-border)]/50 bg-[var(--color-surface-1)]/60">
|
<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 }) {
|
function ThinkingStep({ message }: { message: ChatMessageRecord }) {
|
||||||
const preview = useMemo(() => truncatePreview(message.content, 180), [message.content]);
|
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 (
|
return (
|
||||||
<div className="flex gap-2 text-[12px] leading-relaxed">
|
<div className="flex gap-2 text-[12px] leading-relaxed">
|
||||||
<span className="mt-0.5 shrink-0 text-[var(--color-text-muted)]">▸</span>
|
<span className="mt-0.5 shrink-0 text-[var(--color-text-muted)]">▸</span>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -166,12 +166,16 @@ function clearActiveSessionActivity(
|
|||||||
return current;
|
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;
|
let changed = false;
|
||||||
const nextSessionActivity = Object.fromEntries(
|
const nextSessionActivity = Object.fromEntries(
|
||||||
Object.entries(sessionActivity).filter(([, activity]) => {
|
Object.entries(sessionActivity).map(([key, activity]) => {
|
||||||
const keep = !isAgentActivityActive(activity);
|
if (isAgentActivityActive(activity)) {
|
||||||
changed ||= !keep;
|
changed = true;
|
||||||
return keep;
|
return [key, { ...activity, activityType: 'completed' as const }];
|
||||||
|
}
|
||||||
|
return [key, activity];
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -179,10 +183,6 @@ function clearActiveSessionActivity(
|
|||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(nextSessionActivity).length === 0) {
|
|
||||||
return removeSessionActivity(current, sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...current,
|
...current,
|
||||||
[sessionId]: nextSessionActivity,
|
[sessionId]: nextSessionActivity,
|
||||||
@@ -466,6 +466,60 @@ export function pruneSessionRequestUsage(
|
|||||||
|
|
||||||
/* ── Formatting helpers ─────────────────────────────────────── */
|
/* ── 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 {
|
export function formatTokenCount(tokens: number): string {
|
||||||
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
|
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
|
||||||
if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}k`;
|
if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}k`;
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
isAgentActivityCompleted,
|
isAgentActivityCompleted,
|
||||||
pruneSessionActivities,
|
pruneSessionActivities,
|
||||||
pruneSessionRequestUsage,
|
pruneSessionRequestUsage,
|
||||||
|
purgeCompletedActivity,
|
||||||
|
summarizeSessionActivity,
|
||||||
type SessionActivityMap,
|
type SessionActivityMap,
|
||||||
type SessionRequestUsageMap,
|
type SessionRequestUsageMap,
|
||||||
} from '@renderer/lib/sessionActivity';
|
} from '@renderer/lib/sessionActivity';
|
||||||
@@ -209,6 +211,7 @@ describe('session activity helpers', () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Active agents transition to 'completed' on idle (grace period before purge)
|
||||||
expect(
|
expect(
|
||||||
applySessionEventActivity(current, {
|
applySessionEventActivity(current, {
|
||||||
sessionId: 'session-1',
|
sessionId: 'session-1',
|
||||||
@@ -223,6 +226,11 @@ describe('session activity helpers', () => {
|
|||||||
agentName: 'Architect',
|
agentName: 'Architect',
|
||||||
activityType: 'completed',
|
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(
|
expect(
|
||||||
applySessionEventActivity(current, {
|
applySessionEventActivity(current, {
|
||||||
sessionId: 'session-1',
|
sessionId: 'session-1',
|
||||||
@@ -272,6 +281,11 @@ describe('session activity helpers', () => {
|
|||||||
agentName: 'Architect',
|
agentName: 'Architect',
|
||||||
activityType: 'completed',
|
activityType: 'completed',
|
||||||
},
|
},
|
||||||
|
reviewer: {
|
||||||
|
agentId: 'reviewer',
|
||||||
|
agentName: 'Reviewer',
|
||||||
|
activityType: 'completed',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -392,6 +406,92 @@ describe('session activity helpers', () => {
|
|||||||
'session-2': current['session-2'],
|
'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', () => {
|
describe('assistant usage accumulator', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user