perf(ui): swap the session activity spinner for a dot and a turn timer

The spinner ran a CSS animation on every active row for the whole turn,
repainting a composited layer at frame rate. Rows now carry a static dot —
primary while running, info while unread — and the metadata slot on the right
shows how long the turn has been going, updating once per second in the dot's
colour. The counter is the motion the spinner used to provide, at 1 fps.
Collapsed groups, folders and projects take the dot only, since one counter
cannot speak for several running turns.

Elapsed time is measured client-side because SessionStatus carries no
timestamps, and starts are persisted so a reload resumes the same count. Two
rules keep that honest. Only a liveness stamp — refreshed while a session is
observed active, stamped as the page hides, and compared against the page's
navigation start so a slow bootstrap is not charged to the absence — and a 90s
adoption window may expire a record; a snapshot that cannot yet see a session
is not evidence its turn ended. And a busy event is never read as a turn
boundary, because OpenCode republishes busy at every step of the agent loop, so
after a reload one of those repeats normally beats the first status snapshot.
Idle and error events do end a turn, and retire the record with it.

Snapshot reconciliation walks the running turns and asks whether the snapshot
covers each one, rather than being handed everything it covers: only a live
start can settle, so the pass scales with timing work instead of with the
directory's session list, and allocates nothing per poll.

