Files
openchamber/packages/ui/src/components/chat/MobileSessionStatusBar.tsx
T
Bohdan Triapitsyn 85400459e9 perf: overhaul session loading, caching, and runtime isolation (#2360)
Improve OpenChamber responsiveness under large session workloads while fixing
cache, synchronization, and persistence correctness across runtimes, projects,
directories, and worktrees.

- prioritize selected and visible sessions during bootstrap and defer
  non-critical enrichment work
- reduce redundant message loading, event processing, store publication, and
  hidden sidebar work
- prevent stale session and message requests from overwriting newer
  authoritative state
- preserve existing data when authoritative fetches fail instead of treating
  failures as successful empty responses
- scope session materialization, messages, drafts, queues, todos, pins,
  permissions, folders, tabs, Git state, and pull request data by runtime and
  directory identity
- harden runtime switching, reconnect, cleanup, mutation reconciliation, and
  persisted-state ordering
- preserve live subagent Task linkage when metadata arrives after an older
  message request or while streaming parts are suspended
- coalesce overlapping tail refreshes without losing newer refresh demand
- improve cold-session loading by moving deferrable work out of the critical
  bootstrap path
- isolate URL authentication, mobile credentials, native secrets, and other
  runtime-owned state across endpoint changes
- bound long-lived caches and remove avoidable allocations from event and
  rendering hot paths
- limit virtualization to archive collections where it improves rendering
  without disrupting active sidebar layout
- stabilize session folders, pin ordering, expanded state, and persisted
  sidebar behavior
- open skill files through the same secure editor and outside-workspace grant
  flow used by file navigation, including worktree sessions
- expand regression coverage for stale completions, runtime collisions,
  reconnect behavior, persistence races, authoritative empty results, and
  subagent refresh ordering
- document the updated synchronization, cache ownership, performance, and
  runtime-isolation invariants
2026-07-21 20:52:20 +03:00

624 lines
24 KiB
TypeScript

import React from 'react';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useAllSessionStatuses, useAllLiveSessions } from '@/sync/sync-context';
import { mergeLiveSessionWithGlobalSession, useGlobalSessionsStore, ensureGlobalSessionsLoaded, refreshGlobalSessions } from '@/stores/useGlobalSessionsStore';
import { useUIStore } from '@/stores/useUIStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import type { Session } from '@opencode-ai/sdk/v2';
import type { ProjectEntry } from '@/lib/api/types';
import { cn, formatDirectoryName } from '@/lib/utils';
import { PROJECT_ICON_MAP, PROJECT_COLOR_MAP, ProjectIconImage } from '@/lib/projectMeta';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { Icon } from "@/components/icon/Icon";
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { useNotificationStore } from '@/sync/notification-store';
import { useI18n } from '@/lib/i18n';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
interface MobileSessionStatusBarProps {
onSessionSwitch?: (sessionId: string) => void;
}
interface SessionWithStatus extends Session {
_statusType?: 'busy' | 'retry' | 'idle';
_runningChildrenCount?: number;
}
// Cross-project session source. Mirrors the dedicated MobileSessionsSheet:
// global sessions cover all directories (even unbootstrapped ones), while the
// live aggregate (`useAllLiveSessions`) surfaces fresher data and every
// bootstrapped directory. Merging both makes other projects' sessions appear.
function useAllProjectSessions(): Session[] {
const liveSessions = useAllLiveSessions();
const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions);
return React.useMemo(() => {
const liveById = new Map(liveSessions.map((session) => [session.id, session]));
const merged = globalActiveSessions.map((session) => {
const liveSession = liveById.get(session.id);
return liveSession ? mergeLiveSessionWithGlobalSession(liveSession, session) : session;
});
const seen = new Set(merged.map((session) => session.id));
for (const session of liveSessions) {
if (!seen.has(session.id)) merged.push(session);
}
return merged;
}, [globalActiveSessions, liveSessions]);
}
// Max sessions shown per (filtered) project list - a "recent" cap applied
// after filtering, so each project view shows at most this many.
const MAX_RECENT_SESSIONS = 25;
// Normalize path for comparison
const normalize = (value: string): string => {
if (!value) return '';
const replaced = value.replace(/\\/g, '/');
return replaced === '/' ? '/' : replaced.replace(/\/+$/, '');
};
// A session's directory, mirroring the store's canonical resolution.
const sessionDirectory = (session: Session): string => {
const record = session as Session & {
directory?: string | null;
project?: { worktree?: string | null } | null;
};
return normalize(record.directory ?? record.project?.worktree ?? '');
};
// Prefix-match used to group a session under a project root or worktree.
const pathBelongsToRoot = (path: string, root: string): boolean => {
const p = normalize(path);
const r = normalize(root);
return Boolean(p && r && (p === r || p.startsWith(`${r}/`)));
};
function useSessionGrouping(
sessions: Session[],
sessionStatus: Record<string, { type: string }> | undefined
) {
const unseenCounts = useNotificationStore((s) => s.index.session.unseenCount);
const parentChildMap = React.useMemo(() => {
const map = new Map<string, Session[]>();
const allIds = new Set(sessions.map((s) => s.id));
for (const session of sessions) {
const parentID = (session as { parentID?: string }).parentID;
if (parentID && allIds.has(parentID)) {
const children = map.get(parentID);
if (children) children.push(session);
else map.set(parentID, [session]);
}
}
return map;
}, [sessions]);
const getStatusType = React.useCallback((sessionId: string): 'busy' | 'retry' | 'idle' => {
const status = sessionStatus?.[sessionId];
if (status?.type === 'busy' || status?.type === 'retry') return status.type;
return 'idle';
}, [sessionStatus]);
const processedSessions = React.useMemo(() => {
const sessionIds = new Set(sessions.map((s) => s.id));
const topLevel = sessions.filter((session) => {
const parentID = (session as { parentID?: string }).parentID;
return !parentID || !sessionIds.has(parentID);
});
const running: SessionWithStatus[] = [];
const viewed: SessionWithStatus[] = [];
topLevel.forEach((session) => {
const statusType = getStatusType(session.id);
const runningChildrenCount = (parentChildMap.get(session.id) ?? [])
.filter((child) => getStatusType(child.id) !== 'idle')
.length;
const attention = (unseenCounts[session.id] ?? 0) > 0;
const enriched: SessionWithStatus = {
...session,
_statusType: statusType,
_runningChildrenCount: runningChildrenCount,
};
if (statusType !== 'idle' || runningChildrenCount > 0 || attention) {
running.push(enriched);
} else {
viewed.push(enriched);
}
});
const sortByUpdated = (a: Session, b: Session) => {
const aTime = (a as unknown as { time?: { updated?: number } }).time?.updated ?? 0;
const bTime = (b as unknown as { time?: { updated?: number } }).time?.updated ?? 0;
return bTime - aTime;
};
running.sort(sortByUpdated);
viewed.sort(sortByUpdated);
return [...running, ...viewed];
}, [sessions, getStatusType, parentChildMap, unseenCounts]);
const totalRunning = processedSessions.reduce((sum, s) => {
const selfRunning = s._statusType !== 'idle' ? 1 : 0;
return sum + selfRunning + (s._runningChildrenCount ?? 0);
}, 0);
const totalUnread = processedSessions.filter((s) => (unseenCounts[s.id] ?? 0) > 0).length;
return { sessions: processedSessions, totalRunning, totalUnread, totalCount: processedSessions.length };
}
function useSessionHelpers() {
const getSessionTitle = React.useCallback((session: Session): string => {
const title = session.title;
if (title && title.trim()) return title;
return 'New session';
}, []);
const unseenCounts = useNotificationStore((s) => s.index.session.unseenCount);
const needsAttention = React.useCallback((sessionId: string): boolean => {
return (unseenCounts[sessionId] ?? 0) > 0;
}, [unseenCounts]);
return { getSessionTitle, needsAttention };
}
// Per-project status indicators (running / unread) for the filter chips.
function useProjectStatus(
sessionStatus: Record<string, { type: string }> | undefined,
currentSessionId: string | null
) {
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
const getSessionsByDirectory = useSessionUIStore((state) => state.getSessionsByDirectory);
const notifUnseenCounts = useNotificationStore((s) => s.index.session.unseenCount);
return React.useCallback((projectPath: string): { hasRunning: boolean; hasUnread: boolean } => {
const getStatusType = (sessionId: string): 'busy' | 'retry' | 'idle' => {
const status = sessionStatus?.[sessionId];
if (status?.type === 'busy' || status?.type === 'retry') return status.type;
return 'idle';
};
const projectRoot = normalize(projectPath);
if (!projectRoot) return { hasRunning: false, hasUnread: false };
const dirs: string[] = [projectRoot];
const worktrees = availableWorktreesByProject.get(projectRoot) ?? [];
for (const meta of worktrees) {
const p = (meta && typeof meta === 'object' && 'path' in meta) ? (meta as { path?: unknown }).path : null;
if (typeof p === 'string' && p.trim()) {
const normalized = normalize(p);
if (normalized && normalized !== projectRoot) dirs.push(normalized);
}
}
const seen = new Set<string>();
let hasRunning = false;
let hasUnread = false;
for (const dir of dirs) {
for (const session of getSessionsByDirectory(dir)) {
if (!session?.id || seen.has(session.id)) continue;
seen.add(session.id);
if (getStatusType(session.id) !== 'idle') hasRunning = true;
if (session.id !== currentSessionId && (notifUnseenCounts[session.id] ?? 0) > 0) hasUnread = true;
if (hasRunning && hasUnread) break;
}
if (hasRunning && hasUnread) break;
}
return { hasRunning, hasUnread };
}, [getSessionsByDirectory, availableWorktreesByProject, sessionStatus, notifUnseenCounts, currentSessionId]);
}
// Resolves the project's root directories (root + known worktrees) for
// prefix-matching sessions, mirroring the dedicated MobileSessionsSheet.
function useProjectRootsResolver() {
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
return React.useCallback((project: ProjectEntry): string[] => {
const projectRoot = normalize(project.path);
const roots = [projectRoot];
const worktrees = availableWorktreesByProject.get(projectRoot) ?? [];
for (const meta of worktrees) {
const p = (meta && typeof meta === 'object' && 'path' in meta) ? (meta as { path?: unknown }).path : null;
if (typeof p === 'string' && p.trim()) {
const normalized = normalize(p);
if (normalized) roots.push(normalized);
}
}
return roots;
}, [availableWorktreesByProject]);
}
function StatusIndicator({ isRunning, needsAttention }: { isRunning: boolean; needsAttention: boolean }) {
if (isRunning) {
return <Icon name="loader-4" className="h-3.5 w-3.5 animate-spin text-[var(--status-info)]" />;
}
if (needsAttention) {
return <div className="h-2 w-2 rounded-full bg-[var(--status-error)]" />;
}
return <div className="h-2 w-2 rounded-full border border-[var(--surface-mutedForeground)]" />;
}
function RunningIndicator({ count }: { count: number }) {
if (count === 0) return null;
return (
<span className="flex items-center gap-1 text-[13px] text-[var(--status-info)]">
<Icon name="loader-4" className="h-3.5 w-3.5 animate-spin" />
{count}
</span>
);
}
function UnreadIndicator({ count }: { count: number }) {
if (count === 0) return null;
return (
<span className="flex items-center gap-1 text-[13px] text-[var(--status-error)]">
<div className="h-2 w-2 rounded-full bg-[var(--status-error)]" />
{count}
</span>
);
}
// A single session row sized for comfortable touch.
function SessionItem({
session,
isCurrent,
getSessionTitle,
onClick,
needsAttention,
}: {
session: SessionWithStatus;
isCurrent: boolean;
getSessionTitle: (s: Session) => string;
onClick: () => void;
needsAttention: (sessionId: string) => boolean;
}) {
const attention = needsAttention(session.id);
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex w-full items-center gap-3 rounded-xl px-3 py-3 text-left transition-colors min-h-[56px]",
"active:bg-[var(--interactive-selection)]",
isCurrent ? "bg-[color-mix(in_srgb,var(--interactive-selection)_40%,transparent)]" : "hover:bg-[var(--interactive-hover)]"
)}
>
<span className="flex h-4 w-4 flex-shrink-0 items-center justify-center">
<StatusIndicator isRunning={session._statusType !== 'idle'} needsAttention={attention} />
</span>
<span className={cn(
"flex-1 truncate text-[15px] leading-tight",
isCurrent ? "font-semibold text-[var(--surface-foreground)]" : "text-[var(--surface-foreground)]"
)}>
{getSessionTitle(session)}
</span>
{(session._runningChildrenCount ?? 0) > 0 && (
<span className="flex flex-shrink-0 items-center gap-1 text-[12px] text-[var(--status-info)]">
<Icon name="loader-4" className="h-3 w-3 animate-spin" />
{session._runningChildrenCount}
</span>
)}
{isCurrent && (
<Icon name="check" className="h-4 w-4 flex-shrink-0 text-[var(--primary-base)]" />
)}
</button>
);
}
// A project filter pill sized for touch. Selecting it filters
// the session list; it does NOT switch the active project.
interface ProjectFilterChipProps {
label: string;
icon?: string | null;
project?: Pick<ProjectEntry, 'id' | 'iconImage'> | null;
iconOptions?: React.ComponentProps<typeof ProjectIconImage>['options'];
iconBackground?: string | null;
colorVar?: string | null;
isActive: boolean;
status?: { hasRunning: boolean; hasUnread: boolean };
onClick: () => void;
}
function ProjectFilterChip({
label,
icon,
project,
iconOptions,
iconBackground,
colorVar,
isActive,
status,
onClick,
}: ProjectFilterChipProps) {
const projectIconName = icon ? PROJECT_ICON_MAP[icon] : null;
const fallbackIcon = projectIconName ? (
<Icon name={projectIconName} className="h-4 w-4" style={!isActive && colorVar ? { color: colorVar } : undefined} />
) : null;
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex min-h-[40px] shrink-0 select-none items-center gap-1.5 rounded-full border px-3.5 text-[13px] leading-none whitespace-nowrap transition-colors",
isActive
? "border-transparent bg-[var(--primary-base)] text-[var(--primary-foreground)] font-medium"
: "border-[var(--interactive-border)] bg-[var(--surface-subtle)] text-[var(--surface-foreground)] active:bg-[var(--interactive-hover)]"
)}
>
{status && (status.hasRunning || status.hasUnread) && !isActive && (
status.hasRunning
? <Icon name="loader-4" className="h-2.5 w-2.5 animate-spin text-[var(--status-info)]" />
: <span className="h-1.5 w-1.5 rounded-full bg-[var(--status-error)]" />
)}
{project?.iconImage ? (
<span
className="inline-flex h-4 w-4 items-center justify-center overflow-hidden rounded-[2px]"
style={iconBackground ? { backgroundColor: iconBackground } : undefined}
>
<ProjectIconImage
project={project}
options={iconOptions}
className="h-full w-full object-contain"
fallback={fallbackIcon}
/>
</span>
) : fallbackIcon}
<span className="max-w-[140px] truncate">{label}</span>
</button>
);
}
// The chip that lives in the composer footer and toggles the slide-up sheet.
// This is the only persistent affordance; there is no longer a permanent bar.
interface MobileSessionPanelTriggerProps {
footerIconButtonClass: string;
iconSizeClass: string;
}
export const MobileSessionPanelTrigger: React.FC<MobileSessionPanelTriggerProps> = ({
footerIconButtonClass,
iconSizeClass,
}) => {
const { t } = useI18n();
const isMobile = useUIStore((state) => state.isMobile);
const open = useUIStore((state) => state.mobileSessionPanelOpen);
const setOpen = useUIStore((state) => state.setMobileSessionPanelOpen);
// Ensure the cross-project session list is loaded once, so the panel reflects
// every project, not just the active directory.
React.useEffect(() => {
if (isMobile) {
void ensureGlobalSessionsLoaded();
}
}, [isMobile]);
if (!isMobile) {
return null;
}
return (
<button
type="button"
className={cn(
footerIconButtonClass,
'rounded-md relative hover:bg-[var(--interactive-hover)]',
open && 'text-[var(--primary-base)]'
)}
style={{ touchAction: 'manipulation' }}
onClick={() => setOpen(!open)}
title={t('mobile.sessions.search.section.sessions')}
aria-label={t('mobile.sessions.search.section.sessions')}
aria-expanded={open}
>
<Icon name="stack" className={cn(iconSizeClass)} />
</button>
);
};
const MobileSessionStatusOpenPanel: React.FC<MobileSessionStatusBarProps> = ({
onSessionSwitch,
}) => {
const { t } = useI18n();
const { currentTheme } = useThemeSystem();
const sessions = useAllProjectSessions();
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const sessionStatus = useAllSessionStatuses();
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
const open = useUIStore((state) => state.mobileSessionPanelOpen);
const setOpen = useUIStore((state) => state.setMobileSessionPanelOpen);
const projects = useProjectsStore((state) => state.projects);
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
const { sessions: sortedSessions, totalRunning, totalUnread } = useSessionGrouping(sessions, sessionStatus);
const { getSessionTitle, needsAttention } = useSessionHelpers();
const getProjectStatus = useProjectStatus(sessionStatus, currentSessionId);
const resolveProjectRoots = useProjectRootsResolver();
// Project filter, persisted in the UI store so the choice survives closing and
// reopening the sheet. Defaults to "All" so sessions from every project are
// visible regardless of which session is currently selected.
const filterProjectId = useUIStore((state) => state.mobileSessionFilterProjectId);
const setFilterProjectId = useUIStore((state) => state.setMobileSessionFilterProjectId);
// Refresh the cross-project session list when the panel opens (mirrors the
// dedicated MobileSessionsSheet). The active-directory sync only upserts the
// current project's sessions, so other projects need this global load.
React.useEffect(() => {
if (open) {
void refreshGlobalSessions(sessions);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const formatProjectLabel = React.useCallback((project: ProjectEntry): string => {
return project.label?.trim()
|| formatDirectoryName(project.path, homeDirectory)
|| project.path;
}, [homeDirectory]);
// Filter sessions by the selected project (root + worktrees), using the
// store's canonical directory keying.
const filteredSessions = React.useMemo(() => {
if (!filterProjectId) return sortedSessions;
const project = projects.find((p) => p.id === filterProjectId);
if (!project) return sortedSessions;
const roots = resolveProjectRoots(project);
return sortedSessions.filter((session) => {
const dir = sessionDirectory(session);
return roots.some((root) => pathBelongsToRoot(dir, root));
});
}, [sortedSessions, filterProjectId, projects, resolveProjectRoots]);
// Cap to the most recent N (already sorted running-first, then by updated).
const visibleSessions = React.useMemo(
() => filteredSessions.slice(0, MAX_RECENT_SESSIONS),
[filteredSessions],
);
const handleSessionClick = (session: SessionWithStatus) => {
setCurrentSession(session.id, sessionDirectory(session) || null);
onSessionSwitch?.(session.id);
setOpen(false);
};
// "+" — start a new session draft. Target the project selected in the filter;
// for "All", use the most recently active session's directory, falling back to
// the store's own default target when there are no sessions.
const handleNewChat = React.useCallback(() => {
setOpen(false);
if (filterProjectId) {
const project = projects.find((p) => p.id === filterProjectId);
if (project) {
openNewSessionDraft({ selectedProjectId: project.id, directoryOverride: project.path });
return;
}
}
const mostRecent = [...sessions].sort((a, b) => {
const aTime = (a as { time?: { updated?: number } }).time?.updated ?? 0;
const bTime = (b as { time?: { updated?: number } }).time?.updated ?? 0;
return bTime - aTime;
})[0];
const directory = mostRecent ? sessionDirectory(mostRecent) : '';
openNewSessionDraft(directory ? { directoryOverride: directory } : undefined);
}, [filterProjectId, projects, sessions, openNewSessionDraft, setOpen]);
const renderHeader = React.useCallback(() => (
<div className="shrink-0">
<div className="flex justify-center pt-2.5 pb-1">
<div className="h-1 w-9 rounded-full bg-[color-mix(in_srgb,var(--surface-mutedForeground)_40%,transparent)]" />
</div>
<div className="flex items-center justify-between gap-2 px-4 pb-2">
<h2 className="text-[16px] font-semibold text-[var(--surface-foreground)]">
{t('mobile.sessions.search.section.sessions')}
</h2>
<div className="flex items-center gap-3">
<RunningIndicator count={totalRunning} />
<UnreadIndicator count={totalUnread} />
<button
type="button"
onClick={handleNewChat}
aria-label={t('mobile.sessions.newChat')}
className="flex size-8 items-center justify-center rounded-full text-[var(--surface-mutedForeground)] transition-colors hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]"
style={{ touchAction: 'manipulation' }}
>
<Icon name="add" className="h-5 w-5" />
</button>
<button
type="button"
onClick={() => setOpen(false)}
className="flex size-8 items-center justify-center rounded-full text-[var(--surface-mutedForeground)] transition-colors hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]"
style={{ touchAction: 'manipulation' }}
>
<Icon name="close" className="h-5 w-5" />
</button>
</div>
</div>
{projects.length > 1 && (
<div
className="flex items-center gap-2 overflow-x-auto border-t border-[color-mix(in_srgb,var(--interactive-border)_40%,transparent)] px-4 py-2.5 scrollbar-none"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
<ProjectFilterChip
label={t('chat.modelControls.modeValue.all')}
isActive={filterProjectId === null}
onClick={() => setFilterProjectId(null)}
/>
{projects.map((project) => (
<ProjectFilterChip
key={project.id}
label={formatProjectLabel(project)}
icon={project.icon}
project={{ id: project.id, iconImage: project.iconImage ?? null }}
iconOptions={{
themeVariant: currentTheme.metadata.variant,
iconColor: currentTheme.colors.surface.foreground,
}}
iconBackground={project.iconBackground ?? null}
colorVar={project.color ? (PROJECT_COLOR_MAP[project.color] ?? null) : null}
isActive={filterProjectId === project.id}
status={getProjectStatus(project.path)}
onClick={() => setFilterProjectId(project.id)}
/>
))}
</div>
)}
</div>
), [t, totalRunning, totalUnread, projects, filterProjectId, setFilterProjectId, formatProjectLabel, currentTheme, getProjectStatus, handleNewChat, setOpen]);
return (
<MobileOverlayPanel
open={open}
onClose={() => setOpen(false)}
title={t('mobile.sessions.search.section.sessions')}
renderHeader={renderHeader}
className="h-[72vh]"
contentMaxHeightClassName="max-h-full"
>
<div className="flex min-h-full flex-col gap-0.5">
{visibleSessions.length === 0 ? (
<div className="flex flex-1 items-center justify-center py-10 text-[13px] text-[var(--surface-mutedForeground)]">
<span>{t('chat.mobileStatus.noSessionsInProject')}</span>
</div>
) : (
visibleSessions.map((session) => (
<SessionItem
key={session.id}
session={session}
isCurrent={session.id === currentSessionId}
getSessionTitle={getSessionTitle}
onClick={() => handleSessionClick(session)}
needsAttention={needsAttention}
/>
))
)}
</div>
</MobileOverlayPanel>
);
};
export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = (props) => {
const isMobile = useUIStore((state) => state.isMobile);
const open = useUIStore((state) => state.mobileSessionPanelOpen);
if (!isMobile || !open) return null;
return <MobileSessionStatusOpenPanel {...props} />;
};