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.
59 lines
2.1 KiB
TypeScript
59 lines
2.1 KiB
TypeScript
import React from 'react';
|
|
import { cn } from '@/lib/utils';
|
|
import { useI18n } from '@/lib/i18n';
|
|
import { useDurationTickerNow } from '@/hooks/useDurationTicker';
|
|
import {
|
|
useSessionActivityStartedAt,
|
|
useSessionSettledDurationMs,
|
|
} from '@/sync/session-activity-timing';
|
|
import { formatSessionActivityDuration } from './sessionActivityDurationFormat';
|
|
|
|
/** One update per second: the readout is the animation, at 1 fps instead of 60. */
|
|
const TICK_MS = 1000;
|
|
|
|
/**
|
|
* Elapsed time of a session's current turn, or of the turn that just finished.
|
|
* Colored to match the row's status dot in each state, so the pair reads as one
|
|
* indicator rather than two.
|
|
*
|
|
* Deliberately a leaf. The tick re-renders this span alone rather than the
|
|
* session row around it, which is what makes a live counter cheaper than the
|
|
* spinner it replaced — that spinner repainted a composited layer per row every
|
|
* frame for as long as the session ran.
|
|
*/
|
|
export const SessionActivityDuration: React.FC<{
|
|
sessionId: string;
|
|
/** Turn still running (`busy` or `retry`); false renders the settled total. */
|
|
running: boolean;
|
|
className?: string;
|
|
}> = ({ sessionId, running, className }) => {
|
|
const { t } = useI18n();
|
|
const startedAt = useSessionActivityStartedAt(sessionId);
|
|
const settledMs = useSessionSettledDurationMs(sessionId);
|
|
const now = useDurationTickerNow(running, TICK_MS);
|
|
|
|
const durationMs = running ? Math.max(0, now - (startedAt ?? now)) : settledMs;
|
|
if (durationMs === undefined) return null;
|
|
|
|
const label = formatSessionActivityDuration(durationMs, t);
|
|
const description = running
|
|
? t('sessions.sidebar.session.status.activeFor', { duration: label })
|
|
: t('sessions.sidebar.session.status.lastTurnDuration', { duration: label });
|
|
|
|
return (
|
|
<span
|
|
className={cn(
|
|
'shrink-0 tabular-nums',
|
|
// The readout wears its dot's color, so the row reads as one signal:
|
|
// primary while the turn runs, info once it is waiting to be read.
|
|
running ? 'text-primary' : 'text-[var(--status-info)]',
|
|
className,
|
|
)}
|
|
aria-label={description}
|
|
title={description}
|
|
>
|
|
{label}
|
|
</span>
|
|
);
|
|
};
|