From 094f728777b792cd51ade6c2b86ca3dac2858440 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 5 Aug 2026 03:06:35 +0300 Subject: [PATCH] perf(ui): swap the session activity spinner for a dot and a turn timer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../ui/src/apps/MobileSessionSwitcher.tsx | 27 +- packages/ui/src/apps/MobileSessionsSheet.tsx | 26 +- packages/ui/src/apps/runtimeEndpointReset.ts | 4 + .../chat/message/parts/ToolPart.tsx | 2 +- .../session/SessionActivityDuration.tsx | 58 +++ .../src/components/session/SessionSidebar.tsx | 9 +- .../sessionActivityDurationFormat.test.ts | 34 ++ .../session/sessionActivityDurationFormat.ts | 32 ++ .../session/sidebar/DOCUMENTATION.md | 2 + .../session/sidebar/SessionNodeItem.tsx | 97 ++-- .../sidebar/collapsedActivityIndicator.tsx | 15 +- .../parts => hooks}/useDurationTicker.ts | 4 + packages/ui/src/lib/i18n/messages/de.ts | 5 + packages/ui/src/lib/i18n/messages/en.ts | 5 + packages/ui/src/lib/i18n/messages/es.ts | 5 + packages/ui/src/lib/i18n/messages/fr.ts | 5 + packages/ui/src/lib/i18n/messages/ja.ts | 5 + packages/ui/src/lib/i18n/messages/ko.ts | 5 + packages/ui/src/lib/i18n/messages/pl.ts | 5 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 5 + packages/ui/src/lib/i18n/messages/uk.ts | 5 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 5 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 5 + packages/ui/src/sync/DOCUMENTATION.md | 11 + packages/ui/src/sync/global-session-status.ts | 30 +- .../src/sync/session-activity-timing.test.ts | 310 ++++++++++++ .../ui/src/sync/session-activity-timing.ts | 444 ++++++++++++++++++ 27 files changed, 1094 insertions(+), 66 deletions(-) create mode 100644 packages/ui/src/components/session/SessionActivityDuration.tsx create mode 100644 packages/ui/src/components/session/sessionActivityDurationFormat.test.ts create mode 100644 packages/ui/src/components/session/sessionActivityDurationFormat.ts rename packages/ui/src/{components/chat/message/parts => hooks}/useDurationTicker.ts (89%) create mode 100644 packages/ui/src/sync/session-activity-timing.test.ts create mode 100644 packages/ui/src/sync/session-activity-timing.ts diff --git a/packages/ui/src/apps/MobileSessionSwitcher.tsx b/packages/ui/src/apps/MobileSessionSwitcher.tsx index 4e2a44a8..c05b9ecf 100644 --- a/packages/ui/src/apps/MobileSessionSwitcher.tsx +++ b/packages/ui/src/apps/MobileSessionSwitcher.tsx @@ -1,7 +1,7 @@ import React from 'react'; import type { Session } from '@opencode-ai/sdk/v2'; -import { Icon } from '@/components/icon/Icon'; +import { SessionActivityDuration } from '@/components/session/SessionActivityDuration'; import { formatSessionCompactDateLabel } from '@/components/session/sidebar/utils'; import { useSwitcherItems } from '@/components/session/sidebar/hooks/useSwitcherItems'; import { useTabletLayout } from '@/lib/device'; @@ -10,6 +10,7 @@ import { cn } from '@/lib/utils'; import { refreshGlobalSessions, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useSessionUnseenCount } from '@/sync/notification-store'; +import { useHasSessionActivityDuration } from '@/sync/session-activity-timing'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useGlobalSessionStatus } from '@/sync/sync-context'; @@ -35,6 +36,8 @@ const SwitcherRow: React.FC<{ const statusType = status?.type ?? 'idle'; const isStreaming = statusType === 'busy' || statusType === 'retry'; const showUnreadDot = !isStreaming && unseenCount > 0 && !active; + const hasActivityDuration = useHasSessionActivityDuration(session.id, isStreaming); + const showActivityDuration = (isStreaming || showUnreadDot) && hasActivityDuration; const timeLabel = formatSessionCompactDateLabel(session.time?.updated ?? session.time?.created ?? 0); return ( @@ -56,12 +59,24 @@ const SwitcherRow: React.FC<{ ) : null} {/* Activity sits on the right, before the time — no reserved left gutter. */} - {isStreaming ? ( - - ) : showUnreadDot ? ( - + {isStreaming || showUnreadDot ? ( + ) : null} - {timeLabel ? ( + {/* The elapsed turn takes the time slot while it matters, then hands it + back to the relative timestamp. */} + {showActivityDuration ? ( + + ) : timeLabel ? ( {timeLabel} ) : null} diff --git a/packages/ui/src/apps/MobileSessionsSheet.tsx b/packages/ui/src/apps/MobileSessionsSheet.tsx index 5f93651b..43a28f32 100644 --- a/packages/ui/src/apps/MobileSessionsSheet.tsx +++ b/packages/ui/src/apps/MobileSessionsSheet.tsx @@ -64,6 +64,8 @@ import { import { useSessionUIStore } from '@/sync/session-ui-store'; import { useAllLiveSessions, useGlobalSessionStatus } from '@/sync/sync-context'; import { useSessionUnseenCount } from '@/sync/notification-store'; +import { useHasSessionActivityDuration } from '@/sync/session-activity-timing'; +import { SessionActivityDuration } from '@/components/session/SessionActivityDuration'; import type { WorktreeMetadata } from '@/types/worktree'; import { MobileDeleteWorktreeDialog } from './MobileDeleteWorktreeDialog'; @@ -476,6 +478,8 @@ const SessionRow: React.FC<{ const statusType = liveStatus?.type ?? 'idle'; const isStreaming = statusType === 'busy' || statusType === 'retry'; const showUnreadDot = !isStreaming && unseenCount > 0 && !active; + const hasActivityDuration = useHasSessionActivityDuration(session.id, isStreaming); + const showActivityDuration = (isStreaming || showUnreadDot) && hasActivityDuration; const contentRef = React.useRef(null); const startRef = React.useRef<{ x: number; y: number } | null>(null); @@ -616,10 +620,14 @@ const SessionRow: React.FC<{ onToggleChildren?.(); }} > - {isStreaming ? ( - - ) : showUnreadDot ? ( - + {isStreaming || showUnreadDot ? ( + ) : ( )} @@ -664,7 +672,15 @@ const SessionRow: React.FC<{ > {title} - {time ? ( + {/* The elapsed turn takes the time slot while it matters, then + hands it back to the relative timestamp. */} + {showActivityDuration ? ( + + ) : time ? ( {time} ) : null} diff --git a/packages/ui/src/apps/runtimeEndpointReset.ts b/packages/ui/src/apps/runtimeEndpointReset.ts index e92a4063..b430cfc9 100644 --- a/packages/ui/src/apps/runtimeEndpointReset.ts +++ b/packages/ui/src/apps/runtimeEndpointReset.ts @@ -17,6 +17,7 @@ import { useSessionUIStore } from '@/sync/session-ui-store'; import { resetStreamingState } from '@/sync/streaming'; import { useGlobalSessionStatusStore } from '@/sync/global-session-status'; import { resetSessionOrdering } from '@/sync/session-ordering'; +import { resetSessionActivityTiming } from '@/sync/session-activity-timing'; import { syncDesktopSettings } from '@/lib/persistence'; // Same-device transport switch (LAN⇄relay for one paired device): rebind the SDK @@ -56,6 +57,9 @@ export const resetAppForRuntimeEndpointChange = (detail: RuntimeEndpointChangedD useGlobalSessionsStore.getState().resetForRuntimeSwitch(); useGlobalSessionStatusStore.setState({ statusById: new Map() }); resetSessionOrdering(); + // Turn timings belong to the previous instance's sessions, and the reset also + // restarts the resume window so the switch is treated as a fresh load. + resetSessionActivityTiming(); usePermissionStore.getState().reset(); useFileSearchStore.getState().resetForRuntimeSwitch(); useGitStore.getState().resetForRuntimeSwitch(detail.runtimeKey); diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index ee4983de..2969dd85 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -42,7 +42,7 @@ import { DiffViewToggle, type DiffViewMode } from '../DiffViewToggle'; import { MinDurationShineText } from './MinDurationShineText'; import { ToolRevealOnMount } from './ToolRevealOnMount'; import { getToolIcon } from './toolPresentation'; -import { useDurationTickerNow } from './useDurationTicker'; +import { useDurationTickerNow } from '@/hooks/useDurationTicker'; import { buildTaskSummaryEntriesFromSession, normalizeTaskSummaryEntries, diff --git a/packages/ui/src/components/session/SessionActivityDuration.tsx b/packages/ui/src/components/session/SessionActivityDuration.tsx new file mode 100644 index 00000000..92499e2b --- /dev/null +++ b/packages/ui/src/components/session/SessionActivityDuration.tsx @@ -0,0 +1,58 @@ +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 ( + + {label} + + ); +}; diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index acd52fad..f7ce5a3b 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -17,7 +17,6 @@ import { useUIStore } from '@/stores/useUIStore'; import { getDeferredSafeStorage } from '@/stores/utils/safeStorage'; import { useGitStore, useGitAllBranches, useGitRepoStatusMap } from '@/stores/useGitStore'; import { isVSCodeRuntime } from '@/lib/desktop'; -import { Icon } from '@/components/icon/Icon'; import { TooltipProvider } from '@/components/ui/tooltip'; import { NewWorktreeDialog } from './NewWorktreeDialog'; import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore'; @@ -243,12 +242,14 @@ const ProjectAggregateStatusIndicator: React.FC<{ directories: Array ); } diff --git a/packages/ui/src/components/session/sessionActivityDurationFormat.test.ts b/packages/ui/src/components/session/sessionActivityDurationFormat.test.ts new file mode 100644 index 00000000..4ee81a5a --- /dev/null +++ b/packages/ui/src/components/session/sessionActivityDurationFormat.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from 'bun:test'; + +import { dict as enDict } from '@/lib/i18n/messages/en'; +import { formatMessage, type I18nKey, type I18nParams } from '@/lib/i18n'; +import { formatSessionActivityDuration } from './sessionActivityDurationFormat'; + +const t = (key: I18nKey, params?: I18nParams): string => formatMessage(enDict, key, params); + +const format = (ms: number): string => formatSessionActivityDuration(ms, t); + +describe('formatSessionActivityDuration', () => { + test('renders seconds below a minute', () => { + expect(format(0)).toBe('0s'); + expect(format(999)).toBe('0s'); + expect(format(7_400)).toBe('7s'); + expect(format(59_999)).toBe('59s'); + }); + + test('renders minutes and seconds below an hour', () => { + expect(format(60_000)).toBe('1m 0s'); + expect(format(83_000)).toBe('1m 23s'); + expect(format(59 * 60_000 + 59_000)).toBe('59m 59s'); + }); + + test('drops seconds past an hour so the label stays narrow', () => { + expect(format(3_600_000)).toBe('1h 0m'); + expect(format(3_600_000 + 2 * 60_000 + 33_000)).toBe('1h 2m'); + expect(format(25 * 3_600_000)).toBe('25h 0m'); + }); + + test('clamps a negative duration rather than rendering a negative count', () => { + expect(format(-5_000)).toBe('0s'); + }); +}); diff --git a/packages/ui/src/components/session/sessionActivityDurationFormat.ts b/packages/ui/src/components/session/sessionActivityDurationFormat.ts new file mode 100644 index 00000000..5f710609 --- /dev/null +++ b/packages/ui/src/components/session/sessionActivityDurationFormat.ts @@ -0,0 +1,32 @@ +import type { I18nKey, I18nParams } from '@/lib/i18n'; + +type Translate = (key: I18nKey, params?: I18nParams) => string; + +const SECOND_MS = 1000; +const MINUTE_MS = 60 * SECOND_MS; +const HOUR_MS = 60 * MINUTE_MS; + +/** + * Compact turn duration for a session row: `7s`, `1m 23s`, `1h 2m`. + * + * Seconds are dropped past an hour so the label cannot outgrow the row's + * metadata slot, and the unit suffixes are translated rather than concatenated + * so locales that place or spell them differently stay correct. + */ +export const formatSessionActivityDuration = (durationMs: number, t: Translate): string => { + const total = Math.max(0, durationMs); + + if (total < MINUTE_MS) { + return t('common.duration.secondsCompact', { seconds: Math.floor(total / SECOND_MS) }); + } + if (total < HOUR_MS) { + return t('common.duration.minutesSecondsCompact', { + minutes: Math.floor(total / MINUTE_MS), + seconds: Math.floor((total % MINUTE_MS) / SECOND_MS), + }); + } + return t('common.duration.hoursMinutesCompact', { + hours: Math.floor(total / HOUR_MS), + minutes: Math.floor((total % HOUR_MS) / MINUTE_MS), + }); +}; diff --git a/packages/ui/src/components/session/sidebar/DOCUMENTATION.md b/packages/ui/src/components/session/sidebar/DOCUMENTATION.md index becce78f..4033b4b8 100644 --- a/packages/ui/src/components/session/sidebar/DOCUMENTATION.md +++ b/packages/ui/src/components/session/sidebar/DOCUMENTATION.md @@ -6,6 +6,7 @@ - Layout (web/desktop): top navigation (`SidebarNav`: New session, Scheduled, Multi-run, Archive), then the `recent` zone, then one zone per project with a **flat** session list. There is no rendered worktree grouping level. - **Two grouping display modes** (`useSessionDisplayStore.sessionGroupingMode`, toggled in the view dropdown): `'by-worktree'` (default) renders the worktree-grouped `sectionsForRender` with slim PR-aware branch sub-headers inside each project zone; `'flat'` renders `flatSectionsForRender` — one merged non-archived group per project (`id: 'flat'`, `folderScopes` listing every contributing scope) with per-row branch markers. Both derive from the same `projectSections` data layer, which alone feeds bootstrap demand planning and PR polling. - When sticky zone headers are enabled, project headers are sticky "zone" bands (`SortableProjectItem`); on a vibrant desktop the scrolling content fades behind an unmasked, non-interactive copy of the stuck icon/title without painting a background. The transparent fade zone blocks interaction with obscured rows. The `recent` section uses the same overlay while it is the leading sticky header. Collapsed projects show an aggregated busy/unseen indicator (`ProjectAggregateStatusIndicator`), derived from the live status index and notification store scoped to the project's directories. +- **Activity is a dot plus a counter, never a spinner.** The row's left gutter shows a static dot — primary while the session runs (`busy`/`retry`), info while it is unread — and the metadata slot on the right swaps the goal/branch/date group for the elapsed time of the turn (`SessionActivityDuration`, ticking once per second). The readout takes the dot's color in each state — primary while running, info once it is waiting to be read — so the pair reads as one indicator. A running spinner repainted a composited layer per row every frame for the whole turn; the counter conveys the same "something is happening" at 1 fps. The counter follows the unread marker's lifetime exactly: it survives the turn ending, disappears when the session is read, and never lingers on the session being watched (which is marked read as it goes idle). Aggregate indicators for collapsed groups, folders, and projects show the dot only — a group may hold several running turns, so a single counter would have nothing to count. The same treatment applies to the mobile sessions sheet and session switcher rows. The worktree-move indicator stays a spinner: it marks a short user-initiated operation, not a session state. - Session rows have a single layout (former `minimal`); the `default`/`minimal` display mode was removed (`session-display-mode` store v4 migration drops the key). Rows show an inline branch label (from `node.worktree` or recent's `secondaryMeta`) when the session lives outside the project root, and bold titles while unread. - Folders render **flat** after the loose sessions: nested folders keep `parentId` in the data model but display at one level with a "Parent / Child" path label (`SessionFolderItem.displayName`); collapsing a folder hides its whole subtree. Folder actions resolve their owning scope per folder entry (folders from multiple worktree scopes can coexist under one project). - Archived sessions are not shown in the web/desktop sidebar; the Archive page (`ArchiveView`, `useUIStore.isArchivePageOpen`) replaces the old toggle. VS Code keeps inline archived buckets behind `showArchivedSessions` (compact webview has no page surfaces). Restore (unarchive) is available per session (row context menu, Archive page row) and in bulk (selection bar) and writes `time.archived = 0` — the server cannot clear the field over HTTP, so the global session cache splits active/archived client-side (see "Restore (unarchive) contract" in `sync/DOCUMENTATION.md`). @@ -32,6 +33,7 @@ - `SidebarProjectsList.tsx`: Main scrollable renderer for project zones and their flat/archived groups plus empty/search states; owns project drag-to-reorder. - `SessionGroupSection.tsx`: Renders one flat (or archived) group: sessions first, then flat folder entries with path labels, show-more batching, and explicit loading/error/retry state for empty groups. Archived buckets (VS Code) virtualize past 50 rows. - `SessionNodeItem.tsx`: Renders one session row/tree node with a single-line layout, inline branch label, indicators, menu actions, and nested children. Rows do not initiate directory bootstrap on mount. +- `collapsedActivityIndicator.tsx`: Aggregate busy/unseen dot for collapsed groups and folders. - `ConfirmDialogs.tsx`: Shared confirm dialog wrappers for session delete and folder delete flows. - `sortableItems.tsx`: DnD sortable wrapper for project ordering plus the sticky zone-band project header and its action affordances. - `sessionFolderDnd.tsx`: Folder/session DnD scope and wrappers for dropping/moving sessions into folders. diff --git a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx index ee143afd..6521feba 100644 --- a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx +++ b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx @@ -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 - ? ( - - ) - : ( - - ); + // 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 = ( + + ); + // 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. */}
{renderHighlightedText(sessionTitle, normalizedSessionSearchQuery)}
+ {/* 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. - {sessionGoalGlyph} - {showInlineBranchMarker ? ( - - ) : null} - {sessionCompactUpdatedLabel} + {showActivityDuration ? ( + + ) : ( + <> + {sessionGoalGlyph} + {showInlineBranchMarker ? ( + + ) : null} + {sessionCompactUpdatedLabel} + + )} - ) : (sessionGoalGlyph || showInlineBranchMarker) ? ( + ) : (showActivityDuration || sessionGoalGlyph || showInlineBranchMarker) ? (
- {sessionGoalGlyph} - {showInlineBranchMarker ? ( - - ) : null} + ) : ( + <> + {sessionGoalGlyph} + {showInlineBranchMarker ? ( + + ) : null} + + )}
) : null} diff --git a/packages/ui/src/components/session/sidebar/collapsedActivityIndicator.tsx b/packages/ui/src/components/session/sidebar/collapsedActivityIndicator.tsx index 4d3cfaba..d5cd2a50 100644 --- a/packages/ui/src/components/session/sidebar/collapsedActivityIndicator.tsx +++ b/packages/ui/src/components/session/sidebar/collapsedActivityIndicator.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import { Icon } from '@/components/icon/Icon'; import { cn } from '@/lib/utils'; import type { CollapsedActivityState } from './collapsedActivityState'; @@ -15,21 +14,13 @@ export function CollapsedActivityIndicator({ className?: string; }): React.ReactNode { const label = state === 'active' ? activeLabel : unreadLabel; - if (state === 'active') { - return ( - - ); - } - + // Aggregate rows carry the dot only; the elapsed counter is per session and + // has no meaning for a collapsed group that may hold several running turns. return ( void; diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index a169280f..57a22de2 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -473,6 +473,8 @@ export const dict = { 'sessions.sidebar.session.status.unread': 'Ungelesene Updates', 'sessions.sidebar.session.status.pinned': 'Angeheftete Sitzung', 'sessions.sidebar.session.status.permissionRequired': 'Berechtigung erforderlich', + 'sessions.sidebar.session.status.activeFor': 'Seit {duration} aktiv', + 'sessions.sidebar.session.status.lastTurnDuration': 'Letzter Durchlauf dauerte {duration}', 'sessions.sidebar.session.subsessions.collapse': 'Untersitzungen einklappen', 'sessions.sidebar.session.subsessions.expand': 'Untersitzungen ausklappen', 'sessions.sidebar.dialogs.deleteSession.title': 'Sitzung löschen?', @@ -2728,6 +2730,9 @@ export const dict = { 'common.relative.daysAgoCompact': '{count}d her', 'common.relative.weeksAgoCompact': '{count}w her', 'common.relative.yearsAgoCompact': '{count}y her', + 'common.duration.secondsCompact': '{seconds}s', + 'common.duration.minutesSecondsCompact': '{minutes}m {seconds}s', + 'common.duration.hoursMinutesCompact': '{hours}h {minutes}m', 'contextFileOpen.failure.tooLarge': 'Datei ist zu groß zum Öffnen (>{count} Zeilen)', 'contextFileOpen.failure.missing': 'Datei nicht gefunden', 'contextFileOpen.failure.unreadable': 'Fehler beim Öffnen der Datei', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 6d2e76a5..c77d6a07 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -530,6 +530,8 @@ export const dict = { 'sessions.sidebar.session.status.pinned': 'Pinned session', 'sessions.sidebar.session.status.movingToWorktree': 'Moving session to a new worktree', 'sessions.sidebar.session.status.permissionRequired': 'Permission required', + 'sessions.sidebar.session.status.activeFor': 'Active for {duration}', + 'sessions.sidebar.session.status.lastTurnDuration': 'Last turn took {duration}', 'sessions.sidebar.session.subsessions.collapse': 'Collapse subsessions', 'sessions.sidebar.session.subsessions.expand': 'Expand subsessions', 'sessions.sidebar.dialogs.deleteSession.title': 'Delete session?', @@ -2900,6 +2902,9 @@ export const dict = { 'common.relative.daysAgoCompact': '{count}d ago', 'common.relative.weeksAgoCompact': '{count}w ago', 'common.relative.yearsAgoCompact': '{count}y ago', + 'common.duration.secondsCompact': '{seconds}s', + 'common.duration.minutesSecondsCompact': '{minutes}m {seconds}s', + 'common.duration.hoursMinutesCompact': '{hours}h {minutes}m', 'contextFileOpen.failure.tooLarge': 'File is too large to open (>{count} lines)', 'contextFileOpen.failure.missing': 'File not found', 'contextFileOpen.failure.unreadable': 'Failed to open file', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 18a8b9ed..72985914 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -531,6 +531,8 @@ export const dict: Record = { "sessions.sidebar.session.status.pinned": "Sesión anclada", "sessions.sidebar.session.status.movingToWorktree": "Moviendo la sesión a un worktree nuevo", "sessions.sidebar.session.status.permissionRequired": "Permiso requerido", + "sessions.sidebar.session.status.activeFor": "Activa desde hace {duration}", + "sessions.sidebar.session.status.lastTurnDuration": "El último turno duró {duration}", "sessions.sidebar.session.subsessions.collapse": "Colapsar subsesiones", "sessions.sidebar.session.subsessions.expand": "Expandir subsesiones", "sessions.sidebar.dialogs.deleteSession.title": "¿Eliminar sesión?", @@ -2901,6 +2903,9 @@ export const dict: Record = { "common.relative.daysAgoCompact": "{count}d ago", "common.relative.weeksAgoCompact": "{count}w ago", "common.relative.yearsAgoCompact": "{count}y ago", + "common.duration.secondsCompact": "{seconds}s", + "common.duration.minutesSecondsCompact": "{minutes}m {seconds}s", + "common.duration.hoursMinutesCompact": "{hours}h {minutes}m", "contextFileOpen.failure.tooLarge": "File is too large to open (>{count} lines)", "contextFileOpen.failure.missing": "File not found", "contextFileOpen.failure.unreadable": "Failed to open file", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index a387f1c7..986a4cd4 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -366,6 +366,8 @@ export const dict = { 'sessions.sidebar.session.status.pinned': 'Session épinglée', 'sessions.sidebar.session.status.movingToWorktree': 'Déplacement de la session vers un nouveau worktree', 'sessions.sidebar.session.status.permissionRequired': 'Autorisation requise', + 'sessions.sidebar.session.status.activeFor': 'Active depuis {duration}', + 'sessions.sidebar.session.status.lastTurnDuration': 'Le dernier tour a duré {duration}', 'sessions.sidebar.session.subsessions.collapse': 'Réduire les sous-sessions', 'sessions.sidebar.session.subsessions.expand': 'Développer les sous-sessions', 'sessions.sidebar.dialogs.deleteSession.title': 'Supprimer la session ?', @@ -2648,6 +2650,9 @@ export const dict = { 'common.relative.daysAgoCompact': '{count} j', 'common.relative.weeksAgoCompact': '{count} sem', 'common.relative.yearsAgoCompact': '{count} a', + 'common.duration.secondsCompact': '{seconds}s', + 'common.duration.minutesSecondsCompact': '{minutes}m {seconds}s', + 'common.duration.hoursMinutesCompact': '{hours}h {minutes}m', 'contextFileOpen.failure.tooLarge': 'Le fichier est trop volumineux pour être ouvert (> {count} lignes)', 'contextFileOpen.failure.missing': 'Fichier introuvable', 'contextFileOpen.failure.unreadable': 'Impossible d’ouvrir le fichier', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 68a4fb7a..0f4da4e7 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -531,6 +531,8 @@ export const dict: Record = { 'sessions.sidebar.session.status.pinned': 'ピン留めされたセッション', 'sessions.sidebar.session.status.movingToWorktree': 'セッションを新しいworktreeへ移動中', 'sessions.sidebar.session.status.permissionRequired': '権限が必要です', + 'sessions.sidebar.session.status.activeFor': 'アクティブ時間 {duration}', + 'sessions.sidebar.session.status.lastTurnDuration': '前回のターンの所要時間 {duration}', 'sessions.sidebar.session.subsessions.collapse': 'サブセッションを折りたたむ', 'sessions.sidebar.session.subsessions.expand': 'サブセッションを展開', 'sessions.sidebar.dialogs.deleteSession.title': 'セッションを削除しますか?', @@ -2896,6 +2898,9 @@ export const dict: Record = { 'common.relative.daysAgoCompact': '{count}日前', 'common.relative.weeksAgoCompact': '{count}週前', 'common.relative.yearsAgoCompact': '{count}年前', + 'common.duration.secondsCompact': '{seconds}秒', + 'common.duration.minutesSecondsCompact': '{minutes}分{seconds}秒', + 'common.duration.hoursMinutesCompact': '{hours}時間{minutes}分', 'contextFileOpen.failure.tooLarge': 'ファイルが大きすぎて開けません(>{count}行)', 'contextFileOpen.failure.missing': 'ファイルが見つかりません', 'contextFileOpen.failure.unreadable': 'ファイルを開けませんでした', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 03532bc5..0f09fba2 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -531,6 +531,8 @@ export const dict: Record = { 'sessions.sidebar.session.status.pinned': '고정된 세션', 'sessions.sidebar.session.status.movingToWorktree': '세션을 새 worktree로 이동하는 중', 'sessions.sidebar.session.status.permissionRequired': '권한 필요', + 'sessions.sidebar.session.status.activeFor': '{duration} 동안 활성 상태', + 'sessions.sidebar.session.status.lastTurnDuration': '마지막 턴 소요 시간 {duration}', 'sessions.sidebar.session.subsessions.collapse': '하위 세션 접기', 'sessions.sidebar.session.subsessions.expand': '하위 세션 펼치기', 'sessions.sidebar.dialogs.deleteSession.title': '세션 삭제?', @@ -2900,6 +2902,9 @@ export const dict: Record = { 'common.relative.daysAgoCompact': '{count}d ago', 'common.relative.weeksAgoCompact': '{count}w ago', 'common.relative.yearsAgoCompact': '{count}y ago', + 'common.duration.secondsCompact': '{seconds}초', + 'common.duration.minutesSecondsCompact': '{minutes}분 {seconds}초', + 'common.duration.hoursMinutesCompact': '{hours}시간 {minutes}분', 'contextFileOpen.failure.tooLarge': 'File is too large to open (>{count} lines)', 'contextFileOpen.failure.missing': 'File not found', 'contextFileOpen.failure.unreadable': 'Failed to open file', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 245cbc5f..12f727b1 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -531,6 +531,8 @@ export const dict: Record = { 'sessions.sidebar.session.status.pinned': 'Przypięta sesja', 'sessions.sidebar.session.status.movingToWorktree': 'Przenoszenie sesji do nowego worktree', 'sessions.sidebar.session.status.permissionRequired': 'Wymagane uprawnienie', + 'sessions.sidebar.session.status.activeFor': 'Aktywna od {duration}', + 'sessions.sidebar.session.status.lastTurnDuration': 'Ostatnia tura trwała {duration}', 'sessions.sidebar.session.subsessions.collapse': 'Zwiń pod-sesje', 'sessions.sidebar.session.subsessions.expand': 'Rozwiń pod-sesje', 'sessions.sidebar.dialogs.deleteSession.title': 'Usunąć sesję?', @@ -2917,6 +2919,9 @@ export const dict: Record = { 'common.relative.daysAgoCompact': '{count}d ago', 'common.relative.weeksAgoCompact': '{count}w ago', 'common.relative.yearsAgoCompact': '{count}y ago', + 'common.duration.secondsCompact': '{seconds}s', + 'common.duration.minutesSecondsCompact': '{minutes}m {seconds}s', + 'common.duration.hoursMinutesCompact': '{hours}h {minutes}m', 'contextFileOpen.failure.tooLarge': 'File is too large to open (>{count} lines)', 'contextFileOpen.failure.missing': 'File not found', 'contextFileOpen.failure.unreadable': 'Failed to open file', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 42098a94..10dd7709 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -531,6 +531,8 @@ export const dict: Record = { "sessions.sidebar.session.status.pinned": "Sessão fixada", "sessions.sidebar.session.status.movingToWorktree": "Movendo a sessão para um novo worktree", "sessions.sidebar.session.status.permissionRequired": "Permissão obrigatória", + "sessions.sidebar.session.status.activeFor": "Ativa há {duration}", + "sessions.sidebar.session.status.lastTurnDuration": "O último turno levou {duration}", "sessions.sidebar.session.subsessions.collapse": "Recolher subsessões", "sessions.sidebar.session.subsessions.expand": "Expandir subsessões", "sessions.sidebar.dialogs.deleteSession.title": "Excluir sessão?", @@ -2901,6 +2903,9 @@ export const dict: Record = { "common.relative.daysAgoCompact": "{count}d ago", "common.relative.weeksAgoCompact": "{count}w ago", "common.relative.yearsAgoCompact": "{count}y ago", + "common.duration.secondsCompact": "{seconds}s", + "common.duration.minutesSecondsCompact": "{minutes}m {seconds}s", + "common.duration.hoursMinutesCompact": "{hours}h {minutes}m", "contextFileOpen.failure.tooLarge": "File is too large to open (>{count} lines)", "contextFileOpen.failure.missing": "File not found", "contextFileOpen.failure.unreadable": "Failed to open file", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 1143d4c0..dd52bcb9 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -531,6 +531,8 @@ export const dict: Record = { "sessions.sidebar.session.status.pinned": "Закріплений сесія", "sessions.sidebar.session.status.movingToWorktree": "Перенесення сесії в новий worktree", "sessions.sidebar.session.status.permissionRequired": "Потрібен дозвіл", + "sessions.sidebar.session.status.activeFor": "Активна вже {duration}", + "sessions.sidebar.session.status.lastTurnDuration": "Останній хід тривав {duration}", "sessions.sidebar.session.subsessions.collapse": "Згорнути підсесії", "sessions.sidebar.session.subsessions.expand": "Розгорнути підсесії", "sessions.sidebar.dialogs.deleteSession.title": "Видалити сесія?", @@ -2901,6 +2903,9 @@ export const dict: Record = { "common.relative.daysAgoCompact": "{count}d ago", "common.relative.weeksAgoCompact": "{count}w ago", "common.relative.yearsAgoCompact": "{count}y ago", + "common.duration.secondsCompact": "{seconds}с", + "common.duration.minutesSecondsCompact": "{minutes}хв {seconds}с", + "common.duration.hoursMinutesCompact": "{hours}год {minutes}хв", "contextFileOpen.failure.tooLarge": "File is too large to open (>{count} lines)", "contextFileOpen.failure.missing": "File not found", "contextFileOpen.failure.unreadable": "Failed to open file", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 7e9fa6c9..35de2c9c 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -531,6 +531,8 @@ export const dict: Record = { 'sessions.sidebar.session.status.pinned': '已置顶会话', 'sessions.sidebar.session.status.movingToWorktree': '正在将会话移至新工作树', 'sessions.sidebar.session.status.permissionRequired': '需要权限', + 'sessions.sidebar.session.status.activeFor': '已活动 {duration}', + 'sessions.sidebar.session.status.lastTurnDuration': '上一轮耗时 {duration}', 'sessions.sidebar.session.subsessions.collapse': '折叠子会话', 'sessions.sidebar.session.subsessions.expand': '展开子会话', 'sessions.sidebar.dialogs.deleteSession.title': '删除会话?', @@ -2901,6 +2903,9 @@ export const dict: Record = { 'common.relative.daysAgoCompact': '{count}d ago', 'common.relative.weeksAgoCompact': '{count}w ago', 'common.relative.yearsAgoCompact': '{count}y ago', + 'common.duration.secondsCompact': '{seconds}秒', + 'common.duration.minutesSecondsCompact': '{minutes}分{seconds}秒', + 'common.duration.hoursMinutesCompact': '{hours}小时{minutes}分', 'contextFileOpen.failure.tooLarge': 'File is too large to open (>{count} lines)', 'contextFileOpen.failure.missing': 'File not found', 'contextFileOpen.failure.unreadable': 'Failed to open file', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 35e3389d..6641606b 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -544,6 +544,8 @@ export const dict: Record = { 'sessions.sidebar.session.status.pinned': '已釘選會話', 'sessions.sidebar.session.status.movingToWorktree': '正在將會話移至新工作樹', 'sessions.sidebar.session.status.permissionRequired': '需要權限', + 'sessions.sidebar.session.status.activeFor': '已活動 {duration}', + 'sessions.sidebar.session.status.lastTurnDuration': '上一輪耗時 {duration}', 'sessions.sidebar.session.subsessions.collapse': '摺疊子會話', 'sessions.sidebar.session.subsessions.expand': '展開子會話', 'sessions.sidebar.dialogs.deleteSession.title': '刪除會話?', @@ -2900,6 +2902,9 @@ export const dict: Record = { 'common.relative.daysAgoCompact': '{count}d ago', 'common.relative.weeksAgoCompact': '{count}w ago', 'common.relative.yearsAgoCompact': '{count}y ago', + 'common.duration.secondsCompact': '{seconds}秒', + 'common.duration.minutesSecondsCompact': '{minutes}分{seconds}秒', + 'common.duration.hoursMinutesCompact': '{hours}小時{minutes}分', 'contextFileOpen.failure.tooLarge': 'File is too large to open (>{count} lines)', 'contextFileOpen.failure.missing': 'File not found', 'contextFileOpen.failure.unreadable': 'Failed to open file', diff --git a/packages/ui/src/sync/DOCUMENTATION.md b/packages/ui/src/sync/DOCUMENTATION.md index cc9b4abb..5699fc93 100644 --- a/packages/ui/src/sync/DOCUMENTATION.md +++ b/packages/ui/src/sync/DOCUMENTATION.md @@ -45,6 +45,7 @@ So: | `SessionMessageLoader` | Initial message loading, pagination, prefetch, retries, load state, and optimistic reconciliation | One runtime, directory, and session ID | | `global-session-status.ts` | Incremental non-idle session status index reconciled from events and authoritative directory snapshots | All known directories in the active runtime | | `session-ordering.ts` | Ephemeral lifecycle rank used by every user-visible session list | All known sessions in the active runtime | +| `session-activity-timing.ts` | Elapsed time of the running turn and of the turn that just finished, plus the persisted starts that survive a reload | All known sessions in the active runtime | | `session-ui-store.ts` | Session selection, draft lifecycle, abort prompts, worktree metadata, SDK-facing action entrypoints | App UI state | | `useGlobalSessionsStore.ts` | Global active sessions, global archived sessions, `sessionsByDirectory` | All opened project/worktree session lists | | `viewport-store.ts` | Scroll anchors, session memory, loading indicators | App UI state | @@ -128,6 +129,16 @@ Cross-directory selectors subscribe to the narrow child-store field they aggrega Session display order is independent from streaming-frequency `time.updated` publications. `session-ordering.ts` promotes a session exactly when its authoritative activity phase crosses `settled` (`idle`/`error`) and `active` (`busy`/`retry`) in either direction. Repeated busy/retry or idle/error events are no-ops. The first authoritative status snapshot establishes a baseline without synthetic promotions; later snapshots reconcile missed transitions. Root sessions compare lifecycle rank only with other roots, while child sessions compare lifecycle rank only with siblings sharing the same `parentID`, so child activity never moves its root conversation. Pins remain the first ordering bucket. The timestamp/creation fallback is frozen when a session first participates in ordering, so later metadata-only updates cannot reorder it; creation time and ID provide deterministic ties. Runtime switches clear all phases, baselines, and ranks. +`session-activity-timing.ts` measures how long a turn has been running, because `SessionStatus` carries no timestamps. It is driven from the same two write paths as `global-session-status.ts`, so a row can never count a turn that index calls idle. A session gains a start on its first `active` observation and keeps it across repeated busy/retry events; settling converts that start into a finished duration, which rows show only while the session is unread and which is therefore never persisted. + +Starts are persisted so a reload resumes the same count, but a persisted start is a lookup table and never a claim of activity. **Nothing in the protocol marks where a turn begins.** OpenCode calls `SessionStatus.set` with `busy` at every step of the agent loop and publishes an event each time, so a busy event means "still running", not "just started"; after a refresh one of those repeats normally beats the first status snapshot, so treating it as a turn boundary reset the counter on nearly every reload. Turn *ends* are marked — `session.idle` and `session.error` fire once, live, and retire the persisted record — while a snapshot that omits a session is not evidence of anything, since it may simply not see it yet. + +That leaves the case with no observable answer: a turn that ended, and another that began, entirely while the tab was gone. Two bounds stand in for the evidence the client cannot have. A liveness stamp sits beside the start — refreshed while the session is observed active, at most every 15s, and stamped precisely as the page hides (`pagehide`/`visibilitychange`/`freeze`, written immediately rather than through deferred storage so it cannot lose that race) — and is compared against this page's `performance.timeOrigin`, so the measure is how long the app was absent rather than how long bootstrap took; a 20-second startup must not spend the allowance. Records may only be adopted within 90s of load, after which they are discarded — a backstop for a runtime whose event stream is down and where snapshots are therefore the only signal. A runtime switch resets the module, since the previous instance's turns are not ours. + +Reconciliation walks the running turns and asks the snapshot whether it covers each one, rather than being handed everything the snapshot covers. Only a live start can settle, and there are a handful of those against a directory's hundreds of sessions, so the pass stays proportional to the timing work and allocates nothing per poll. Malformed, wrong-shaped, over-age, and future-dated entries are rejected on read. The payload is not runtime-scoped: records live for seconds and are keyed by instance-unique session IDs, whereas the runtime key is derived from injected globals and is not guaranteed stable across early startup — a read under a key the previous page never wrote to is indistinguishable from "no turn was running". + +**Only the stamp expires a persisted start.** A snapshot that covers a session without reporting it busy is not proof the turn ended: bootstrap fetches status and sessions in parallel and directory scopes resolve at different times, so a snapshot legitimately arrives before it can see a running session. Treating one of those as a settle deleted the start moments before the real busy snapshot arrived, which reset every counter to zero on reload. Settles therefore act only on sessions that already have a live start in this page session. + The active-session watchdog in `sync-context.tsx` (per-directory status polls and child-session discovery lists) runs its network calls through the shared background-network gate in `@/lib/background-network`, alongside poll-shaped git reads, global session pages, and command/skill discovery. Background fan-out must stay under that gate so the browser's per-origin connection pool keeps free sockets for interactive traffic — an uncapped startup burst previously queued the first session-open message fetch for seconds. Imperative cross-directory session lookups use the cached ID index from `getAllSyncSessionMap()`. The index is rebuilt only when a child store's `state.session` reference changes; permission lineage checks must reuse it instead of rebuilding a full session map per call. diff --git a/packages/ui/src/sync/global-session-status.ts b/packages/ui/src/sync/global-session-status.ts index e2601bcf..ee458ade 100644 --- a/packages/ui/src/sync/global-session-status.ts +++ b/packages/ui/src/sync/global-session-status.ts @@ -6,6 +6,11 @@ import { reconcileSessionActivitySnapshot, removeSessionOrdering, } from './session-ordering'; +import { + observeSessionActivityTiming, + reconcileSessionActivityTiming, + removeSessionActivityTiming, +} from './session-activity-timing'; // Shared live busy/retry index for every directory. Global events update it // incrementally and authoritative directory snapshots reconcile it, so each @@ -74,6 +79,8 @@ export const applyGlobalSessionStatusEvent = (directory: string, payload: Event) type === 'idle' ? { type: 'idle' } : { ...(props.status ?? {}), type } as SessionStatus, ); observeSessionActivityEvent(props.sessionID, type === 'idle' ? 'settled' : 'active'); + // `retry` is still a running turn, so the elapsed counter keeps going. + observeSessionActivityTiming(props.sessionID, type === 'idle' ? 'settled' : 'active'); return; } case 'session.idle': @@ -82,13 +89,17 @@ export const applyGlobalSessionStatusEvent = (directory: string, payload: Event) if (typeof props?.sessionID === 'string' && props.sessionID) { setStatus(props.sessionID, normalizeDirectory(directory), { type: 'idle' }); observeSessionActivityEvent(props.sessionID, 'settled'); + observeSessionActivityTiming(props.sessionID, 'settled'); } return; } case 'session.deleted': { const props = payload.properties as { sessionID?: string; info?: { id?: string } } | undefined; const sessionId = props?.sessionID ?? props?.info?.id; - if (sessionId) removeSessionOrdering(sessionId); + if (sessionId) { + removeSessionOrdering(sessionId); + removeSessionActivityTiming(sessionId); + } return; } default: @@ -108,10 +119,21 @@ export const applyGlobalSessionStatusSnapshot = ( ): void => { const directory = normalizeDirectory(rawDirectory); const known = new Set(knownSessionIds ?? []); - const activeSessionIds = Object.entries(raw) - .filter(([, status]) => normalizeStatusType(status?.type) !== 'idle') - .map(([sessionId]) => sessionId); + // Built once as a set and shared by both consumers below; only non-idle + // sessions land here, so it stays small however long the directory's list is. + const activeSessionIds = new Set(); + for (const [sessionId, status] of Object.entries(raw)) { + if (normalizeStatusType(status?.type) !== 'idle') activeSessionIds.add(sessionId); + } reconcileSessionActivitySnapshot(activeSessionIds, known); + // Timing asks the coverage question instead of being handed a list: a snapshot + // authoritatively covers the caller's session list plus every id it reports + // itself, and only the handful of sessions actually being timed need an + // answer. Reuses the sets already built above, so this allocates nothing. + reconcileSessionActivityTiming( + activeSessionIds, + (sessionId) => known.has(sessionId) || sessionId in raw, + ); useGlobalSessionStatusStore.setState((state) => { let changed = false; const next = new Map(state.statusById); diff --git a/packages/ui/src/sync/session-activity-timing.test.ts b/packages/ui/src/sync/session-activity-timing.test.ts new file mode 100644 index 00000000..cd01a4d4 --- /dev/null +++ b/packages/ui/src/sync/session-activity-timing.test.ts @@ -0,0 +1,310 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; + +import { getSafeStorage } from '@/stores/utils/safeStorage'; +import { applyGlobalSessionStatusSnapshot } from './global-session-status'; +import { + observeSessionActivityTiming, + reconcileSessionActivityTiming, + removeSessionActivityTiming, + resetSessionActivityTiming, + useSessionActivityTimingStore, +} from './session-activity-timing'; + +const STORAGE_KEY = 'oc.session-activity.v1'; + +const startedAt = (sessionId: string): number | undefined => + useSessionActivityTimingStore.getState().startedAt.get(sessionId); + +const settledMs = (sessionId: string): number | undefined => + useSessionActivityTimingStore.getState().settledMs.get(sessionId); + +type PersistedStart = { start: number; seen: number }; + +const readPersisted = (): Record | null => { + const raw = getSafeStorage().getItem(STORAGE_KEY); + return raw ? (JSON.parse(raw) as Record) : null; +}; + +/** + * Seed a previous page session's record, then simulate the reload. + * `loadedAgoMs` places this page's navigation start in the past, which is how a + * slow bootstrap or an expired adoption window is expressed. + */ +const seedReload = (payload: unknown, loadedAgoMs = 0): void => { + getSafeStorage().setItem(STORAGE_KEY, JSON.stringify(payload)); + resetSessionActivityTiming({ pageLoadAt: Date.now() - loadedAgoMs }); +}; + +/** A status snapshot: which sessions it reports busy, and which it covers. */ +const snapshot = (activeIds: string[], coveredIds: string[] = activeIds): void => { + const covered = new Set(coveredIds); + reconcileSessionActivityTiming(new Set(activeIds), (sessionId) => covered.has(sessionId)); +}; + +/** A record for a turn that began `ageMs` ago and was alive until the reload. */ +const runningUntilReload = (ageMs: number, loadedAgoMs = 0, quietFor = 1_000): PersistedStart => ({ + start: Date.now() - ageMs, + seen: Date.now() - loadedAgoMs - quietFor, +}); + +beforeEach(() => { + getSafeStorage().removeItem(STORAGE_KEY); + resetSessionActivityTiming(); +}); + +afterEach(() => { + getSafeStorage().removeItem(STORAGE_KEY); + resetSessionActivityTiming(); +}); + +describe('session activity timing', () => { + test('starts a turn on the first active observation and keeps it stable', () => { + observeSessionActivityTiming('ses_a', 'active'); + const first = startedAt('ses_a'); + expect(first).toBeGreaterThan(0); + + // Repeated busy/retry status events must not restart the counter. + observeSessionActivityTiming('ses_a', 'active'); + expect(startedAt('ses_a')).toBe(first); + }); + + test('settling converts the start into a duration', () => { + observeSessionActivityTiming('ses_a', 'active'); + observeSessionActivityTiming('ses_a', 'settled'); + + expect(startedAt('ses_a')).toBe(undefined); + expect(settledMs('ses_a')).toBeGreaterThanOrEqual(0); + }); + + test('a new turn clears the previous settled duration', () => { + observeSessionActivityTiming('ses_a', 'active'); + observeSessionActivityTiming('ses_a', 'settled'); + expect(settledMs('ses_a')).toBeDefined(); + + observeSessionActivityTiming('ses_a', 'active'); + expect(settledMs('ses_a')).toBe(undefined); + expect(startedAt('ses_a')).toBeDefined(); + }); + + test('settling a session that was never observed active yields no duration', () => { + observeSessionActivityTiming('ses_a', 'settled'); + + expect(startedAt('ses_a')).toBe(undefined); + expect(settledMs('ses_a')).toBe(undefined); + }); + + test('snapshot reconciliation starts covered actives and settles the rest', () => { + observeSessionActivityTiming('ses_a', 'active'); + observeSessionActivityTiming('ses_b', 'active'); + + snapshot(['ses_a'], ['ses_a', 'ses_b', 'ses_c']); + + expect(startedAt('ses_a')).toBeDefined(); + expect(startedAt('ses_b')).toBe(undefined); + expect(settledMs('ses_b')).toBeGreaterThanOrEqual(0); + // Never active, never covered by a start: nothing to report. + expect(settledMs('ses_c')).toBe(undefined); + }); + + test('a session outside the snapshot scope keeps running', () => { + observeSessionActivityTiming('ses_other_directory', 'active'); + const start = startedAt('ses_other_directory'); + + snapshot([], ['ses_a']); + + expect(startedAt('ses_other_directory')).toBe(start); + }); + + test('persists the start and a liveness stamp for a running turn', () => { + observeSessionActivityTiming('ses_a', 'active'); + + const persisted = readPersisted(); + expect(persisted?.ses_a.start).toBe(startedAt('ses_a') as number); + expect(persisted?.ses_a.seen).toBeGreaterThanOrEqual(persisted?.ses_a.start as number); + }); + + test('clears the persisted record when the turn ends', () => { + observeSessionActivityTiming('ses_a', 'active'); + observeSessionActivityTiming('ses_a', 'settled'); + + expect(readPersisted()).toBeNull(); + }); + + test('resumes a persisted start when a status snapshot reports the session active', () => { + const record = runningUntilReload(90_000); + seedReload({ ses_a: record }); + + snapshot(['ses_a'], ['ses_a']); + + expect(startedAt('ses_a')).toBe(record.start); + }); + + // Regression: the server re-publishes `session.status: busy` at every step of + // the agent loop, so after a reload one of those repeats normally arrives + // before the first status snapshot. Reading a busy event as "a turn just + // started" therefore reset the counter on almost every refresh. + test('resumes when a repeated busy event arrives before the first snapshot', () => { + const record = runningUntilReload(90_000); + seedReload({ ses_a: record }); + + observeSessionActivityTiming('ses_a', 'active'); + + expect(startedAt('ses_a')).toBe(record.start); + }); + + test('the turn after a resumed one still counts from zero', () => { + const record = runningUntilReload(90_000); + seedReload({ ses_a: record }); + + // Reload lands mid-turn: the snapshot resumes it… + snapshot(['ses_a'], ['ses_a']); + expect(startedAt('ses_a')).toBe(record.start); + + // …it finishes, which retires the record, so the next turn starts fresh. + observeSessionActivityTiming('ses_a', 'settled'); + const before = Date.now(); + observeSessionActivityTiming('ses_a', 'active'); + + expect(startedAt('ses_a')).toBeGreaterThanOrEqual(before); + }); + + test('a live idle event retires the persisted record', () => { + const record = runningUntilReload(90_000); + seedReload({ ses_a: record }); + + // The turn ended while the tab was gone; the event arrives on reconnect. + observeSessionActivityTiming('ses_a', 'settled'); + // A later snapshot must not resurrect the retired start. + const before = Date.now(); + snapshot(['ses_a'], ['ses_a']); + + expect(startedAt('ses_a')).toBeGreaterThanOrEqual(before); + }); + + // The absence is measured from navigation start, not from "now", so a slow + // bootstrap on a slow machine cannot spend the whole allowance before the + // first status snapshot arrives. + test('resumes even when bootstrap takes most of a minute', () => { + const loadedAgoMs = 45_000; + const record = runningUntilReload(300_000, loadedAgoMs); + seedReload({ ses_a: record }, loadedAgoMs); + + snapshot(['ses_a'], ['ses_a']); + + expect(startedAt('ses_a')).toBe(record.start); + }); + + test('does not adopt a record once the adoption window has passed', () => { + const loadedAgoMs = 5 * 60_000; + const record = runningUntilReload(300_000, loadedAgoMs); + seedReload({ ses_a: record }, loadedAgoMs); + + // A turn starting this long after load is a new turn, not the one that was + // running before the reload. + const before = Date.now(); + snapshot(['ses_a'], ['ses_a']); + + expect(startedAt('ses_a')).toBeGreaterThanOrEqual(before); + }); + + // Regression: bootstrap fetches status and sessions in parallel, so a + // snapshot can legitimately cover a session before it can see it busy. + // Treating that as "the turn ended" used to destroy the persisted start + // moments before the real busy snapshot arrived, resetting the counter to 0s. + test('an early snapshot that cannot see the session busy does not lose the start', () => { + const record = runningUntilReload(120_000); + seedReload({ ses_a: record }); + + applyGlobalSessionStatusSnapshot('/repo', {}, ['ses_a']); + applyGlobalSessionStatusSnapshot('/repo', { ses_a: { type: 'busy' } }, ['ses_a']); + + expect(startedAt('ses_a')).toBe(record.start); + }); + + test('resumes through a snapshot that arrives before the session list loads', () => { + const record = runningUntilReload(120_000); + seedReload({ ses_a: record }); + + applyGlobalSessionStatusSnapshot('/repo', { ses_a: { type: 'busy' } }, []); + + expect(startedAt('ses_a')).toBe(record.start); + }); + + test('does not resume a record whose liveness stamp has gone quiet', () => { + const before = Date.now(); + seedReload({ ses_a: { start: before - 300_000, seen: before - 240_000 } }); + + snapshot(['ses_a'], ['ses_a']); + + expect(startedAt('ses_a')).toBeGreaterThanOrEqual(before); + }); + + test('does not resume a turn older than the maximum turn age', () => { + const before = Date.now(); + seedReload({ ses_a: { start: before - 48 * 60 * 60 * 1000, seen: before - 1_000 } }); + + snapshot(['ses_a'], ['ses_a']); + + expect(startedAt('ses_a')).toBeGreaterThanOrEqual(before); + }); + + test('ignores malformed persisted payloads', () => { + getSafeStorage().setItem(STORAGE_KEY, 'not json'); + resetSessionActivityTiming(); + + const before = Date.now(); + snapshot(['ses_a'], ['ses_a']); + + expect(startedAt('ses_a')).toBeGreaterThanOrEqual(before); + }); + + test('ignores entries of the wrong shape or dated in the future', () => { + const before = Date.now(); + seedReload({ + ses_a: before - 5_000, + ses_b: { start: 'nope', seen: before }, + ses_c: { start: before + 60_000, seen: before }, + ses_d: { start: before - 5_000, seen: before + 60_000 }, + }); + + for (const sessionId of ['ses_a', 'ses_b', 'ses_c', 'ses_d']) { + snapshot([sessionId]); + expect(startedAt(sessionId)).toBeGreaterThanOrEqual(before); + } + }); + + test('a quiet record ages out of storage on the next write', () => { + const before = Date.now(); + seedReload({ ses_quiet: { start: before - 300_000, seen: before - 240_000 } }); + + observeSessionActivityTiming('ses_a', 'active'); + + expect(readPersisted()?.ses_quiet).toBe(undefined); + expect(readPersisted()?.ses_a.start).toBeDefined(); + }); + + test('deleting a session clears live, settled, and persisted timing', () => { + observeSessionActivityTiming('ses_a', 'active'); + observeSessionActivityTiming('ses_b', 'active'); + observeSessionActivityTiming('ses_b', 'settled'); + + removeSessionActivityTiming('ses_a'); + removeSessionActivityTiming('ses_b'); + + expect(startedAt('ses_a')).toBe(undefined); + expect(settledMs('ses_b')).toBe(undefined); + expect(readPersisted()).toBeNull(); + }); + + test('unrelated sessions keep their map references across a no-op update', () => { + observeSessionActivityTiming('ses_a', 'active'); + const before = useSessionActivityTimingStore.getState(); + + observeSessionActivityTiming('ses_a', 'active'); + observeSessionActivityTiming('ses_unknown', 'settled'); + + const after = useSessionActivityTimingStore.getState(); + expect(after.startedAt).toBe(before.startedAt); + expect(after.settledMs).toBe(before.settledMs); + }); +}); diff --git a/packages/ui/src/sync/session-activity-timing.ts b/packages/ui/src/sync/session-activity-timing.ts new file mode 100644 index 00000000..288696c4 --- /dev/null +++ b/packages/ui/src/sync/session-activity-timing.ts @@ -0,0 +1,444 @@ +import { useCallback } from 'react'; +import { create } from 'zustand'; +import { getSafeStorage } from '@/stores/utils/safeStorage'; + +// Per-session turn timing behind the sidebar activity readout. +// +// The OpenCode status contract carries no timestamps — `SessionStatus` is a +// bare `busy | retry | idle` union — so how long the current turn has been +// running has to be measured on the client. This module owns that measurement +// and is driven from the same two write paths as `global-session-status`, the +// index rows actually render their live state from, so a row can never count a +// turn that index calls idle. +// +// Two maps with deliberately different lifetimes: +// +// - `startedAt` — sessions observed active right now. Persisted, so reloading +// the page resumes the same count instead of restarting it at zero. +// - `settledMs` — how long the turn that just finished took. In memory only: +// rows show it while the session is unread, and unread state itself does not +// survive a reload, so persisting it would outlive its only consumer. +// +// A persisted start is a lookup table, never a claim of activity. Nothing in +// the protocol marks where a turn begins: the server calls `SessionStatus.set` +// with `busy` at every step of the agent loop and publishes an event each time, +// so a busy event means "still running", not "just started" — it cannot be read +// as a turn boundary, and reading it that way reset every counter on reload, +// because after a refresh one of those repeats almost always beats the first +// status snapshot. +// +// Turn *ends* are marked: `session.idle` and `session.error` events fire once, +// live, and retire the persisted record. +// +// That leaves the case with no observable answer at all: a turn that ended, and +// another that began, entirely while the tab was gone. Two bounds stand in for +// the evidence the client cannot have: +// +// - a liveness stamp beside the start, refreshed while the session is observed +// active and stamped precisely as the page hides, compared against this page's +// navigation start — how long the app was actually absent; +// - an adoption window after load, after which unclaimed records are discarded, +// which backstops a runtime whose event stream is down and where snapshots are +// therefore the only signal. +// +// Nothing else may drop a persisted start. Status snapshots legitimately arrive +// before they can see a session as busy — bootstrap fetches status and sessions +// in parallel, directory scopes resolve at different times — and treating one +// of those as "the turn ended" destroyed the start moments before the real busy +// snapshot arrived, which is exactly the reload-resets-to-zero bug. Absence of +// evidence is not evidence here; only the two bounds above expire a record. + +type SessionActivityPhase = 'active' | 'settled'; + + +type SessionActivityTimingState = { + startedAt: ReadonlyMap; + settledMs: ReadonlyMap; +}; + +/** Persisted per session: when this turn began, and when it was last alive. */ +type PersistedStart = { start: number; seen: number }; + +/** Finished turns worth remembering at once; each row only needs its own. */ +const SETTLED_LIMIT = 200; +/** A turn running longer than this is treated as a stale record, not a turn. */ +const MAX_TURN_AGE_MS = 24 * 60 * 60 * 1000; +/** + * How long the app may have been gone and still have its counters resumed, + * measured from the liveness stamp to this page's navigation start — not to + * "now". Bootstrap latency belongs to this page, not to the absence, and this + * client has seen 20-second startups; charging those to the gap would refuse + * a legitimate resume on exactly the slowest machines. + */ +const MAX_AWAY_MS = 30_000; +/** Refresh the persisted stamp at most this often during a long turn. */ +const LIVENESS_PERSIST_INTERVAL_MS = 15_000; +/** + * How long after page load a persisted record may still be adopted. Past this + * point the app has certainly seen live status, so a record nothing claimed + * describes a turn that is over — and a turn starting later is a new one that + * must count from zero. + */ +const RESTORE_ADOPTION_WINDOW_MS = 90_000; +// One key, not one per runtime. These records live for seconds and are keyed by +// instance-unique session IDs, so runtime scoping bought nothing while adding a +// real failure mode: the runtime key is derived from injected globals and is not +// guaranteed stable across early startup, and a read under a key the previous +// page did not write to looks exactly like "no turn was running". +const STORAGE_KEY = 'oc.session-activity.v1'; + +const EMPTY_ACTIVE: ReadonlySet = new Set(); +const EMPTY_RESTORED: ReadonlyMap = new Map(); + +export const useSessionActivityTimingStore = create(() => ({ + startedAt: new Map(), + settledMs: new Map(), +})); + +/** Last moment each live start was observed active, for the liveness stamp. */ +const liveSeen = new Map(); +let lastPersistAt = 0; + +// --------------------------------------------------------------------------- +// Persistence +// --------------------------------------------------------------------------- + +let restoredStarts: Map | null = null; + +/** Epoch ms of this page's navigation start; the reference for "how long gone". */ +const readPageLoadAt = (): number => { + if (typeof performance !== 'undefined' && Number.isFinite(performance.timeOrigin)) { + return performance.timeOrigin; + } + return Date.now(); +}; + +let pageLoadAt = readPageLoadAt(); + +const isResumable = (entry: PersistedStart, now: number): boolean => ( + entry.start <= now + && now - entry.start <= MAX_TURN_AGE_MS + && entry.seen <= now + // Negative when this page wrote the stamp itself, which is trivially fresh. + && pageLoadAt - entry.seen <= MAX_AWAY_MS +); + +const parseEntry = (value: unknown): PersistedStart | null => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const { start, seen } = value as { start?: unknown; seen?: unknown }; + if (typeof start !== 'number' || !Number.isFinite(start)) return null; + if (typeof seen !== 'number' || !Number.isFinite(seen)) return null; + return { start, seen }; +}; + +const readRestoredStarts = (): Map => { + const restored = new Map(); + let raw: string | null = null; + try { + raw = getSafeStorage().getItem(STORAGE_KEY); + } catch { + return restored; + } + if (!raw) return restored; + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + // Malformed payload is a failed read, not authoritative "no turns were + // running": live status re-seeds every counter from now either way. + return restored; + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return restored; + + const now = Date.now(); + for (const [sessionId, value] of Object.entries(parsed as Record)) { + const entry = parseEntry(value); + // Rejects stale turns, quiet stamps, and clock-skewed futures rather than + // rendering a counter that reads days long or negative. + if (entry && isResumable(entry, now)) restored.set(sessionId, entry); + } + return restored; +}; + +const getRestoredStarts = (): Map => { + restoredStarts ??= readRestoredStarts(); + return restoredStarts; +}; + +/** + * Restored records still eligible to be adopted. Past the adoption window they + * are dropped for good, so a turn that starts later counts from zero instead of + * inheriting the start of whatever ran before the reload. + */ +const getAdoptableStarts = (now: number): ReadonlyMap => { + if (now - pageLoadAt > RESTORE_ADOPTION_WINDOW_MS) { + restoredStarts?.clear(); + return EMPTY_RESTORED; + } + return getRestoredStarts(); +}; + +// Live starts merged over restored-but-unconfirmed ones, so a reload landing +// before the first authoritative snapshot does not drop the starts that +// snapshot is about to confirm. Restored entries whose stamp has gone quiet are +// dropped here, which is the only way they leave storage. +const persistStarts = (startedAt: ReadonlyMap, now: number): void => { + const payload: Record = {}; + for (const [sessionId, entry] of getRestoredStarts()) { + if (isResumable(entry, now)) payload[sessionId] = entry; + } + for (const [sessionId, start] of startedAt) { + payload[sessionId] = { start, seen: liveSeen.get(sessionId) ?? now }; + } + + lastPersistAt = now; + try { + const storage = getSafeStorage(); + if (Object.keys(payload).length === 0) { + storage.removeItem(STORAGE_KEY); + return; + } + storage.setItem(STORAGE_KEY, JSON.stringify(payload)); + } catch { + // Storage is unavailable or full; counters simply restart after a reload. + } +}; + +// The most accurate liveness stamp available: the page is going away and every +// running turn was still running as of now. Writes are immediate (not deferred) +// so this cannot lose the race against a deferred flush on the same event. +const stampLiveness = (): void => { + const { startedAt } = useSessionActivityTimingStore.getState(); + if (startedAt.size === 0) return; + const now = Date.now(); + for (const sessionId of startedAt.keys()) liveSeen.set(sessionId, now); + persistStarts(startedAt, now); +}; + +let lifecycleHooked = false; + +const ensureLivenessStampOnHide = (): void => { + if (lifecycleHooked || typeof window === 'undefined') return; + lifecycleHooked = true; + try { + // `pagehide` covers unload and bfcache entry; `visibilitychange`/`freeze` + // cover backgrounding and are the reliable ones in WKWebView. No + // `beforeunload` — it would cost bfcache for a stamp the others already + // wrote. + window.addEventListener('pagehide', stampLiveness, { capture: true }); + if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') stampLiveness(); + }); + document.addEventListener('freeze', stampLiveness); + } + } catch { + // Restricted environments can reject listeners; the periodic stamp refresh + // still bounds how quiet a running turn's record can get. + } +}; + +// --------------------------------------------------------------------------- +// Transitions +// --------------------------------------------------------------------------- + +const trimSettled = (settled: Map): void => { + while (settled.size > SETTLED_LIMIT) { + const oldest = settled.keys().next(); + if (oldest.done) return; + settled.delete(oldest.value); + } +}; + +/** + * What ends a turn in this pass. An event names its session outright; a snapshot + * only answers whether it covers a given one — deliberately the cheaper + * question, since the settle loop walks running turns rather than session lists. + * An `event` idle is a live, one-shot "this turn is over"; a snapshot omitting a + * session is not, because it may simply not see it yet. + */ +type SettleInput = + | { source: 'event'; sessionId: string } + | { source: 'snapshot'; isCovered: (sessionId: string) => boolean }; + +const applyTransitions = ( + activeSessionIds: ReadonlySet, + settle: SettleInput | null, +): void => { + const now = Date.now(); + const restored = getAdoptableStarts(now); + const state = useSessionActivityTimingStore.getState(); + + const next: { started: Map | null; settled: Map | null } = { + started: null, + settled: null, + }; + let sawActive = false; + let restoredChanged = false; + + const draftStarted = (): Map => (next.started ??= new Map(state.startedAt)); + const draftSettled = (): Map => (next.settled ??= new Map(state.settledMs)); + + for (const sessionId of activeSessionIds) { + sawActive = true; + liveSeen.set(sessionId, now); + if ((next.started ?? state.startedAt).has(sessionId)) continue; + // Busy carries no turn boundary from either source: the server re-publishes + // `session.status: busy` on every step of the agent loop, so a busy event + // means "still running", not "just started". Both paths therefore prefer a + // persisted start when one survives; only the bounds below expire it. + draftStarted().set(sessionId, restored.get(sessionId)?.start ?? now); + if ((next.settled ?? state.settledMs).has(sessionId)) draftSettled().delete(sessionId); + } + + const settleTurn = (sessionId: string, start: number): void => { + draftStarted().delete(sessionId); + liveSeen.delete(sessionId); + draftSettled().set(sessionId, Math.max(0, now - start)); + }; + + if (settle === null) { + // Nothing ends this pass. + } else if (settle.source === 'event') { + // An idle/error event is a live, unambiguous end of turn, so it also retires + // the persisted record. A snapshot's silence is not: it may simply not see + // the session yet. + if (getRestoredStarts().delete(settle.sessionId)) restoredChanged = true; + const start = state.startedAt.get(settle.sessionId); + // Only a turn watched from its start yields a duration. + if (start !== undefined) settleTurn(settle.sessionId, start); + } else { + // Walk the running turns, not everything the snapshot covers. Only a live + // start can settle, and there are a handful of those against a directory's + // hundreds of sessions — asking "does this snapshot cover that one?" keeps + // the pass proportional to the work instead of to the session list, and + // allocates nothing per poll. + for (const [sessionId, start] of state.startedAt) { + if (activeSessionIds.has(sessionId)) continue; + if (!settle.isCovered(sessionId)) continue; + settleTurn(sessionId, start); + } + } + + if (next.settled) trimSettled(next.settled); + + if (next.started || next.settled) { + useSessionActivityTimingStore.setState({ + startedAt: next.started ?? state.startedAt, + settledMs: next.settled ?? state.settledMs, + }); + } + + if (next.started) { + if (next.started.size > 0) ensureLivenessStampOnHide(); + persistStarts(next.started, now); + return; + } + if (restoredChanged) { + persistStarts(state.startedAt, now); + return; + } + // Nothing structural changed, but a long-running turn still needs its stamp + // refreshed so a reload can tell it apart from one that ended unobserved. + if (sawActive && state.startedAt.size > 0 && now - lastPersistAt >= LIVENESS_PERSIST_INTERVAL_MS) { + persistStarts(state.startedAt, now); + } +}; + +/** + * Event-driven path: one session changed phase, live. Busy repeats throughout a + * turn and carries no boundary; idle/error fire once and end it, which is why + * only settling here retires the persisted record. + */ +export const observeSessionActivityTiming = ( + sessionId: string, + phase: SessionActivityPhase, +): void => { + if (phase === 'active') { + applyTransitions(new Set([sessionId]), null); + return; + } + applyTransitions(EMPTY_ACTIVE, { source: 'event', sessionId }); +}; + +/** + * Authoritative path: a `/session/status` snapshot for one directory. Sessions + * the snapshot covers but does not report active stop their live counters — + * that is what recovers a turn whose end event this client missed — but their + * persisted records survive, because a snapshot that cannot yet see a session + * looks identical to one whose turn is over. + */ +export const reconcileSessionActivityTiming = ( + activeSessionIds: ReadonlySet, + isCoveredBySnapshot: (sessionId: string) => boolean, +): void => { + applyTransitions(activeSessionIds, { source: 'snapshot', isCovered: isCoveredBySnapshot }); +}; + +export const removeSessionActivityTiming = (sessionId: string): void => { + const restoredChanged = getRestoredStarts().delete(sessionId); + const state = useSessionActivityTimingStore.getState(); + const hadStart = state.startedAt.has(sessionId); + const hadSettled = state.settledMs.has(sessionId); + liveSeen.delete(sessionId); + + if (!hadStart && !hadSettled) { + if (restoredChanged) persistStarts(state.startedAt, Date.now()); + return; + } + + let startedAt = state.startedAt; + if (hadStart) { + const draft = new Map(state.startedAt); + draft.delete(sessionId); + startedAt = draft; + } + let settledMs = state.settledMs; + if (hadSettled) { + const draft = new Map(state.settledMs); + draft.delete(sessionId); + settledMs = draft; + } + + useSessionActivityTimingStore.setState({ startedAt, settledMs }); + if (hadStart || restoredChanged) persistStarts(startedAt, Date.now()); +}; + +/** + * Drops in-memory state and the cached restored-start snapshot — i.e. treats + * what follows as a fresh page load. Called on a runtime switch, where the + * previous instance's turns are no longer ours, and by tests. `pageLoadAt` + * overrides the navigation-start reference so tests can place a load in the + * past (slow bootstrap, expired window). + */ +export const resetSessionActivityTiming = (options: { pageLoadAt?: number } = {}): void => { + restoredStarts = null; + liveSeen.clear(); + lastPersistAt = 0; + pageLoadAt = options.pageLoadAt ?? Date.now(); + useSessionActivityTimingStore.setState({ startedAt: new Map(), settledMs: new Map() }); +}; + +// --------------------------------------------------------------------------- +// Leaf subscriptions +// --------------------------------------------------------------------------- + +export const useSessionActivityStartedAt = (sessionId: string): number | undefined => ( + useSessionActivityTimingStore(useCallback((state) => state.startedAt.get(sessionId), [sessionId])) +); + +export const useSessionSettledDurationMs = (sessionId: string): number | undefined => ( + useSessionActivityTimingStore(useCallback((state) => state.settledMs.get(sessionId), [sessionId])) +); + +/** + * Whether a duration exists to render, without subscribing the caller to the + * value itself — a row uses this to decide between the counter and its normal + * metadata, and must not re-render every tick to do so. + */ +export const useHasSessionActivityDuration = (sessionId: string, running: boolean): boolean => ( + useSessionActivityTimingStore(useCallback((state) => ( + running ? state.startedAt.has(sessionId) : state.settledMs.has(sessionId) + ), [running, sessionId])) +);