fix: accurate elapsed timing and UX polish for activity panel

- Fix useElapsedTimer to use completedAt instead of Date.now() for
  finished runs, preventing wildly inaccurate durations (e.g. 9802m)
  when reopening old sessions
- Extract formatElapsedMs as a shared pure formatter; reuse in
  SubagentActivityCard
- Standardize icon column alignment across all activity row types
  using consistent w-4 + h-[18px] icon containers
- Redesign collapsed header: status dot with pulse animation, bolder
  status label, tabular-nums on elapsed time, cleaner hierarchy
- Polish expanded stream: timeline spine connecting activity items,
  more prominent intent dividers with pill-style background,
  tightened vertical rhythm
- Add unit tests for formatElapsedMs

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-04-15 09:11:38 +02:00
co-authored by Copilot
parent 9243ec10a2
commit 05b3771544
5 changed files with 137 additions and 54 deletions
@@ -5,12 +5,12 @@ 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`;
const minutes = Math.floor(seconds / 60);
const remainder = seconds % 60;
return `${minutes}m ${remainder}s`;
import { formatElapsedMs } from '@renderer/hooks/useElapsedTimer';
function formatElapsed(startedAt: string, endedAt?: string): string {
const endMs = endedAt ? new Date(endedAt).getTime() : Date.now();
const durationMs = endMs - new Date(startedAt).getTime();
return formatElapsedMs(durationMs);
}
function StatusIcon({ status }: { status: ActiveSubagent['status'] }) {
@@ -153,12 +153,12 @@ function ActivityTimelineEventRow({ event }: { event: RunTimelineEventRecord })
const isTerminal = event.kind === 'run-completed' || event.kind === 'run-cancelled' || event.kind === 'run-failed';
return (
<div className="turn-activity-row flex gap-2 py-1">
<div className="mt-0.5 flex shrink-0 items-start">
<div className="turn-activity-row flex items-start gap-2 py-1">
<div className="flex h-[18px] w-4 shrink-0 items-center justify-center">
<ActivityEventIcon kind={event.kind} status={event.status} toolName={event.toolName} />
</div>
<div className="min-w-0 flex-1">
<span className={`text-[12px] font-medium ${isTerminal ? 'text-[var(--color-text-muted)]' : 'text-[var(--color-text-secondary)]'}`}>
<span className={`text-[12px] leading-[18px] font-medium ${isTerminal ? 'text-[var(--color-text-muted)]' : 'text-[var(--color-text-secondary)]'}`}>
{label}
</span>
@@ -231,14 +231,14 @@ function GroupedToolCallRow({ toolName, events }: { toolName: string; events: Ru
<div className="turn-activity-row py-0.5">
<button
type="button"
className="flex w-full items-start gap-2 py-1 text-left transition-colors hover:bg-[var(--color-surface-2)]/30 rounded px-1 -mx-1"
className="flex w-full items-start gap-2 rounded px-1 -mx-1 py-1 text-left transition-colors hover:bg-[var(--color-surface-2)]/30"
onClick={() => setExpanded((prev) => !prev)}
aria-expanded={expanded}
>
<div className="mt-0.5 flex shrink-0 items-start">
<div className="flex h-[18px] w-4 shrink-0 items-center justify-center">
<ToolCategoryIcon toolName={toolName} />
</div>
<span className="min-w-0 flex-1 text-[12px] font-medium text-[var(--color-text-secondary)]">
<span className="min-w-0 flex-1 text-[12px] leading-[18px] font-medium text-[var(--color-text-secondary)]">
{label}
</span>
<ChevronRight
@@ -250,7 +250,7 @@ function GroupedToolCallRow({ toolName, events }: { toolName: string; events: Ru
{/* Collapsed preview: show snippets inline */}
{!expanded && snippets.length > 0 && (
<div className="ml-5 flex flex-wrap gap-x-2 gap-y-0.5 pb-0.5">
<div className="ml-6 flex flex-wrap gap-x-2 gap-y-0.5 pb-0.5">
{snippets.slice(0, 6).map((s, i) => (
<span key={i} className="truncate font-mono text-[10px] text-[var(--color-text-muted)]">
{s}
@@ -266,7 +266,7 @@ function GroupedToolCallRow({ toolName, events }: { toolName: string; events: Ru
{/* Expanded: full per-event rows */}
{expanded && (
<div className="ml-5 border-l border-[var(--color-border)]/30 pl-2">
<div className="ml-6 border-l border-[var(--color-border)]/30 pl-2">
{events.map((event) => (
<div key={event.id} className="py-0.5">
<span className="text-[11px] text-[var(--color-text-secondary)]">
@@ -285,7 +285,7 @@ function GroupedToolCallRow({ toolName, events }: { toolName: string; events: Ru
{/* Aggregate file changes when collapsed */}
{!expanded && hasFileChanges && (
<div className="ml-5 mt-0.5">
<div className="ml-6 mt-0.5">
{events
.filter((e) => e.fileChanges && e.fileChanges.length > 0)
.flatMap((e) => e.fileChanges!)
@@ -304,9 +304,9 @@ function GroupedToolCallRow({ toolName, events }: { toolName: string; events: Ru
function IntentDividerRow({ text }: { text: string }) {
return (
<div className="turn-activity-row flex items-center gap-2 py-1.5" role="separator">
<div className="turn-activity-row flex items-center gap-2 py-2" role="separator">
<div className="h-px flex-1 bg-[var(--color-border)]/40" />
<span className="shrink-0 text-[10px] font-medium tracking-wide text-[var(--color-text-muted)]">
<span className="shrink-0 rounded-full bg-[var(--color-surface-2)]/60 px-2 py-0.5 text-[10px] font-semibold tracking-wide text-[var(--color-text-muted)]">
{text}
</span>
<div className="h-px flex-1 bg-[var(--color-border)]/40" />
@@ -322,8 +322,8 @@ function ThinkingStepRow({ message }: { message: ChatMessageRecord }) {
if (message.pending && !message.content) return null;
return (
<div className="turn-activity-row flex gap-2 py-1">
<div className="mt-0.5 flex shrink-0 items-start">
<div className="turn-activity-row flex items-start gap-2 py-1">
<div className="flex h-[18px] w-4 shrink-0 items-center justify-center">
<Brain className="size-3 text-[var(--color-accent-purple)]" />
</div>
<div className="min-w-0 flex-1">
@@ -349,8 +349,8 @@ function ThinkingGroupRow({ messages }: { messages: ChatMessageRecord[] }) {
return (
<div className="turn-activity-row py-0.5">
<div className="flex gap-2 py-1">
<div className="mt-0.5 flex shrink-0 items-start">
<div className="flex items-start gap-2 py-1">
<div className="flex h-[18px] w-4 shrink-0 items-center justify-center">
<Brain className="size-3 text-[var(--color-accent-purple)]" />
</div>
<div className="min-w-0 flex-1">
@@ -370,7 +370,7 @@ function ThinkingGroupRow({ messages }: { messages: ChatMessageRecord[] }) {
</div>
{expanded && (
<div className="ml-5 space-y-0.5 border-l border-[var(--color-border)]/30 pl-2">
<div className="ml-6 space-y-0.5 border-l border-[var(--color-border)]/30 pl-2">
{visibleMessages.slice(0, -1).map((msg) => (
<p key={msg.id} className="text-[10px] italic leading-snug text-[var(--color-text-muted)]">
"{truncatePreview(msg.content, 140)}"
@@ -461,6 +461,7 @@ export function TurnActivityPanel({
const elapsed = useElapsedTimer(
thinkingMessages.length > 0 || run ? effectiveTurnStartedAt : undefined,
isActive,
run?.completedAt,
);
const summary = useMemo(
@@ -516,17 +517,25 @@ export function TurnActivityPanel({
: isCancelled
? 'text-[var(--color-text-muted)]'
: isActive
? 'text-[var(--color-text-secondary)]'
? 'text-[var(--color-text-primary)]'
: 'text-[var(--color-text-secondary)]';
const statusDotClass = isFailed
? 'bg-[var(--color-status-error)]'
: isCancelled
? 'bg-[var(--color-text-muted)]'
: isActive
? 'bg-[var(--color-accent)]'
: 'bg-[var(--color-status-success)]';
return (
<div
className={`turn-activity-enter overflow-hidden rounded-lg border bg-[var(--color-surface-1)]/60 transition-colors duration-200 ${
className={`turn-activity-enter overflow-hidden rounded-lg border transition-colors duration-200 ${
isActive
? 'border-[var(--color-accent)]/30'
? 'border-[var(--color-accent)]/30 bg-[var(--color-accent)]/[0.03]'
: isFailed
? 'border-[var(--color-status-error)]/20'
: 'border-[var(--color-border)]/50'
? 'border-[var(--color-status-error)]/20 bg-[var(--color-surface-1)]/60'
: 'border-[var(--color-border)]/50 bg-[var(--color-surface-1)]/60'
}`}
>
{/* Summary header */}
@@ -535,20 +544,23 @@ export function TurnActivityPanel({
onClick={toggle}
onKeyDown={(e) => { if (e.key === ' ') { e.preventDefault(); toggle(); } }}
aria-expanded={expanded}
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-[12px] transition-colors hover:bg-[var(--color-surface-2)]/50 ${
isActive ? 'bg-[var(--color-accent)]/[0.04]' : ''
}`}
className="flex w-full items-center gap-2.5 px-3 py-2 text-left transition-colors hover:bg-[var(--color-surface-2)]/50"
>
<Zap className={`size-3.5 shrink-0 ${isActive ? 'text-[var(--color-accent)]' : 'text-[var(--color-text-muted)]'}`} />
{/* Status dot */}
<span className={`size-2 shrink-0 rounded-full ${statusDotClass} ${isActive ? 'animate-pulse' : ''}`} />
{isActive ? (
<span className="flex items-center gap-1.5">
<span className={statusColorClass}>{summaryLabel}</span>
<ActivityPulse />
{/* Status label + elapsed */}
<span className="flex items-center gap-1.5">
<span className={`text-[12px] font-semibold ${statusColorClass}`}>
{summaryLabel}
</span>
) : (
<span className={statusColorClass}>{summaryLabel}</span>
)}
{isActive && <ActivityPulse />}
{elapsed && (
<span className="tabular-nums text-[11px] text-[var(--color-text-muted)]">
{isActive ? elapsed : ''}
</span>
)}
</span>
{/* Intent / generated summary */}
{headerDetail && (
@@ -560,7 +572,7 @@ export function TurnActivityPanel({
{/* Inline counters */}
{summaryParts.length > 0 && (
<span className="shrink-0 font-mono text-[10px] text-[var(--color-text-muted)]">
<span className="shrink-0 tabular-nums text-[10px] text-[var(--color-text-muted)]">
{'· '}
{summaryParts.join(' · ')}
</span>
@@ -576,7 +588,7 @@ export function TurnActivityPanel({
{/* Expanded activity stream — grouped */}
{expanded && (
<div className="border-t border-[var(--color-border)]/30 px-3 py-2">
<div className="space-y-0.5">
<div className="activity-timeline-spine relative space-y-px">
{groupedItems.map((item, index) => (
<GroupedItemRow key={index} item={item} />
))}
+31 -14
View File
@@ -1,39 +1,56 @@
import { useEffect, useState } from 'react';
function formatElapsed(startMs: number): string {
const seconds = Math.max(0, Math.floor((Date.now() - startMs) / 1000));
/** Format a millisecond duration as a human-readable elapsed string. */
export function formatElapsedMs(durationMs: number): string {
const seconds = Math.max(0, Math.floor(durationMs / 1000));
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const remainder = seconds % 60;
return `${minutes}m ${remainder}s`;
return remainder > 0 ? `${minutes}m ${remainder}s` : `${minutes}m`;
}
/**
* Returns a live-ticking elapsed-time string while `active` is true.
* Freezes the display when `active` becomes false.
*
* When the timer is no longer active and `completedAt` is provided, the
* duration is computed as `completedAt startedAt` — giving an accurate
* frozen value even when the session is reopened much later. Without
* `completedAt` the hook falls back to `Date.now() startedAt` at the
* moment `active` becomes false.
*/
export function useElapsedTimer(startedAt: string | undefined, active: boolean): string | undefined {
export function useElapsedTimer(
startedAt: string | undefined,
active: boolean,
completedAt?: string,
): string | undefined {
const startMs = startedAt ? new Date(startedAt).getTime() : undefined;
const endMs = completedAt ? new Date(completedAt).getTime() : undefined;
const [elapsed, setElapsed] = useState<string | undefined>(() => {
if (!startMs) return undefined;
return formatElapsed(startMs);
});
const computeElapsed = (): string | undefined => {
if (!startMs || Number.isNaN(startMs)) return undefined;
if (!active && endMs && !Number.isNaN(endMs)) {
return formatElapsedMs(endMs - startMs);
}
return formatElapsedMs(Date.now() - startMs);
};
const [elapsed, setElapsed] = useState<string | undefined>(computeElapsed);
useEffect(() => {
if (!startMs) {
if (!startMs || Number.isNaN(startMs)) {
setElapsed(undefined);
return;
}
// Always sync to current value immediately
setElapsed(formatElapsed(startMs));
// Sync immediately
setElapsed(computeElapsed());
if (!active) return;
const id = setInterval(() => setElapsed(formatElapsed(startMs)), 1000);
const id = setInterval(() => setElapsed(formatElapsedMs(Date.now() - startMs)), 1000);
return () => clearInterval(id);
}, [startMs, active]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [startMs, endMs, active]);
return elapsed;
}
+17
View File
@@ -674,6 +674,23 @@ body {
animation: intent-divider-in 0.2s cubic-bezier(0.16, 1, 0.3, 1) both;
}
/* Timeline spine connecting activity items */
.activity-timeline-spine > .turn-activity-row:not([role="separator"]) {
position: relative;
}
.activity-timeline-spine > .turn-activity-row:not([role="separator"]):not(:last-child)::before {
content: "";
position: absolute;
left: 7px; /* center of the 16px (w-4) icon column */
top: 22px; /* below the icon center */
bottom: 0;
width: 1px;
background: var(--color-border);
opacity: 0.25;
pointer-events: none;
}
/* ── Batch selection animations ──────────────────────────────── */
@keyframes selection-checkbox-in {
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, test } from 'bun:test';
import { formatElapsedMs } from '@renderer/hooks/useElapsedTimer';
describe('formatElapsedMs', () => {
test('formats sub-second durations as 0s', () => {
expect(formatElapsedMs(0)).toBe('0s');
expect(formatElapsedMs(500)).toBe('0s');
expect(formatElapsedMs(999)).toBe('0s');
});
test('formats seconds under a minute', () => {
expect(formatElapsedMs(1000)).toBe('1s');
expect(formatElapsedMs(5_000)).toBe('5s');
expect(formatElapsedMs(59_000)).toBe('59s');
});
test('formats minutes with remaining seconds', () => {
expect(formatElapsedMs(60_000)).toBe('1m');
expect(formatElapsedMs(90_000)).toBe('1m 30s');
expect(formatElapsedMs(125_000)).toBe('2m 5s');
});
test('formats exact minutes without trailing seconds', () => {
expect(formatElapsedMs(120_000)).toBe('2m');
expect(formatElapsedMs(300_000)).toBe('5m');
});
test('clamps negative durations to 0s', () => {
expect(formatElapsedMs(-5000)).toBe('0s');
});
test('formats large durations correctly', () => {
// 2 hours, 30 minutes, 15 seconds = 9015s = 150m 15s
expect(formatElapsedMs(9_015_000)).toBe('150m 15s');
});
});