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:
@@ -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,
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
type Subscriber = (now: number) => void;
|
||||
|
||||
type TickerChannel = {
|
||||
subscribers: Set<Subscriber>;
|
||||
timerId: number | null;
|
||||
};
|
||||
|
||||
const tickerChannels = new Map<number, TickerChannel>();
|
||||
|
||||
const getTickerChannel = (intervalMs: number): TickerChannel => {
|
||||
const existing = tickerChannels.get(intervalMs);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const created: TickerChannel = {
|
||||
subscribers: new Set<Subscriber>(),
|
||||
timerId: null,
|
||||
};
|
||||
tickerChannels.set(intervalMs, created);
|
||||
return created;
|
||||
};
|
||||
|
||||
const subscribeToTicker = (intervalMs: number, subscriber: Subscriber): (() => void) => {
|
||||
const channel = getTickerChannel(intervalMs);
|
||||
channel.subscribers.add(subscriber);
|
||||
subscriber(Date.now());
|
||||
|
||||
if (channel.timerId === null && typeof window !== 'undefined') {
|
||||
channel.timerId = window.setInterval(() => {
|
||||
const now = Date.now();
|
||||
channel.subscribers.forEach((listener) => {
|
||||
listener(now);
|
||||
});
|
||||
}, intervalMs);
|
||||
}
|
||||
|
||||
return () => {
|
||||
const tracked = tickerChannels.get(intervalMs);
|
||||
if (!tracked) {
|
||||
return;
|
||||
}
|
||||
|
||||
tracked.subscribers.delete(subscriber);
|
||||
if (tracked.subscribers.size > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (tracked.timerId !== null && typeof window !== 'undefined') {
|
||||
window.clearInterval(tracked.timerId);
|
||||
}
|
||||
tickerChannels.delete(intervalMs);
|
||||
};
|
||||
};
|
||||
|
||||
export const useDurationTickerNow = (active: boolean, intervalMs: number = 250): number => {
|
||||
const [now, setNow] = React.useState(() => Date.now());
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
return subscribeToTicker(intervalMs, setNow);
|
||||
}, [active, intervalMs]);
|
||||
|
||||
return now;
|
||||
};
|
||||
@@ -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 (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -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<string | nu
|
||||
return false;
|
||||
}, [directorySet]));
|
||||
|
||||
// Aggregate header: dot only. A collapsed project can hold several running
|
||||
// turns, so a single elapsed counter would have nothing to count.
|
||||
if (hasBusySession) {
|
||||
return (
|
||||
<Icon
|
||||
name="loader-4"
|
||||
className="h-3 w-3 animate-spin text-primary"
|
||||
<span
|
||||
className="h-1.5 w-1.5 rounded-full bg-primary"
|
||||
aria-label={t('sessions.sidebar.session.status.active')}
|
||||
title={t('sessions.sidebar.session.status.active')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
};
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 (
|
||||
<Icon
|
||||
name="loader-4"
|
||||
className={cn('h-3 w-3 shrink-0 animate-spin text-primary', className)}
|
||||
aria-label={label}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<span
|
||||
className={cn(
|
||||
'h-1.5 w-1.5 shrink-0 rounded-full',
|
||||
'bg-[var(--status-info)]',
|
||||
state === 'active' ? 'bg-primary' : 'bg-[var(--status-info)]',
|
||||
className,
|
||||
)}
|
||||
aria-label={label}
|
||||
|
||||
Reference in New Issue
Block a user