Files
openchamber/packages/ui/src/components/chat/MobileSessionStatusBar.tsx
T
bashrusakhandLeonid Skorobogatyy a25fc4c25a fix(sync): reflect share status from global store after cancel (#1709)
* fix(sync): reflect share status from global store after cancel

Fix #1551: unshareSession() called updateLiveSession() which silently
fails when the child store doesn't exist. The sidebar rendered from the
child store first, showing stale share data. Now overlays the global
session's share field at merge points.

* fix(sync): extract shared mergeLiveSessionWithGlobalSession helper

Extracted the share-field overlay into a single shared helper in
useGlobalSessionsStore.ts. All 3 merge sites now use the helper
instead of duplicating the overlay logic.

* test(sync): add unit tests for mergeLiveSessionWithGlobalSession helper

---------

Co-authored-by: Leonid Skorobogatyy <bash@opencode.itc.local>
2026-06-23 22:18:44 +03:00

642 lines
25 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';
_hasRunningChildren?: boolean;
_runningChildrenCount?: number;
_childIndicators?: Array<{ session: Session; isRunning: boolean }>;
}
// 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));
sessions.forEach((session) => {
const parentID = (session as { parentID?: string }).parentID;
if (parentID && allIds.has(parentID)) {
map.set(parentID, [...(map.get(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 hasRunningChildren = React.useCallback((sessionId: string): boolean => {
const children = parentChildMap.get(sessionId) || [];
return children.some((child) => getStatusType(child.id) !== 'idle');
}, [parentChildMap, getStatusType]);
const getRunningChildrenCount = React.useCallback((sessionId: string): number => {
const children = parentChildMap.get(sessionId) || [];
return children.filter((child) => getStatusType(child.id) !== 'idle').length;
}, [parentChildMap, getStatusType]);
const getChildIndicators = React.useCallback((sessionId: string): Array<{ session: Session; isRunning: boolean }> => {
const children = parentChildMap.get(sessionId) || [];
return children
.filter((child) => getStatusType(child.id) !== 'idle')
.map((child) => ({ session: child, isRunning: true }))
.slice(0, 3);
}, [parentChildMap, getStatusType]);
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 hasRunning = hasRunningChildren(session.id);
const attention = (unseenCounts[session.id] ?? 0) > 0;
const enriched: SessionWithStatus = {
...session,
_statusType: statusType,
_hasRunningChildren: hasRunning,
_runningChildrenCount: getRunningChildrenCount(session.id),
_childIndicators: getChildIndicators(session.id),
};
if (statusType !== 'idle' || hasRunning) {
running.push(enriched);
} else if (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, hasRunningChildren, getRunningChildrenCount, getChildIndicators, 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(
sessions: Session[],
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>
);
};
export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
onSessionSwitch,
}) => {
const { t } = useI18n();
const { currentTheme } = useThemeSystem();
const isMobile = useUIStore((state) => state.isMobile);
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(sessions, 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]);
if (!isMobile) {
return null;
}
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>
);
};