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.
251 lines
10 KiB
TypeScript
251 lines
10 KiB
TypeScript
import React from 'react';
|
|
import type { Session } from '@opencode-ai/sdk/v2';
|
|
|
|
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';
|
|
import { useI18n } from '@/lib/i18n';
|
|
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';
|
|
|
|
const RECENT_SESSIONS_LIMIT = 10;
|
|
/** Matches the metadata popover's width so both header dropdowns read as a pair. */
|
|
const TABLET_POPOVER_WIDTH = 380;
|
|
|
|
const getSessionTitle = (session: Session, fallback: string): string =>
|
|
session.title?.trim() || fallback;
|
|
|
|
/** One switcher row: live status (busy spinner / attention dot), title,
|
|
"project · branch", compact time. Mirrors the desktop SessionSwitcherDropdown
|
|
indicator conventions; no subsession chevrons on mobile by design. */
|
|
const SwitcherRow: React.FC<{
|
|
session: Session;
|
|
meta: string;
|
|
active: boolean;
|
|
onSelect: () => void;
|
|
}> = ({ session, meta, active, onSelect }) => {
|
|
const { t } = useI18n();
|
|
const status = useGlobalSessionStatus(session.id);
|
|
const unseenCount = useSessionUnseenCount(session.id);
|
|
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 (
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
'flex w-full items-center gap-3 rounded-xl px-2.5 py-2 text-left transition-colors active:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary',
|
|
active && 'bg-[color-mix(in_srgb,var(--primary)_10%,transparent)]',
|
|
)}
|
|
onClick={onSelect}
|
|
style={{ touchAction: 'manipulation' }}
|
|
>
|
|
<span className="flex min-w-0 flex-1 flex-col">
|
|
<span className={cn('block truncate typography-ui-label', active ? 'text-primary' : 'text-foreground')}>
|
|
{getSessionTitle(session, t('sessions.sidebar.session.untitled'))}
|
|
</span>
|
|
{meta ? (
|
|
<span className="block truncate typography-micro text-muted-foreground">{meta}</span>
|
|
) : null}
|
|
</span>
|
|
{/* Activity sits on the right, before the time — no reserved left gutter. */}
|
|
{isStreaming || showUnreadDot ? (
|
|
<span
|
|
className={cn(
|
|
'size-1.5 shrink-0 rounded-full',
|
|
isStreaming ? 'bg-primary' : 'bg-[var(--status-info)]',
|
|
)}
|
|
aria-hidden
|
|
/>
|
|
) : null}
|
|
{/* The elapsed turn takes the time slot while it matters, then hands it
|
|
back to the relative timestamp. */}
|
|
{showActivityDuration ? (
|
|
<SessionActivityDuration
|
|
sessionId={session.id}
|
|
running={isStreaming}
|
|
className="typography-micro"
|
|
/>
|
|
) : timeLabel ? (
|
|
<span className="shrink-0 typography-micro text-muted-foreground tabular-nums">{timeLabel}</span>
|
|
) : null}
|
|
</button>
|
|
);
|
|
};
|
|
|
|
/** Recent-sessions popover under the mobile header, opened by tapping the
|
|
session title. Same visual family as the metadata/usage overlay. */
|
|
export const MobileSessionSwitcher: React.FC<{
|
|
open: boolean;
|
|
onClose: () => void;
|
|
anchorRef: React.RefObject<HTMLElement | null>;
|
|
}> = ({ open, onClose, anchorRef }) => {
|
|
const { t } = useI18n();
|
|
const panelRef = React.useRef<HTMLDivElement>(null);
|
|
const [shouldRender, setShouldRender] = React.useState(open);
|
|
const [isExiting, setIsExiting] = React.useState(false);
|
|
// Tablet: a phone-width sheet stretched across the whole chat column looks
|
|
// broken — anchor a popover under the title instead. Mirror image of the
|
|
// metadata/usage popover, which anchors to the ring on the right.
|
|
const { enabled: isTabletLayout } = useTabletLayout();
|
|
const wrapperRef = React.useRef<HTMLDivElement>(null);
|
|
const [anchorLeft, setAnchorLeft] = React.useState<number | null>(null);
|
|
|
|
// The shell has transformed ancestors, so the fixed wrapper's containing
|
|
// block is the chat column, NOT the viewport — anchor in the wrapper's own
|
|
// coordinate space (see SessionMetadataOverlay for the same reasoning).
|
|
React.useLayoutEffect(() => {
|
|
if (!open || !isTabletLayout || !shouldRender) return;
|
|
const compute = () => {
|
|
const anchorRect = anchorRef.current?.getBoundingClientRect();
|
|
const wrapperRect = wrapperRef.current?.getBoundingClientRect();
|
|
if (!anchorRect || !wrapperRect) {
|
|
setAnchorLeft(null);
|
|
return;
|
|
}
|
|
const relativeLeft = anchorRect.left - wrapperRect.left;
|
|
setAnchorLeft(Math.min(
|
|
Math.max(relativeLeft, 8),
|
|
Math.max(8, wrapperRect.width - TABLET_POPOVER_WIDTH - 8),
|
|
));
|
|
};
|
|
compute();
|
|
// Re-anchor if the chat column shifts while the popover is open (sidebar
|
|
// toggle/resize, orientation change) — the header buttons move with it.
|
|
const wrapper = wrapperRef.current;
|
|
if (typeof ResizeObserver === 'undefined' || !wrapper) return;
|
|
const observer = new ResizeObserver(compute);
|
|
observer.observe(wrapper);
|
|
return () => observer.disconnect();
|
|
}, [anchorRef, isTabletLayout, open, shouldRender]);
|
|
|
|
const isPopover = isTabletLayout && anchorLeft !== null;
|
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
|
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
|
|
const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly);
|
|
|
|
const items = useSwitcherItems(open || shouldRender, { maxParents: RECENT_SESSIONS_LIMIT });
|
|
|
|
React.useEffect(() => {
|
|
if (open) {
|
|
// Fresh authoritative snapshot on open — updated stamps re-sort recents
|
|
// (see raiseSessionOrderingBaselines) while the cached list shows first.
|
|
void refreshGlobalSessions();
|
|
setShouldRender(true);
|
|
setIsExiting(false);
|
|
return;
|
|
}
|
|
if (!shouldRender) return;
|
|
setIsExiting(true);
|
|
const timeoutId = window.setTimeout(() => {
|
|
setShouldRender(false);
|
|
setIsExiting(false);
|
|
}, 140);
|
|
return () => window.clearTimeout(timeoutId);
|
|
}, [open, shouldRender]);
|
|
|
|
React.useEffect(() => {
|
|
if (!open) return;
|
|
const handleKey = (event: KeyboardEvent) => {
|
|
if (event.key === 'Escape') onClose();
|
|
};
|
|
document.addEventListener('keydown', handleKey);
|
|
return () => document.removeEventListener('keydown', handleKey);
|
|
}, [onClose, open]);
|
|
|
|
React.useEffect(() => {
|
|
if (!open) return;
|
|
const closeIfOutside = (event: PointerEvent) => {
|
|
const target = event.target;
|
|
if (!(target instanceof Node)) {
|
|
onClose();
|
|
return;
|
|
}
|
|
if (panelRef.current?.contains(target) || anchorRef.current?.contains(target)) return;
|
|
onClose();
|
|
};
|
|
document.addEventListener('pointerdown', closeIfOutside, true);
|
|
return () => document.removeEventListener('pointerdown', closeIfOutside, true);
|
|
}, [anchorRef, onClose, open]);
|
|
|
|
const handleSelect = React.useCallback((session: Session) => {
|
|
void setCurrentSession(session.id, resolveGlobalSessionDirectory(session));
|
|
onClose();
|
|
}, [onClose, setCurrentSession]);
|
|
|
|
if (!shouldRender) return null;
|
|
|
|
return (
|
|
<div ref={wrapperRef} className="fixed inset-x-0 bottom-0 top-[calc(var(--oc-safe-area-top,0px)+var(--oc-header-height,56px))] z-20 pointer-events-none">
|
|
<div
|
|
ref={panelRef}
|
|
role="dialog"
|
|
aria-label={t('sessions.switcher.openAria')}
|
|
className={cn(
|
|
'flex flex-col overflow-hidden rounded-[20px] border border-border/70 bg-[var(--surface-elevated)] p-2 shadow-[0_12px_32px_rgb(0_0_0_/_0.2)] will-change-transform',
|
|
isPopover ? 'absolute origin-top-left' : 'mx-3 mt-2',
|
|
isExiting ? 'pointer-events-none' : 'pointer-events-auto',
|
|
)}
|
|
style={{
|
|
animation: `${isExiting ? 'session-switcher-out' : 'session-switcher-in'} ${isExiting ? 140 : 170}ms cubic-bezier(0.32, 0.72, 0, 1) forwards`,
|
|
maxHeight: 'min(72dvh, calc(100dvh - var(--oc-safe-area-top, 0px) - var(--oc-header-height, 56px) - 1rem))',
|
|
...(isPopover
|
|
? {
|
|
top: 8,
|
|
left: anchorLeft ?? 8,
|
|
width: `min(${TABLET_POPOVER_WIDTH}px, calc(100% - 16px))`,
|
|
}
|
|
: null),
|
|
}}
|
|
>
|
|
<div className="oc-hide-scrollbar min-h-0 flex-1 space-y-0.5 overflow-y-auto overscroll-contain">
|
|
{items.length === 0 ? (
|
|
<p className="px-3 py-6 text-center typography-small text-muted-foreground">
|
|
{t('sessions.switcher.empty')}
|
|
</p>
|
|
) : (
|
|
items.map((item) => {
|
|
const session = item.node.session;
|
|
const meta = [item.secondaryMeta?.projectLabel, item.secondaryMeta?.branchLabel]
|
|
.filter(Boolean)
|
|
.join(' · ');
|
|
return (
|
|
<SwitcherRow
|
|
key={session.id}
|
|
session={session}
|
|
meta={meta}
|
|
active={session.id === currentSessionId}
|
|
onSelect={() => {
|
|
if (item.projectId) setActiveProjectIdOnly(item.projectId);
|
|
handleSelect(session);
|
|
}}
|
|
/>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</div>
|
|
<style>{`
|
|
@keyframes session-switcher-in {
|
|
from { opacity: 0; transform: translateY(-8px) scale(0.985); }
|
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
|
}
|
|
@keyframes session-switcher-out {
|
|
from { opacity: 1; transform: translateY(0) scale(1); }
|
|
to { opacity: 0; transform: translateY(-6px) scale(0.985); }
|
|
}
|
|
`}</style>
|
|
</div>
|
|
);
|
|
};
|