mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-03 18:38:35 +02:00
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:
@@ -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} />
|
||||
))}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user