Also applied to the mobile sessions sheet and session switcher. The shared
duration ticker moves to hooks/ now that it has a second consumer.
This commit is contained in:
Bohdan Triapitsyn
2026-08-05 03:06:35 +03:00
parent ce0e1cea27
commit 094f728777
27 changed files with 1094 additions and 66 deletions
@@ -34,6 +34,8 @@ import { useProjectsStore } from '@/stores/useProjectsStore';
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
import { getGitHubPrStatusKey, usePrVisualSummary } from '@/stores/useGitHubPrStatusStore';
import { useSessionUnseenCount } from '@/sync/notification-store';
import { useHasSessionActivityDuration } from '@/sync/session-activity-timing';
import { SessionActivityDuration } from '@/components/session/SessionActivityDuration';
import { useSessionMultiSelectStore } from '@/stores/useSessionMultiSelectStore';
import { useI18n } from '@/lib/i18n';
import { useShiftKeyHeld } from '@/hooks/useShiftKeyHeld';
@@ -443,6 +445,11 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
React.useCallback((state) => Boolean(state.sessionMemoryState.get(viewportSessionKey(session.id))?.isZombie), [session.id]),
);
const sessionStatus = useGlobalSessionStatus(session.id);
const statusType = sessionStatus?.type ?? 'idle';
const isStreaming = statusType === 'busy' || statusType === 'retry';
// Read as a boolean, not as the value: the row must not re-render on every
// tick of the counter it only decides to mount.
const hasActivityDuration = useHasSessionActivityDuration(session.id, isStreaming);
const isMovingToWorktree = useIsSessionWorktreeMovePending(session.id);
const sessionPermissions = useSessionPermissions(session.id, sessionDirectory ?? undefined, { bootstrap: false });
const sessionGoal = getSessionGoal(resolvedSession);
@@ -668,26 +675,28 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
);
}
const statusType = sessionStatus?.type ?? 'idle';
const isStreaming = statusType === 'busy' || statusType === 'retry';
const pendingPermissionCount = sessionPermissions.length;
const showUnreadStatus = !isMovingToWorktree && !isStreaming && needsAttention && !isActive;
const showStatusMarker = isStreaming || showUnreadStatus;
const statusMarkerContent = isStreaming
? (
<Icon
name="loader-4"
className="h-3 w-3 animate-spin text-primary"
aria-label={t('sessions.sidebar.session.status.active')}
/>
)
: (
<span
className="h-1.5 w-1.5 rounded-full bg-[var(--status-info)]"
aria-label={t('sessions.sidebar.session.status.unread')}
title={t('sessions.sidebar.session.status.unread')}
/>
);
// Both states are the same static dot; only the color separates "running"
// from "unread". The elapsed-turn readout on the right carries the motion
// that a spinner used to, at one repaint per second instead of per frame.
const statusMarkerLabel = isStreaming
? t('sessions.sidebar.session.status.active')
: t('sessions.sidebar.session.status.unread');
const statusMarkerContent = (
<span
className={cn(
'h-1.5 w-1.5 rounded-full',
isStreaming ? 'bg-primary' : 'bg-[var(--status-info)]',
)}
aria-label={statusMarkerLabel}
title={statusMarkerLabel}
/>
);
// The settled duration lives exactly as long as the unread marker does, so a
// session read (or watched) while it finishes never keeps a stale total.
const showActivityDuration = (isStreaming || showUnreadStatus) && hasActivityDuration;
const hideLeadingIndicatorOnHover = !alwaysShowActions && hasChildren && (isMovingToWorktree || showStatusMarker || isPinnedSession);
const showPinnedMarker = isPinnedSession && !isMovingToWorktree && !showStatusMarker;
const pinnedMarkerContent = (
@@ -1224,21 +1233,31 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
would reflow the truncated title and cause a micro
horizontal shift when the status flips. */}
<div className={cn('block min-w-0 flex-1 truncate typography-ui-label font-normal', isActive ? 'text-primary' : needsAttention ? 'text-foreground' : 'text-foreground/80')}>{renderHighlightedText(sessionTitle, normalizedSessionSearchQuery)}</div>
{/* While a turn runs (and until its result is read) the
elapsed counter takes over this slot from the usual
goal/branch/date metadata, which stays one hover or
one read away. */}
{alwaysShowActions ? (
// Touch runtimes have no hover tooltip, so the compact
// date stays inline there.
<span className="ml-2 inline-flex flex-shrink-0 items-center gap-1 text-[0.72rem] text-muted-foreground/75">
{sessionGoalGlyph}
{showInlineBranchMarker ? (
<Icon
name="git-branch"
className={cn('h-3 w-3', !prIconColor && 'text-muted-foreground/60')}
style={prIconColor ? { color: prIconColor } : undefined}
/>
) : null}
{sessionCompactUpdatedLabel}
{showActivityDuration ? (
<SessionActivityDuration sessionId={session.id} running={isStreaming} />
) : (
<>
{sessionGoalGlyph}
{showInlineBranchMarker ? (
<Icon
name="git-branch"
className={cn('h-3 w-3', !prIconColor && 'text-muted-foreground/60')}
style={prIconColor ? { color: prIconColor } : undefined}
/>
) : null}
{sessionCompactUpdatedLabel}
</>
)}
</span>
) : (sessionGoalGlyph || showInlineBranchMarker) ? (
) : (showActivityDuration || sessionGoalGlyph || showInlineBranchMarker) ? (
<div className="relative ml-1 flex h-4 flex-shrink-0 items-center justify-end">
<span className={cn(
'inline-flex items-center gap-1 whitespace-nowrap text-right transition-opacity duration-150',
@@ -1246,14 +1265,24 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
? 'opacity-0'
: hideOnHoverClass,
)}>
{sessionGoalGlyph}
{showInlineBranchMarker ? (
<Icon
name="git-branch"
className={cn('h-3 w-3', !prIconColor && 'text-muted-foreground/60')}
style={prIconColor ? { color: prIconColor } : undefined}
{showActivityDuration ? (
<SessionActivityDuration
sessionId={session.id}
running={isStreaming}
className="text-[0.72rem]"
/>
) : null}
) : (
<>
{sessionGoalGlyph}
{showInlineBranchMarker ? (
<Icon
name="git-branch"
className={cn('h-3 w-3', !prIconColor && 'text-muted-foreground/60')}
style={prIconColor ? { color: prIconColor } : undefined}
/>
) : null}
</>
)}
</span>
</div>
) : null}