fix(sidebar): make file tree rows reliably clickable

On macOS trackpads, a light touch on a file/folder row can start a
native HTML5 drag after ~4px of movement, and Chromium then swallows
the resulting click. Track the drag start position and, on dragend,
treat a micro-drag (dropEffect 'none' and under 8px of travel) as the
click the gesture was meant to be.

Reconciled with main's OS file drag-drop upload handlers on the same
rows: kept onDrop/onDragOver/hasExternalFiles for external file
uploads, and wired the new click-recovery logic alongside it on both
the file tree row and the search-results row. Dropped the row's
cursor-grab/active:cursor-grabbing classes, since these rows are not
meant to read as draggable to the user.

Closes #2368
This commit is contained in:
Bohdan Triapitsyn
2026-08-28 23:44:27 +03:00
1264 changed files with 107929 additions and 30300 deletions
File diff suppressed because it is too large Load Diff
@@ -36,6 +36,7 @@ import { cn } from '@/lib/utils';
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
import { useGitStatus } from '@/stores/useGitStore';
import { normalizeContextPanelDirectoryKey, useUIStore } from '@/stores/useUIStore';
import { ContextRailSurfacesDialog } from './ContextRailSurfacesDialog';
const RAIL_TOOLTIP_DELAY_MS = 150;
// Hold the surface-switch modifier for this long before revealing the order
@@ -120,7 +121,14 @@ const ContextPanelRailItem: React.FC<RailItemProps> = ({
) : displayBadgeCount ? (
<span
aria-hidden="true"
className="absolute right-0 top-0 flex h-4 min-w-4 items-center justify-center rounded-full bg-surface-muted px-1 text-[0.625rem] font-medium leading-none text-muted-foreground"
// Muted digits on the muted surface sat at almost the same
// luminance as the glyph they overlap. The count is a live
// signal, so it takes the info tone on its own opaque chip.
className="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[0.625rem] font-semibold leading-none"
style={{
backgroundColor: 'var(--status-info-background)',
color: 'var(--status-info)',
}}
>
{displayBadgeCount}
</span>
@@ -152,7 +160,9 @@ export const ContextPanelRail: React.FC = () => {
const directoryKey = effectiveDirectory ? normalizeContextPanelDirectoryKey(effectiveDirectory) : '';
const panelState = useUIStore((state) => (directoryKey ? state.contextPanelByDirectory[directoryKey] : undefined));
const workStatusPanelVisible = useUIStore((state) => state.workStatusPanelVisible);
const contextRailOrder = useUIStore((state) => state.contextRailOrder);
const contextRailHiddenSurfaces = useUIStore((state) => state.contextRailHiddenSurfaces);
const setContextRailOrder = useUIStore((state) => state.setContextRailOrder);
const openContextSurface = useUIStore((state) => state.openContextSurface);
const shortcutOverrides = useUIStore((state) => state.shortcutOverrides);
@@ -248,12 +258,15 @@ export const ContextPanelRail: React.FC = () => {
const surfaces = React.useMemo(() => {
return getVisibleContextRailSurfaces({
railOrder: contextRailOrder,
hiddenSurfaces: contextRailHiddenSurfaces,
planModeEnabled,
isVSCode: isVSCodeRuntime(),
screenWidth,
tabs,
});
}, [contextRailOrder, planModeEnabled, screenWidth, tabs]);
}, [contextRailHiddenSurfaces, contextRailOrder, planModeEnabled, screenWidth, tabs]);
const [isSurfacesDialogOpen, setIsSurfacesDialogOpen] = React.useState(false);
const handleDragEnd = React.useCallback((event: DragEndEvent) => {
const { active, over } = event;
@@ -286,7 +299,9 @@ export const ContextPanelRail: React.FC = () => {
const label = t(surface.labelKey);
// Git shows a numeric badge instead of the old activity dot.
// Other surfaces never inherit git's changed-files signal.
const gitChangedCount = surface.id === 'git' ? changedFilesCount : 0;
// The work-status panel reports the same count in words a few
// pixels away; two live counts for one fact is one too many.
const gitChangedCount = surface.id === 'git' && !workStatusPanelVisible ? changedFilesCount : 0;
const badgeCount = gitChangedCount > 0 ? gitChangedCount : null;
return (
<ContextPanelRailItem
@@ -321,6 +336,24 @@ export const ContextPanelRail: React.FC = () => {
})}
</SortableContext>
</DndContext>
{/* Outside the sortable list on purpose: this button takes no digit,
cannot be dragged, and configures the rail rather than living on it. */}
<Tooltip delayDuration={RAIL_TOOLTIP_DELAY_MS}>
<TooltipTrigger asChild>
<button
type="button"
aria-label={t('contextRail.configure.open')}
onClick={() => setIsSurfacesDialogOpen(true)}
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground/70 transition-colors hover:text-foreground"
>
<Icon name="equalizer-2" className="h-[18px] w-[18px]" />
</button>
</TooltipTrigger>
<TooltipContent side="left" sideOffset={8}>
{t('contextRail.configure.open')}
</TooltipContent>
</Tooltip>
<ContextRailSurfacesDialog open={isSurfacesDialogOpen} onOpenChange={setIsSurfacesDialogOpen} />
</nav>
);
};
@@ -0,0 +1,78 @@
import React from 'react';
import { useI18n } from '@/lib/i18n';
import { useUIStore } from '@/stores/useUIStore';
import { SettingsCheckboxRow } from '@/components/sections/shared/SettingsSection';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { sortContextSurfaces } from '@/lib/surfaces/registry';
/**
* Which surfaces the context rail shows. Everything is on by default and the
* choice is stored as the *hidden* set, so a surface added in a later release
* appears for everyone rather than staying invisible to whoever had saved
* settings before it existed. Hidden surfaces also leave the digit shortcuts
* (the rail and the shortcut share one visibility filter).
*/
export const ContextRailSurfacesDialog: React.FC<{
open: boolean;
onOpenChange: (open: boolean) => void;
}> = ({ open, onOpenChange }) => {
const { t } = useI18n();
const contextRailOrder = useUIStore((state) => state.contextRailOrder);
const hidden = useUIStore((state) => state.contextRailHiddenSurfaces);
const setSurfaceVisible = useUIStore((state) => state.setContextRailSurfaceVisible);
const setHiddenSurfaces = useUIStore((state) => state.setContextRailHiddenSurfaces);
// The full registry in the user's rail order — including surfaces a runtime
// filter currently drops, so a choice made on desktop is editable anywhere.
const surfaces = React.useMemo(() => sortContextSurfaces(contextRailOrder), [contextRailOrder]);
const allVisible = hidden.length === 0;
const noneVisible = surfaces.every((surface) => hidden.includes(surface.id));
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t('contextRail.configure.dialogTitle')}</DialogTitle>
<DialogDescription>{t('contextRail.configure.dialogDescription')}</DialogDescription>
</DialogHeader>
<div className="flex flex-col">
{surfaces.map((surface) => (
<SettingsCheckboxRow
key={surface.id}
settingsItem={`layout.context-rail.surface.${surface.id}`}
checked={!hidden.includes(surface.id)}
onChange={(checked) => setSurfaceVisible(surface.id, checked)}
label={t(surface.labelKey)}
ariaLabel={t(surface.labelKey)}
/>
))}
</div>
{!allVisible ? (
<div className="flex items-center justify-between border-t pt-3">
{noneVisible ? (
<span className="text-xs text-destructive">{t('contextRail.configure.noneWarning')}</span>
) : <span />}
<Button
variant="link"
size="xs"
onClick={() => setHiddenSurfaces([])}
className="normal-case text-muted-foreground hover:text-foreground"
>
{t('contextRail.configure.showAll')}
</Button>
</div>
) : null}
</DialogContent>
</Dialog>
);
};
@@ -11,6 +11,7 @@ import { computeCacheHitRate } from '@/stores/utils/tokenUtils';
import { useSessions, useSessionMessageRecords } from '@/sync/sync-context';
import { copyTextToClipboard } from '@/lib/clipboard';
import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
import { formatMoney } from '@/lib/money';
import {
derivePartsLabel,
deriveUserSnippet,
@@ -92,6 +93,7 @@ const extractTokenBreakdown = (message: SessionMessage): TokenBreakdown => {
}
const breakdown = source as {
total?: unknown;
input?: unknown;
output?: unknown;
reasoning?: unknown;
@@ -103,6 +105,10 @@ const extractTokenBreakdown = (message: SessionMessage): TokenBreakdown => {
const reasoning = toNonNegativeNumber(breakdown.reasoning);
const cacheRead = toNonNegativeNumber(breakdown.cache?.read);
const cacheWrite = toNonNegativeNumber(breakdown.cache?.write);
// Multi-step turns accumulate the fields across API round-trips (every tool
// call re-reads the whole cached prompt), so summing them overstates the
// window. The server-reported total is the final round-trip's window.
const reportedTotal = toNonNegativeNumber(breakdown.total);
return {
input,
@@ -110,7 +116,7 @@ const extractTokenBreakdown = (message: SessionMessage): TokenBreakdown => {
reasoning,
cacheRead,
cacheWrite,
total: input + output + reasoning + cacheRead + cacheWrite,
total: reportedTotal > 0 ? reportedTotal : input + output + reasoning + cacheRead + cacheWrite,
};
};
@@ -231,16 +237,6 @@ const computeContextBreakdown = (
const formatNumber = (value: number): string => value.toLocaleString(getCurrentIntlLocale());
const formatMoney = (value: number): string => {
if (!Number.isFinite(value) || value <= 0) return new Intl.NumberFormat(getCurrentIntlLocale(), { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(0);
return new Intl.NumberFormat(getCurrentIntlLocale(), {
style: 'currency',
currency: 'USD',
minimumFractionDigits: value < 0.01 ? 4 : 2,
maximumFractionDigits: value < 0.01 ? 4 : 2,
}).format(value);
};
const formatDateTime = (timestamp: number | null, timeFormatPreference: TimeFormatPreference): string => {
if (!timestamp || !Number.isFinite(timestamp)) return '-';
return formatDateTimeForPreference(timestamp, timeFormatPreference, {
File diff suppressed because it is too large Load Diff
+91 -387
View File
@@ -1,10 +1,8 @@
import React, { useRef, useEffect } from 'react';
import { animate, motion, useMotionValue } from 'motion/react';
import React from 'react';
import { Header } from './Header';
import { Sidebar } from './Sidebar';
import { SidebarTopBar } from './SidebarTopBar';
import { TitlebarLeftControls } from './TitlebarLeftControls';
import { ProjectContextPanel } from './RightSidebarTabs';
import { ContextPanel } from './ContextPanel';
import { ContextPanelRail } from './ContextPanelRail';
import { ErrorBoundary } from '../ui/ErrorBoundary';
@@ -13,51 +11,62 @@ import { HelpDialog } from '../ui/HelpDialog';
import { OpenCodeStatusDialog } from '../ui/OpenCodeStatusDialog';
import { SessionSidebar } from '@/components/session/SessionSidebar';
import { SessionDialogs } from '@/components/session/SessionDialogs';
import { SessionWorktreeMoveConfirmDialog } from '@/components/session/sidebar/SessionWorktreeMoveConfirmDialog';
import { ScheduledTasksDialog } from '@/components/session/ScheduledTasksDialog';
import { ArchiveView } from '@/components/views/ArchiveView';
import { WorktreesView } from '@/components/views/WorktreesView';
import { DiffWorkerProvider } from '@/contexts/DiffWorkerProvider';
import { MultiRunLauncher } from '@/components/multirun';
import { TerminalView } from '@/components/views/TerminalView';
import { DrawerProvider } from '@/contexts/DrawerContext';
import { useUIStore } from '@/stores/useUIStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import {
cancelSessionTreeMove,
confirmSessionTreeMove,
useSessionTreeMoveConfirmation,
} from '@/lib/worktrees/sessionWorktreeMove';
import { useUpdatePolling } from '@/hooks/useUpdatePolling';
import { useDeviceInfo } from '@/lib/device';
import { cn } from '@/lib/utils';
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
import { useSessionListSync } from '@/components/session/sidebar/list/useSessionListSync';
import { ChatView } from '@/components/views/ChatView';
import { DiffView } from '@/components/views/DiffView';
import { FilesView } from '@/components/views/FilesView';
import { GitView } from '@/components/views/GitView';
import { PlanView } from '@/components/views/PlanView';
// Keep TerminalView eager: the bottom dock reserves its height immediately, so
// suspending here leaves a large blank panel on slower machines.
// Other heavy views stay on-demand to reduce initial bundle parse time.
const DiagramView = lazyWithChunkRecovery(() => import('@/components/views/DiagramView').then(m => ({ default: m.DiagramView })));
const SettingsView = lazyWithChunkRecovery(() => import('@/components/views/SettingsView').then(m => ({ default: m.SettingsView })));
const SettingsWindow = lazyWithChunkRecovery(() => import('@/components/views/SettingsWindow').then(m => ({ default: m.SettingsWindow })));
/**
* Desktop-surface layout: the chat owns the main area, and every other
* surface (git, diff, files, terminal, ...) opens in the ContextPanel via the
* rail. Phone-sized viewports run the separate MobileApp shell — a viewport
* crossing the threshold reloads into it (see watchHostedSurfaceViewport).
*/
export const MainLayout: React.FC = () => {
useSessionListSync({ isVSCode: false });
const isSidebarOpen = useUIStore((state) => state.isSidebarOpen);
const activeMainTab = useUIStore((state) => state.activeMainTab);
const setIsMobile = useUIStore((state) => state.setIsMobile);
const isSessionSwitcherOpen = useUIStore((state) => state.isSessionSwitcherOpen);
const isSettingsDialogOpen = useUIStore((state) => state.isSettingsDialogOpen);
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
// Mount the windowed settings dialog only after its first open: rendering
// the lazy component (even closed) makes React fetch the SettingsView
// chunk graph (CodeMirror editor, vim mode, theme tooling) on startup.
// Once opened it stays mounted so the close animation and state behave as
// before.
const [settingsWindowMounted, setSettingsWindowMounted] = React.useState(false);
React.useEffect(() => {
if (isSettingsDialogOpen) {
setSettingsWindowMounted(true);
}
}, [isSettingsDialogOpen]);
const isMultiRunLauncherOpen = useUIStore((state) => state.isMultiRunLauncherOpen);
const setMultiRunLauncherOpen = useUIStore((state) => state.setMultiRunLauncherOpen);
const multiRunLauncherPrefillPrompt = useUIStore((state) => state.multiRunLauncherPrefillPrompt);
const isScheduledTasksPageOpen = useUIStore((state) => state.isScheduledTasksDialogOpen);
const isArchivePageOpen = useUIStore((state) => state.isArchivePageOpen);
const worktreesPageProjectId = useUIStore((state) => state.worktreesPageProjectId);
// Any full-page surface replacing the chat area. While open, the chat and
// secondary views are fully hidden (not just covered) so none of their
// floating chrome bleeds through, and selecting a session / draft / main
// tab anywhere closes the surface.
// Any full-page surface replacing the chat area. While open, the chat is
// fully hidden (not just covered) so none of its floating chrome bleeds
// through, and selecting a session or draft anywhere closes the surface.
const isSurfacePageOpen = isScheduledTasksPageOpen || isArchivePageOpen || Boolean(worktreesPageProjectId) || isMultiRunLauncherOpen;
React.useEffect(() => {
@@ -69,169 +78,16 @@ export const MainLayout: React.FC = () => {
const draftOpened = Boolean(state.newSessionDraft?.open) && state.newSessionDraft !== prev.newSessionDraft;
if (sessionSelected || draftOpened) closeSurfacePages();
});
const unsubscribeTab = useUIStore.subscribe((state, prev) => {
if (state.activeMainTab !== prev.activeMainTab) closeSurfacePages();
});
return () => {
unsubscribeSession();
unsubscribeTab();
};
}, []);
const { isMobile } = useDeviceInfo();
const mobilePanelsResetRef = React.useRef(false);
// Mobile drawer state
const [mobileLeftDrawerOpen, setMobileLeftDrawerOpen] = React.useState(false);
const [mobileRightSidebarOpen, setMobileRightSidebarOpen] = React.useState(false);
const [mobileLeftDrawerVisible, setMobileLeftDrawerVisible] = React.useState(false);
const [mobileRightDrawerVisible, setMobileRightDrawerVisible] = React.useState(false);
const setMobileSessionPanelOpen = React.useCallback((open: boolean) => {
setMobileLeftDrawerOpen(open);
useUIStore.getState().setSessionSwitcherOpen(open);
}, []);
const initialDrawerWidthRef = React.useRef(typeof window === 'undefined' ? 0 : window.innerWidth);
// Left drawer motion value
const leftDrawerX = useMotionValue(-initialDrawerWidthRef.current);
const leftDrawerWidth = useRef(0);
// Right drawer motion value
const rightDrawerX = useMotionValue(initialDrawerWidthRef.current);
const rightDrawerWidth = useRef(0);
// Compute drawer width
useEffect(() => {
if (isMobile) {
leftDrawerWidth.current = window.innerWidth;
rightDrawerWidth.current = window.innerWidth;
}
}, [isMobile]);
// Sync left drawer state and motion value
useEffect(() => {
if (!isMobile) {
setMobileLeftDrawerVisible(false);
return;
}
if (mobileLeftDrawerOpen) {
setMobileLeftDrawerVisible(true);
}
animate(leftDrawerX, mobileLeftDrawerOpen ? 0 : -leftDrawerWidth.current, {
type: 'spring',
stiffness: 400,
damping: 35,
mass: 0.8,
});
}, [mobileLeftDrawerOpen, isMobile, leftDrawerX]);
// Sync right drawer state and motion value
useEffect(() => {
if (!isMobile) {
setMobileRightDrawerVisible(false);
return;
}
if (mobileRightSidebarOpen) {
setMobileRightDrawerVisible(true);
}
animate(rightDrawerX, mobileRightSidebarOpen ? 0 : rightDrawerWidth.current, {
type: 'spring',
stiffness: 400,
damping: 35,
mass: 0.8,
});
}, [isMobile, mobileRightSidebarOpen, rightDrawerX]);
useEffect(() => {
if (!isMobile) return;
return leftDrawerX.on('change', (value) => {
const width = leftDrawerWidth.current || initialDrawerWidthRef.current;
const visible = mobileLeftDrawerOpen || value > -width + 0.5;
setMobileLeftDrawerVisible((previous) => previous === visible ? previous : visible);
});
}, [isMobile, leftDrawerX, mobileLeftDrawerOpen]);
useEffect(() => {
if (!isMobile) return;
return rightDrawerX.on('change', (value) => {
const width = rightDrawerWidth.current || initialDrawerWidthRef.current;
const visible = mobileRightSidebarOpen || value < width - 0.5;
setMobileRightDrawerVisible((previous) => previous === visible ? previous : visible);
});
}, [isMobile, mobileRightSidebarOpen, rightDrawerX]);
// Sync session switcher close events to left drawer.
useEffect(() => {
if (isMobile && !isSessionSwitcherOpen && mobileLeftDrawerOpen) {
setMobileSessionPanelOpen(false);
}
}, [isSessionSwitcherOpen, isMobile, mobileLeftDrawerOpen, setMobileSessionPanelOpen]);
useEffect(() => {
if (!isMobile) {
mobilePanelsResetRef.current = false;
return;
}
if (mobilePanelsResetRef.current) {
return;
}
mobilePanelsResetRef.current = true;
setMobileSessionPanelOpen(false);
setMobileRightSidebarOpen(false);
}, [isMobile, setMobileSessionPanelOpen]);
useEffect(() => {
if (!isMobile || activeMainTab !== 'chat' || mobileLeftDrawerOpen || mobileRightSidebarOpen || isSettingsDialogOpen) {
return;
}
let disposed = false;
let timeoutId: number | undefined;
const scheduleDraftOpen = (delayMs: number) => {
timeoutId = window.setTimeout(() => {
if (disposed) {
return;
}
const sessionState = useSessionUIStore.getState();
const uiState = useUIStore.getState();
if (uiState.activeMainTab !== 'chat' || uiState.isSettingsDialogOpen || sessionState.currentSessionId || sessionState.newSessionDraft?.open) {
return;
}
if (sessionState.isLoading) {
scheduleDraftOpen(250);
return;
}
sessionState.openNewSessionDraft({ automatic: true });
}, delayMs);
};
scheduleDraftOpen(500);
return () => {
disposed = true;
if (timeoutId !== undefined) {
window.clearTimeout(timeoutId);
}
};
}, [activeMainTab, isMobile, isSettingsDialogOpen, mobileLeftDrawerOpen, mobileRightSidebarOpen]);
// Ensure mobile drawers are closed when opening full-screen settings
useEffect(() => {
if (!isMobile || !isSettingsDialogOpen) {
return;
}
setMobileSessionPanelOpen(false);
setMobileRightSidebarOpen(false);
}, [isMobile, isSettingsDialogOpen, setMobileSessionPanelOpen]);
useUpdatePolling();
const sessionTreeMoveConfirmation = useSessionTreeMoveConfirmation();
React.useEffect(() => {
const previous = useUIStore.getState().isMobile;
if (previous !== isMobile) {
@@ -239,241 +95,89 @@ export const MainLayout: React.FC = () => {
}
}, [isMobile, setIsMobile]);
const handleToggleMobileRightDrawer = React.useCallback(() => {
if (mobileLeftDrawerOpen) {
setMobileSessionPanelOpen(false);
}
setMobileRightSidebarOpen(!mobileRightSidebarOpen);
}, [mobileLeftDrawerOpen, mobileRightSidebarOpen, setMobileSessionPanelOpen]);
const secondaryView = React.useMemo(() => {
// Desktop surfaces live in the context panel; the only full-view
// overlays left there are the terminal (promoted by project actions)
// and the diagram viewer. Mobile keeps the full tab set.
if (!isMobile && activeMainTab !== 'terminal' && activeMainTab !== 'diagram') {
return null;
}
switch (activeMainTab) {
case 'plan':
return <React.Suspense fallback={null}><PlanView /></React.Suspense>;
case 'git':
return <React.Suspense fallback={null}><GitView isActive={!mobileRightSidebarOpen} /></React.Suspense>;
case 'diff':
return <React.Suspense fallback={null}><DiffView /></React.Suspense>;
case 'terminal':
return <TerminalView />;
case 'files':
return <React.Suspense fallback={null}><FilesView /></React.Suspense>;
case 'context':
return <React.Suspense fallback={null}><ProjectContextPanel /></React.Suspense>;
case 'diagram':
return <React.Suspense fallback={null}><DiagramView /></React.Suspense>;
default:
return null;
}
}, [activeMainTab, isMobile, mobileRightSidebarOpen]);
const isChatActive = activeMainTab === 'chat';
return (
<DiffWorkerProvider>
<div
data-page-scroll-lock="true"
className={cn(
'main-content-safe-area',
isMobile ? 'flex h-[100dvh] flex-col' : 'relative flex h-[100dvh]',
'bg-background'
)}
className="main-content-safe-area relative flex h-[100dvh] bg-background"
>
<CommandPalette />
<HelpDialog />
<OpenCodeStatusDialog />
<SessionDialogs />
<SessionWorktreeMoveConfirmDialog
value={sessionTreeMoveConfirmation}
onMoveSessionOnly={() => confirmSessionTreeMove(false)}
onMoveAllChanges={() => confirmSessionTreeMove(true)}
onCancel={cancelSessionTreeMove}
/>
{isMobile ? (
<DrawerProvider value={{
leftDrawerOpen: mobileLeftDrawerOpen,
rightDrawerOpen: mobileRightSidebarOpen,
toggleLeftDrawer: () => {
const nextOpen = !mobileLeftDrawerOpen;
if (mobileRightSidebarOpen) {
setMobileRightSidebarOpen(false);
}
setMobileSessionPanelOpen(nextOpen);
},
toggleRightDrawer: handleToggleMobileRightDrawer,
leftDrawerX,
rightDrawerX,
leftDrawerWidth,
rightDrawerWidth,
setMobileLeftDrawerOpen: setMobileSessionPanelOpen,
setRightSidebarOpen: setMobileRightSidebarOpen,
}}>
{/* Mobile: header + drawer mode */}
{!isSettingsDialogOpen && <Header
onToggleLeftDrawer={() => {
const nextOpen = !mobileLeftDrawerOpen;
if (mobileRightSidebarOpen) {
setMobileRightSidebarOpen(false);
}
setMobileSessionPanelOpen(nextOpen);
}}
onToggleRightDrawer={() => {
handleToggleMobileRightDrawer();
}}
leftDrawerOpen={mobileLeftDrawerOpen}
rightDrawerOpen={mobileRightSidebarOpen}
/>}
{/* Main content area (fixed) */}
<div
data-page-scroll-lock="true"
className={cn(
'flex flex-1 overflow-hidden relative',
isSettingsDialogOpen && 'hidden'
)}
{/* Persistent top-left controls (toggle + project actions) that
stay put while the sidebar/header animate beneath them. */}
<TitlebarLeftControls />
{/* Full-height Sidebar beside [Header above (chat | RightSidebar)] */}
<div className="flex flex-1 overflow-hidden" data-page-scroll-lock="true">
<Sidebar
isOpen={isSidebarOpen}
isMobile={isMobile}
className="border-border"
topBar={<SidebarTopBar />}
>
<main className="w-full h-full overflow-hidden bg-background relative" data-page-scroll-lock="true">
<div className={cn('absolute inset-0', (!isChatActive || isSurfacePageOpen) && 'invisible')}>
<ErrorBoundary><ChatView active={isChatActive && !isSettingsDialogOpen && !isSurfacePageOpen} /></ErrorBoundary>
</div>
{secondaryView && (
<div className={cn('absolute inset-0', isSurfacePageOpen && 'invisible')}>
<ErrorBoundary>{secondaryView}</ErrorBoundary>
</div>
)}
{isMultiRunLauncherOpen && (
<div className="absolute inset-0 z-10 bg-background">
<ErrorBoundary>
<MultiRunLauncher
initialPrompt={multiRunLauncherPrefillPrompt}
onCreated={() => setMultiRunLauncherOpen(false)}
onCancel={() => setMultiRunLauncherOpen(false)}
/>
</ErrorBoundary>
</div>
)}
<ErrorBoundary><ScheduledTasksDialog /></ErrorBoundary>
<ErrorBoundary><ArchiveView /></ErrorBoundary>
<ErrorBoundary><WorktreesView /></ErrorBoundary>
{/* Always mount SessionSidebar on mobile to match desktop behavior.
Conditional mount (mobileLeftDrawerVisible && ...) caused a
data-loading cascade on every drawer open: paginated sessions
fetch, worktree discovery, repo status, PR status, and 10+ memo
recomputations. On Android PWA this manifested as a >10s delay
before the drawer became interactive (issue #1695). Visibility is
controlled by the leftDrawerX transform (off-screen when closed).
The invisible class matters when fully hidden: leftDrawerWidth is
not recomputed on resize/rotation, so a closed drawer translated by
the old width could otherwise peek into the viewport; it also keeps
the off-screen sidebar out of the tab order and skips painting it. */}
<motion.div
className={cn(
'absolute inset-0 z-20 bg-sidebar',
!mobileLeftDrawerVisible && 'pointer-events-none invisible',
)}
data-page-scroll-lock="true"
style={{ x: leftDrawerX }}
aria-hidden={!mobileLeftDrawerOpen}
>
<ErrorBoundary>
<SessionSidebar mobileVariant isVisible={mobileLeftDrawerVisible} />
</ErrorBoundary>
</motion.div>
{mobileRightDrawerVisible && (
<motion.div className="absolute inset-0 z-20 bg-sidebar" data-page-scroll-lock="true" style={{ x: rightDrawerX }} aria-hidden={!mobileRightSidebarOpen}>
<ErrorBoundary>
<React.Suspense fallback={null}><GitView isActive={mobileRightSidebarOpen} /></React.Suspense>
</ErrorBoundary>
</motion.div>
)}
</main>
</div>
{/* Mobile settings: full screen */}
{isSettingsDialogOpen && (
<div
className="absolute inset-0 z-10 bg-background"
style={{ paddingTop: 'var(--oc-safe-area-top, 0px)' }}
>
<ErrorBoundary>
<React.Suspense fallback={null}>
<SettingsView onClose={() => setSettingsDialogOpen(false)} />
</React.Suspense>
</ErrorBoundary>
</div>
)}
</DrawerProvider>
) : (
<>
{/* Persistent top-left controls (toggle + project actions) that
stay put while the sidebar/header animate beneath them. */}
<TitlebarLeftControls />
{/* Desktop: full-height Sidebar beside [Header above (chat | RightSidebar)] */}
<div className="flex flex-1 overflow-hidden" data-page-scroll-lock="true">
<Sidebar
isOpen={isSidebarOpen}
isMobile={isMobile}
className="border-border"
topBar={<SidebarTopBar />}
>
<SessionSidebar isVisible={isSidebarOpen} />
</Sidebar>
<div className="relative flex flex-1 min-w-0 flex-col overflow-hidden bg-background" data-page-scroll-lock="true">
<Header />
<div className="relative flex flex-1 min-h-0 overflow-hidden bg-background" data-page-scroll-lock="true">
<div className="relative flex flex-1 min-w-0 flex-col overflow-hidden border-t border-border bg-background" data-page-scroll-lock="true">
<div className="flex flex-1 min-h-0 overflow-hidden" data-page-scroll-lock="true">
<div className="relative flex flex-1 min-h-0 min-w-0 overflow-hidden" data-page-scroll-lock="true">
<main className="flex-1 overflow-hidden bg-background relative" data-page-scroll-lock="true">
<div className={cn('absolute inset-0', (!isChatActive || isSurfacePageOpen) && 'invisible')}>
<ErrorBoundary><ChatView active={isChatActive && !isSettingsDialogOpen && !isSurfacePageOpen} /></ErrorBoundary>
<SessionSidebar isVisible={isSidebarOpen} />
</Sidebar>
<div className="relative flex flex-1 min-w-0 flex-col overflow-hidden bg-background" data-page-scroll-lock="true">
<Header />
<div className="relative flex flex-1 min-h-0 overflow-hidden bg-background" data-page-scroll-lock="true">
<div className="relative flex flex-1 min-w-0 flex-col overflow-hidden border-t border-border bg-background" data-page-scroll-lock="true">
<div className="flex flex-1 min-h-0 overflow-hidden" data-page-scroll-lock="true">
{/* Holds the chat and the context panel together, so its
width does not move when the context panel opens. The
work-status panel measures this rather than the chat,
which the context panel animates. */}
<div className="relative flex flex-1 min-h-0 min-w-0 overflow-hidden" data-page-scroll-lock="true" data-chat-area="true">
<main className="flex-1 overflow-hidden bg-background relative" data-page-scroll-lock="true">
<div className={cn('absolute inset-0', isSurfacePageOpen && 'invisible')}>
<ErrorBoundary><ChatView active={!isSettingsDialogOpen && !isSurfacePageOpen} /></ErrorBoundary>
</div>
{isMultiRunLauncherOpen && (
<div className="absolute inset-0 z-10 bg-background">
<ErrorBoundary>
{/* isWindowed: the app Header already shows the surface
title, so skip the launcher's own title bar. */}
<MultiRunLauncher
isWindowed
initialPrompt={multiRunLauncherPrefillPrompt}
onCreated={() => setMultiRunLauncherOpen(false)}
onCancel={() => setMultiRunLauncherOpen(false)}
/>
</ErrorBoundary>
</div>
{secondaryView && (
<div className={cn('absolute inset-0', isSurfacePageOpen && 'invisible')}>
<ErrorBoundary>{secondaryView}</ErrorBoundary>
</div>
)}
{isMultiRunLauncherOpen && (
<div className="absolute inset-0 z-10 bg-background">
<ErrorBoundary>
{/* isWindowed: the app Header already shows the surface
title, so skip the launcher's own title bar. */}
<MultiRunLauncher
isWindowed
initialPrompt={multiRunLauncherPrefillPrompt}
onCreated={() => setMultiRunLauncherOpen(false)}
onCancel={() => setMultiRunLauncherOpen(false)}
/>
</ErrorBoundary>
</div>
)}
<ErrorBoundary><ScheduledTasksDialog /></ErrorBoundary>
<ErrorBoundary><ArchiveView /></ErrorBoundary>
<ErrorBoundary><WorktreesView /></ErrorBoundary>
</main>
<ContextPanel />
</div>
)}
<ErrorBoundary><ScheduledTasksDialog /></ErrorBoundary>
<ErrorBoundary><ArchiveView /></ErrorBoundary>
<ErrorBoundary><WorktreesView /></ErrorBoundary>
</main>
<ContextPanel />
</div>
</div>
<div className="border-t border-border" data-page-scroll-lock="true">
<ErrorBoundary><ContextPanelRail /></ErrorBoundary>
</div>
</div>
<div className="border-t border-border" data-page-scroll-lock="true">
<ErrorBoundary><ContextPanelRail /></ErrorBoundary>
</div>
</div>
</div>
</div>
{/* Desktop settings: windowed dialog with blur */}
{/* Settings: windowed dialog with blur */}
{settingsWindowMounted ? (
<React.Suspense fallback={null}>
<SettingsWindow
open={isSettingsDialogOpen}
onOpenChange={setSettingsDialogOpen}
/>
</React.Suspense>
</>
)}
</div>
</DiffWorkerProvider>
) : null}
</div>
</DiffWorkerProvider>
);
};
@@ -15,6 +15,8 @@ import { useDeviceInfo } from '@/lib/device';
import { isDesktopShell } from '@/lib/desktop';
import { useUIStore } from '@/stores/useUIStore';
import { useTerminalStore } from '@/stores/useTerminalStore';
import { extractAnnouncedUrls, extractProjectActionUrl } from '@/lib/terminalPreview';
import { setAnnouncedDevServers } from '@/lib/browser/announcedServers';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { useDesktopSshStore } from '@/stores/useDesktopSshStore';
import { openExternalUrl } from '@/lib/url';
@@ -39,6 +41,10 @@ type UrlWatchEntry = {
openedUrl: boolean;
tail: string;
openInPreview: boolean;
/** Addresses announced so far by an auto-discovery run, in announcement order. */
announced: string[];
/** Set once the panel is showing these candidates and wants later ones too. */
offering: boolean;
};
interface ProjectActionsButtonProps {
@@ -49,11 +55,14 @@ interface ProjectActionsButtonProps {
allowMobile?: boolean;
}
const ANSI_ESCAPE_PREFIX = String.fromCharCode(27);
const ANSI_ESCAPE_PATTERN = new RegExp(`${ANSI_ESCAPE_PREFIX}\\[[0-9;?]*[ -/]*[@-~]`, 'g');
const URL_GLOBAL_PATTERN = /https?:\/\/[^\s<>'"`]+/gi;
const AUTO_DISCOVER_ACTION_ID = '__openchamber_auto_discover_preview__';
const AUTO_DISCOVER_PREVIEW_WAIT_TIMEOUT_MS = 15_000;
/**
* How long to keep listening after the first server announces itself. A project
* that starts several at once staggers them by a second or two, and opening the
* first to speak would just be a race.
*/
const AUTO_DISCOVER_SETTLE_MS = 3_000;
const stripControlChars = (value: string): string => {
let next = '';
@@ -89,57 +98,6 @@ const normalizeManualOpenUrl = (value: string | undefined): string | null => {
}
};
const extractBestUrl = (value: string): string | null => {
const cleaned = value.replace(ANSI_ESCAPE_PATTERN, '');
const matches = cleaned.match(URL_GLOBAL_PATTERN);
if (!matches || matches.length === 0) {
return null;
}
const normalized = matches
.map((entry) => entry.replace(/[),.;]+$/, ''))
.filter(Boolean);
if (normalized.length === 0) {
return null;
}
const portCandidates: Array<{ raw: string; parsed: URL }> = [];
for (const candidate of normalized) {
try {
const parsed = new URL(candidate);
if (parsed.port && parsed.port.length > 0) {
portCandidates.push({ raw: candidate, parsed });
}
} catch {
// noop
}
}
if (portCandidates.length > 0) {
const scoreCandidate = (entry: { raw: string; parsed: URL }): number => {
const { parsed } = entry;
const host = parsed.hostname.toLowerCase();
const isLocalHost = host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0' || host === '::1';
const normalizedPath = parsed.pathname || '/';
const pathSegments = normalizedPath.split('/').filter(Boolean).length;
const hasRootPath = normalizedPath === '/' || normalizedPath === '';
const hasQueryOrHash = Boolean(parsed.search || parsed.hash);
let score = 0;
if (isLocalHost) score += 50;
if (hasRootPath) score += 30;
score -= Math.min(pathSegments * 5, 20);
if (hasQueryOrHash) score -= 10;
return score;
};
portCandidates.sort((a, b) => scoreCandidate(b) - scoreCandidate(a));
return portCandidates[0]?.parsed.origin ?? portCandidates[0]?.raw ?? null;
}
return normalized[0] ?? null;
};
export const ProjectActionsButton = ({
projectRef,
@@ -307,6 +265,39 @@ export const ProjectActionsButton = ({
}, [actions, canUseAutoDiscover, selectedActionId]);
React.useEffect(() => {
/**
* Decides what an auto-discovery run found, once its servers have had a
* moment to announce themselves. One address is opened; several are offered
* in the browser panel, because choosing between them would be a guess
* dressed up as a feature.
*/
const settleAutoDiscovery = (runKey: string) => {
delete previewWaitTimeoutByRunKeyRef.current[runKey];
const watch = urlWatchByRunKeyRef.current[runKey];
if (!watch || watch.openedUrl) return;
const store = useTerminalStore.getState();
const run = store.projectActionRuns[runKey];
if (!run) return;
const candidates = watch.announced;
if (candidates.length === 0) return;
watch.openedUrl = true;
store.updateProjectActionRunStatus(runKey, 'running');
if (candidates.length === 1) {
setAnnouncedDevServers(run.directory, []);
setTabPreviewUrl(run.directory, run.tabId, candidates[0], { locked: false, autoOpened: true });
openContextPreview(run.directory, candidates[0]);
return;
}
watch.offering = true;
setAnnouncedDevServers(run.directory, candidates);
useUIStore.getState().openContextSurface(run.directory, 'browser');
toast.info(t('projectActions.toast.multipleServers'));
};
const monitorRuns = () => {
const terminalStore = useTerminalStore.getState();
const terminalSessions = terminalStore.sessions;
@@ -319,7 +310,7 @@ export const ProjectActionsButton = ({
continue;
}
const watch = urlWatchByRunKeyRef.current[runKey] ?? { lastSeenChunkId: null, openedUrl: false, tail: '', openInPreview: false };
const watch = urlWatchByRunKeyRef.current[runKey] ?? { lastSeenChunkId: null, openedUrl: false, tail: '', openInPreview: false, announced: [], offering: false };
urlWatchByRunKeyRef.current[runKey] = watch;
const action = displayActions.find((item) => item.id === entry.actionId);
const bufferChunks = terminalStore.getBuffer(entry.directory, entry.tabId).chunks;
@@ -330,7 +321,34 @@ export const ProjectActionsButton = ({
const combined = nextChunks.map((chunk) => chunk.data).join('');
const textForScan = `${watch.tail}${combined}`;
const maybeUrl = !watch.openedUrl && action.autoOpenUrl === true ? extractBestUrl(textForScan) : null;
// Auto-discovery inferred the command; it must not also infer the
// address. It collects what the servers announce and decides once they
// have had a moment to all speak up.
// Keep listening after the panel starts offering candidates: servers in
// one project can be seconds apart, and a list that froze at whoever was
// ready first would quietly omit the rest.
if (watch.openInPreview && (!watch.openedUrl || watch.offering)) {
const announced = extractAnnouncedUrls(textForScan);
const before = watch.announced.length;
for (const url of announced) {
if (!watch.announced.includes(url)) watch.announced.push(url);
}
const added = watch.announced.length - before;
if (watch.offering && added > 0) {
setAnnouncedDevServers(entry.directory, watch.announced);
} else if (!watch.openedUrl && before === 0 && watch.announced.length > 0) {
window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[runKey]);
previewWaitTimeoutByRunKeyRef.current[runKey] = window.setTimeout(
() => settleAutoDiscovery(runKey),
AUTO_DISCOVER_SETTLE_MS,
);
}
}
const maybeUrl = !watch.openedUrl && action.autoOpenUrl === true && !watch.openInPreview
? extractProjectActionUrl(textForScan)
: null;
const lastChunkId = nextChunks[nextChunks.length - 1]?.id ?? watch.lastSeenChunkId;
watch.lastSeenChunkId = lastChunkId;
@@ -551,6 +569,8 @@ export const ProjectActionsButton = ({
openedUrl: Boolean(desktopForwardUrl) || Boolean(manualOpenUrl) || hasCustomOpenUrl,
tail: '',
openInPreview: discovered.id === AUTO_DISCOVER_ACTION_ID,
announced: [],
offering: false,
};
const normalizedCommand = stripControlChars(discovered.command.trim().replace(/\r\n|\r/g, '\n'));
@@ -1,55 +1,62 @@
import React from 'react';
import { ProjectNotesTodoPanel } from '@/components/session/ProjectNotesTodoPanel';
import { ProjectNotesTodoPanel } from '@/components/session/project-context/ProjectNotesTodoPanel';
import { useGitStore } from '@/stores/useGitStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { formatDirectoryName } from '@/lib/utils';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useProjectContextOwner } from '@/hooks/useProjectContextOwner';
import { CHAT_DRAFT_PROJECT_ID } from '@/lib/chatDirectories';
import type { ProjectRef } from '@/lib/projectContextApi';
import { useI18n } from '@/lib/i18n';
export const ProjectContextPanel: React.FC<{
onActionComplete?: () => void;
onOpenPlan?: (plan: { path: string; title: string }) => void;
onOpenPlan?: (plan: { id: string; title: string; projectRef: ProjectRef }) => void;
}> = ({ onActionComplete, onOpenPlan }) => {
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
const projects = useProjectsStore((state) => state.projects);
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
const { t } = useI18n();
const gitDirectories = useGitStore((state) => state.directories);
const chatSessionDirectory = useSessionUIStore((state) => state.currentSessionDirectory);
const activeProject = React.useMemo(() => {
if (activeProjectId) {
return projects.find((project) => project.id === activeProjectId) ?? projects[0] ?? null;
}
return projects[0] ?? null;
}, [activeProjectId, projects]);
// One owner decision shared with the panel, agent memory, and PlanView:
// chats resolve to the Chats owner, worktrees to their project, and an
// unrecognized directory owns nothing (null) rather than borrowing
// whichever project happens to be active.
const projectRef = useProjectContextOwner(chatSessionDirectory);
const projectRef = React.useMemo(() => {
if (!activeProject) {
return null;
}
return {
id: activeProject.id,
path: activeProject.path,
};
}, [activeProject]);
// Display-only lookup: a user-renamed project label wins over the directory
// name. The owner decision stays with the hook — this must not reintroduce
// a fallback.
const projects = useProjectsStore((state) => state.projects);
const labeledProject = React.useMemo(
() => (projectRef ? projects.find((project) => project.id === projectRef.id) ?? null : null),
[projectRef, projects],
);
const projectLabel = React.useMemo(() => {
if (!activeProject) {
if (!projectRef) {
return null;
}
return activeProject.label?.trim()
|| formatDirectoryName(activeProject.path, homeDirectory)
|| activeProject.path;
}, [activeProject, homeDirectory]);
if (projectRef.id === CHAT_DRAFT_PROJECT_ID) {
return t('sessions.sidebar.activity.chatsTitle');
}
return labeledProject?.label?.trim()
|| formatDirectoryName(projectRef.path, homeDirectory)
|| projectRef.path;
}, [homeDirectory, labeledProject, projectRef, t]);
const canCreateWorktree = React.useMemo(() => {
if (!activeProject) {
if (!projectRef || projectRef.id === CHAT_DRAFT_PROJECT_ID) {
return false;
}
return gitDirectories.get(activeProject.path)?.isGitRepo === true;
}, [activeProject, gitDirectories]);
return gitDirectories.get(projectRef.path)?.isGitRepo === true;
}, [gitDirectories, projectRef]);
return (
<div className="h-full min-h-0 overflow-auto bg-background">
/* The panel scrolls its own tab content; a scroller here would nest. */
<div className="h-full min-h-0 overflow-hidden bg-background">
<ProjectNotesTodoPanel
projectRef={projectRef}
projectLabel={projectLabel}
@@ -0,0 +1,426 @@
import React from 'react';
import {
DndContext,
MouseSensor,
TouchSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
type Modifier,
} from '@dnd-kit/core';
import {
SortableContext,
horizontalListSortingStrategy,
useSortable,
} from '@dnd-kit/sortable';
import { CSS as DndCSS } from '@dnd-kit/utilities';
import { ContextMenu } from '@base-ui/react/context-menu';
import type { Session } from '@opencode-ai/sdk/v2';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { dropdownMenuItemClass, dropdownMenuPopupClass, dropdownMenuSeparatorClass } from '@/components/ui/dropdown-menu.styles';
import { Icon } from '@/components/icon/Icon';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { useSessionTabsStore } from '@/stores/useSessionTabsStore';
import { closeSessionTabAndActivateNeighbour } from '@/lib/sessionTabs';
import { useGlobalSessionsStore, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useGlobalSessionStatus } from '@/sync/sync-context';
import { useSessionUnseenCount } from '@/sync/notification-store';
const restrictToXAxis: Modifier = ({ transform }) => ({ ...transform, y: 0 });
type SessionTab = { id: string; session: Session };
export type SessionTabMenuComponents = {
Item: React.ComponentType<{
className?: string;
disabled?: boolean;
onClick?: React.MouseEventHandler;
children?: React.ReactNode;
}>;
Separator: React.ComponentType<{ className?: string }>;
};
export type SessionTabMenuArgs = {
session: Session;
isActive: boolean;
select: () => void;
closeOtherTabs: () => void;
/** Menu primitives for the surface the menu opens in (dropdown or context menu). */
components: SessionTabMenuComponents;
};
const dropdownComponents: SessionTabMenuComponents = {
Item: DropdownMenuItem,
Separator: DropdownMenuSeparator,
};
const contextComponents: SessionTabMenuComponents = {
Item: ({ className, ...props }) => (
<ContextMenu.Item className={cn(dropdownMenuItemClass, className)} {...props} />
),
Separator: ({ className, ...props }) => (
<ContextMenu.Separator className={cn(dropdownMenuSeparatorClass, className)} {...props} />
),
};
/**
* One tab, active or not. The tab drags to reorder; the menu and close
* controls sit in a hover-revealed overlay at the tab's end (menu first,
* close after it). One session menu — supplied by the header via
* `renderMenu` — backs both the "..." dropdown and the right-click context
* menu, which opens under the cursor without changing the active tab. The
* dropdown's anchor overlay stays mounted through the close animation so the
* popup never flashes detached. While the active tab is renaming, the
* overlay is suppressed entirely — only the rename controls show.
*/
const SessionTabItem: React.FC<{
tab: SessionTab;
isActive: boolean;
suppressControls: boolean;
onSelect: (tab: SessionTab) => void;
onClose: (id: string) => void;
renderMenu: (args: SessionTabMenuArgs) => React.ReactNode;
closeOtherTabs: (id: string) => void;
onMenuOpenChangeComplete?: (open: boolean) => void;
children?: React.ReactNode;
}> = ({ tab, isActive, suppressControls, onSelect, onClose, renderMenu, closeOtherTabs, onMenuOpenChangeComplete, children }) => {
const { t } = useI18n();
const [menuOpen, setMenuOpen] = React.useState(false);
// Keeps the overlay (the dropdown's anchor) mounted through the close animation.
const [menuVisible, setMenuVisible] = React.useState(false);
const [contextMenuOpen, setContextMenuOpen] = React.useState(false);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: tab.id });
const title = tab.session.title?.trim() || t('sessions.sidebar.session.untitled');
const overlayVisible = !suppressControls && (menuOpen || menuVisible);
// Session state for the dot and the hover tooltip.
const sessionStatus = useGlobalSessionStatus(tab.id);
const isStreaming = sessionStatus?.type === 'busy' || sessionStatus?.type === 'retry';
const unseenCount = useSessionUnseenCount(tab.id);
const showUnread = unseenCount > 0 && !isActive && !isStreaming;
const showDot = isStreaming || showUnread;
const dotLabel = isStreaming
? t('sessions.sidebar.session.status.active')
: t('sessions.sidebar.session.status.unread');
const menuArgsFor = (components: SessionTabMenuComponents): SessionTabMenuArgs => ({
session: tab.session,
isActive,
select: () => onSelect(tab),
closeOtherTabs: () => closeOtherTabs(tab.id),
components,
});
return (
<div
ref={setNodeRef}
style={{ transform: DndCSS.Translate.toString(transform), transition }}
className={cn('session-tab-slot flex h-7 w-44 shrink-0 touch-none', isDragging && 'z-10 opacity-60')}
data-active={isActive ? 'true' : 'false'}
{...(isActive ? { 'data-active-session-tab': true } : {})}
{...attributes}
{...listeners}
>
<ContextMenu.Root
open={contextMenuOpen}
onOpenChange={setContextMenuOpen}
onOpenChangeComplete={(open) => onMenuOpenChangeComplete?.(open)}
>
<ContextMenu.Trigger
render={(triggerProps) => (
<div
{...triggerProps}
role="tab"
aria-selected={isActive}
tabIndex={isActive ? undefined : 0}
onClick={isActive ? undefined : () => onSelect(tab)}
onKeyDown={isActive ? undefined : (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onSelect(tab);
}
}}
onAuxClick={(event) => {
if (event.button === 1) {
event.preventDefault();
onClose(tab.id);
}
}}
data-controls-open={overlayVisible ? 'true' : 'false'}
className={cn(
'session-tab group/session-tab relative flex h-7 w-full min-w-0 select-none items-center rounded-md px-2',
'transition-colors duration-75',
isActive
? 'bg-interactive-selection'
: cn(
'cursor-pointer text-muted-foreground hover:bg-interactive-hover hover:text-foreground',
overlayVisible && 'bg-interactive-hover text-foreground',
),
)}
>
<div className={cn(
'flex min-w-0 flex-1 items-center',
!suppressControls && 'group-hover/session-tab:pr-10',
overlayVisible && 'pr-10',
)}
>
<div className={cn(
'min-w-0 flex-1 overflow-hidden whitespace-nowrap',
!suppressControls && 'session-tab-title',
)}
>
{isActive ? children : (
<span className="text-[13px] font-medium leading-4">{title}</span>
)}
</div>
{showDot ? (
<span
className={cn(
'ml-1.5 h-1.5 w-1.5 shrink-0 rounded-full',
isStreaming ? 'bg-primary' : 'bg-[var(--status-info)]',
!suppressControls && 'group-hover/session-tab:opacity-0',
overlayVisible && 'opacity-0',
)}
aria-label={dotLabel}
/>
) : null}
</div>
{!suppressControls ? (
<div
onClick={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
className={cn(
'absolute right-1 top-1/2 hidden -translate-y-1/2 items-center gap-0.5',
'opacity-0 transition-opacity duration-150',
'group-hover/session-tab:flex group-hover/session-tab:opacity-100',
overlayVisible && 'flex opacity-100',
)}
>
<DropdownMenu
open={menuOpen}
onOpenChange={(open) => {
setMenuOpen(open);
if (open) setMenuVisible(true);
}}
onOpenChangeComplete={(open) => {
if (!open) setMenuVisible(false);
onMenuOpenChangeComplete?.(open);
}}
>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label={t('header.sessionTabs.tabMenuAria')}
className="flex size-5 items-center justify-center rounded text-muted-foreground hover:text-foreground"
>
<Icon name="more" className="size-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[190px]">
{renderMenu(menuArgsFor(dropdownComponents))}
</DropdownMenuContent>
</DropdownMenu>
<button
type="button"
aria-label={t('header.sessionTabs.closeTab')}
onClick={() => onClose(tab.id)}
className="flex size-5 items-center justify-center rounded text-muted-foreground hover:text-foreground"
>
<Icon name="close" className="size-4" />
</button>
</div>
) : null}
</div>
)}
/>
<ContextMenu.Portal>
<ContextMenu.Positioner className="app-region-no-drag z-50">
<ContextMenu.Popup
data-slot="dropdown-menu-content"
style={{ color: 'var(--surface-elevated-foreground)' }}
className={cn(dropdownMenuPopupClass, 'min-w-[190px]')}
>
{renderMenu(menuArgsFor(contextComponents))}
</ContextMenu.Popup>
</ContextMenu.Positioner>
</ContextMenu.Portal>
</ContextMenu.Root>
</div>
);
};
/**
* The header's horizontal working set of sessions (web/desktop only).
*
* Every session the user opens joins the strip once; the tab whose session is
* current renders `children` — the header's title/rename block — inside a
* selected pill. Closing a tab only removes it from the strip; closing the
* active one activates its neighbour. Ids whose session has not loaded (or
* was archived/deleted) stay in the store but do not render, so a partial
* session list never destroys the working set.
*/
export const SessionTabsStrip: React.FC<{
/** Menu items for one tab's session, supplied by the header. */
renderMenu: (args: SessionTabMenuArgs) => React.ReactNode;
/** Fires when a tab menu finishes opening/closing (deferred rename hook). */
onMenuOpenChangeComplete?: (open: boolean) => void;
/** While the active tab renames, its hover controls stay hidden. */
suppressActiveTabControls?: boolean;
children: React.ReactNode;
}> = ({ renderMenu, onMenuOpenChangeComplete, suppressActiveTabControls = false, children }) => {
const { t } = useI18n();
const tabIds = useSessionTabsStore((state) => state.tabIds);
const ensureTab = useSessionTabsStore((state) => state.ensureTab);
const closeOtherTabs = useSessionTabsStore((state) => state.closeOtherTabs);
const reorderTabs = useSessionTabsStore((state) => state.reorderTabs);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
const activeSessions = useGlobalSessionsStore((state) => state.activeSessions);
// Opening a session anywhere (sidebar, palette, deep link) adds its tab.
React.useEffect(() => {
if (currentSessionId) ensureTab(currentSessionId);
}, [currentSessionId, ensureTab]);
const sessionsById = React.useMemo(() => {
const map = new Map<string, Session>();
for (const session of activeSessions) map.set(session.id, session);
return map;
}, [activeSessions]);
// Only tabs with a known live session render; unknown ids stay stored.
const tabs = React.useMemo<SessionTab[]>(() => {
const list: SessionTab[] = [];
for (const id of tabIds) {
const session = sessionsById.get(id);
if (session) list.push({ id, session });
}
return list;
}, [tabIds, sessionsById]);
const handleSelect = React.useCallback((tab: SessionTab) => {
setCurrentSession(tab.id, resolveGlobalSessionDirectory(tab.session));
}, [setCurrentSession]);
const handleClose = React.useCallback((id: string) => {
closeSessionTabAndActivateNeighbour(id);
}, []);
const handleCloseOthers = React.useCallback((id: string) => {
closeOtherTabs(id);
if (currentSessionId && currentSessionId !== id) {
const kept = tabs.find((tab) => tab.id === id);
if (kept) handleSelect(kept);
}
}, [closeOtherTabs, currentSessionId, handleSelect, tabs]);
const sensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 8 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 6 } }),
);
const handleDragEnd = React.useCallback((event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
reorderTabs(String(active.id), String(over.id));
}
}, [reorderTabs]);
// Soft fade at the edges while more tabs hide behind them.
const scrollRef = React.useRef<HTMLDivElement | null>(null);
const [edges, setEdges] = React.useState({ left: false, right: false });
const updateEdges = React.useCallback(() => {
const node = scrollRef.current;
if (!node) return;
const left = node.scrollLeft > 2;
const right = node.scrollLeft + node.clientWidth < node.scrollWidth - 2;
setEdges((prev) => (prev.left === left && prev.right === right ? prev : { left, right }));
}, []);
React.useEffect(() => {
updateEdges();
const node = scrollRef.current;
if (!node || !globalThis.ResizeObserver) return;
const observer = new ResizeObserver(updateEdges);
observer.observe(node);
return () => observer.disconnect();
}, [updateEdges, tabs.length]);
// Keep the active tab in view when it changes.
React.useEffect(() => {
scrollRef.current
?.querySelector('[data-active-session-tab]')
?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
}, [currentSessionId]);
const maskImage = edges.left && edges.right
? 'linear-gradient(to right, transparent, black 24px, black calc(100% - 24px), transparent)'
: edges.left
? 'linear-gradient(to right, transparent, black 24px)'
: edges.right
? 'linear-gradient(to right, black calc(100% - 24px), transparent)'
: undefined;
const tabIdsInOrder = React.useMemo(() => tabs.map((tab) => tab.id), [tabs]);
// A brand-new draft (no session yet) shows as a transient active pill after
// the tabs; it becomes a real tab once the first message creates the session.
const showDraftPill = !currentSessionId || !tabs.some((tab) => tab.id === currentSessionId);
return (
<div className="app-region-no-drag flex h-full min-w-0 flex-1 items-center" role="tablist" aria-label={t('header.sessionTabs.stripAria')}>
<div
ref={scrollRef}
onScroll={updateEdges}
className="session-tabs-scroll flex min-w-0 flex-1 items-center gap-1.5 overflow-x-auto overscroll-x-contain"
style={maskImage ? { maskImage, WebkitMaskImage: maskImage } : undefined}
>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToXAxis]}
onDragEnd={handleDragEnd}
>
<SortableContext items={tabIdsInOrder} strategy={horizontalListSortingStrategy}>
{tabs.map((tab) => (
<SessionTabItem
key={tab.id}
tab={tab}
isActive={tab.id === currentSessionId}
suppressControls={tab.id === currentSessionId && suppressActiveTabControls}
onSelect={handleSelect}
onClose={handleClose}
renderMenu={renderMenu}
closeOtherTabs={handleCloseOthers}
onMenuOpenChangeComplete={onMenuOpenChangeComplete}
>
{tab.id === currentSessionId ? children : null}
</SessionTabItem>
))}
</SortableContext>
</DndContext>
{showDraftPill ? (
<div
role="tab"
aria-selected
className="session-tab-slot flex h-7 w-44 shrink-0 items-center rounded-md bg-interactive-selection px-2"
data-active="true"
>
<div className="min-w-0 flex-1">{children}</div>
</div>
) : null}
</div>
</div>
);
};
@@ -127,8 +127,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children, cl
ref={sidebarRef}
className={cn(
'relative flex h-full overflow-hidden border-r border-border will-change-[width] motion-reduce:transition-none',
'bg-sidebar oc-vibrancy-surface',
isOpen && 'shadow-[inset_-2px_0_10px_-2px_rgb(0_0_0_/_0.06)]',
'bg-sidebar',
!isOpen && 'border-r-0',
className,
)}
@@ -144,6 +143,12 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children, cl
}}
aria-hidden={!isOpen || appliedWidth === 0}
>
{isOpen && (
<div
className="pointer-events-none absolute inset-0 z-30 shadow-[inset_-2px_0_10px_-2px_rgb(0_0_0_/_0.06)]"
aria-hidden="true"
/>
)}
{isOpen && (
<div
className={cn(
@@ -43,6 +43,8 @@ import { opencodeClient } from '@/lib/opencode/client';
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
import { Icon } from "@/components/icon/Icon";
import { getContextFileOpenFailureMessage, validateContextFileOpen } from '@/lib/contextFileOpenGuard';
import { isFilesystemError } from '@/lib/api/files-errors';
import { notifyFileContentInvalidated } from '@/lib/fileContentInvalidation';
import { isBrowserClientRuntime } from '@/lib/desktop';
import { useI18n } from '@/lib/i18n';
import { recordFileTreeDragStart, shouldTreatFileTreeDragEndAsClick } from './fileTreeDragClick';
@@ -55,6 +57,40 @@ type FileNode = {
relativePath?: string;
};
type UploadConflicts = {
directory: string;
files: File[];
runtimeKey: string;
workspaceRoot: string;
};
type UploadOutcome = 'uploaded' | 'conflict' | 'failed';
const MAX_PARALLEL_UPLOADS = 3;
const hasExternalFiles = (dataTransfer: DataTransfer): boolean => (
Array.from(dataTransfer.types).includes('Files')
);
const getExternalFiles = (dataTransfer: DataTransfer): File[] => {
const items = Array.from(dataTransfer.items);
if (items.length === 0) return Array.from(dataTransfer.files);
return items.flatMap((item) => {
if (item.kind !== 'file' || item.webkitGetAsEntry()?.isDirectory) return [];
const file = item.getAsFile();
return file ? [file] : [];
});
};
const getUploadName = (file: File): string | null => {
const name = file.name;
if (!name || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) {
return null;
}
return name;
};
const sortNodes = (items: FileNode[]) =>
items.slice().sort((a, b) => {
if (a.type !== b.type) {
@@ -94,6 +130,22 @@ const getRelativePath = (root: string, path: string): string => {
return normalizedPath.slice(normalizedRoot.length + 1);
};
const getDropTargetLabel = (root: string, target: string): string => {
const relativePath = getRelativePath(root, target);
if (relativePath !== '.') return relativePath;
const normalizedRoot = normalizePath(root);
return normalizedRoot.split('/').filter(Boolean).pop() ?? normalizedRoot;
};
const getParentPath = (value: string): string => {
const normalized = normalizePath(value);
const separatorIndex = normalized.lastIndexOf('/');
if (separatorIndex < 0) return '';
if (separatorIndex === 0) return '/';
return normalized.slice(0, separatorIndex);
};
const isAbsolutePath = (value: string): boolean => {
return value.startsWith('/') || value.startsWith('//') || /^[A-Za-z]:\//.test(value);
};
@@ -195,6 +247,8 @@ interface FileRowProps {
isBrowserClient: boolean;
status?: FileStatus | null;
badge?: { modified: number; added: number } | null;
isDropTarget: boolean;
canUpload: boolean;
permissions: {
canRename: boolean;
canCreateFile: boolean;
@@ -207,6 +261,8 @@ interface FileRowProps {
onToggle: (path: string) => void;
onRevealPath: (path: string) => void;
onOpenDialog: (type: 'createFile' | 'createFolder' | 'rename' | 'delete', data: { path: string; name?: string; type?: 'file' | 'directory' }) => void;
onSetDropTarget: (path: string | null) => void;
onDropFiles: (directory: string, dataTransfer: DataTransfer) => void;
}
const FileRow: React.FC<FileRowProps> = ({
@@ -217,15 +273,20 @@ const FileRow: React.FC<FileRowProps> = ({
isBrowserClient,
status,
badge,
isDropTarget,
canUpload,
permissions,
downloadFile,
onSelect,
onToggle,
onRevealPath,
onOpenDialog,
onSetDropTarget,
onDropFiles,
}) => {
const { t } = useI18n();
const isDir = node.type === 'directory';
const uploadDirectory = isDir ? node.path : getParentPath(node.path);
const { canRename, canCreateFile, canCreateFolder, canDelete, canReveal } = permissions;
const canDownload = !isDir && Boolean(downloadFile);
const canRevealPath = canReveal && !isBrowserClient;
@@ -342,9 +403,40 @@ const FileRow: React.FC<FileRowProps> = ({
}
}, [handleInteraction]);
const handleExternalDragOver = React.useCallback((event: React.DragEvent) => {
if (!canUpload || !uploadDirectory || !hasExternalFiles(event.dataTransfer)) return;
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = 'copy';
onSetDropTarget(uploadDirectory);
}, [canUpload, onSetDropTarget, uploadDirectory]);
const handleExternalDragLeave = React.useCallback((event: React.DragEvent) => {
if (!canUpload || !uploadDirectory || !hasExternalFiles(event.dataTransfer)) return;
if (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget)) return;
event.stopPropagation();
onSetDropTarget(null);
}, [canUpload, onSetDropTarget, uploadDirectory]);
const handleExternalDrop = React.useCallback((event: React.DragEvent) => {
if (!canUpload || !uploadDirectory || !hasExternalFiles(event.dataTransfer)) return;
event.preventDefault();
event.stopPropagation();
onDropFiles(uploadDirectory, event.dataTransfer);
}, [canUpload, onDropFiles, uploadDirectory]);
return (
<ContextMenu open={rightClickOpen} onOpenChange={setRightClickOpen}>
<ContextMenuTrigger render={<div className="group relative flex items-center" onContextMenu={handleContextMenu} />}>
<ContextMenuTrigger render={(
<div
className="group relative flex items-center"
onContextMenu={handleContextMenu}
onDragEnter={handleExternalDragOver}
onDragOver={handleExternalDragOver}
onDragLeave={handleExternalDragLeave}
onDrop={handleExternalDrop}
/>
)}>
<button
type="button"
onClick={handleInteraction}
@@ -354,7 +446,9 @@ const FileRow: React.FC<FileRowProps> = ({
onDragEnd={handleDragEnd}
className={cn(
'flex w-full items-center gap-1.5 rounded-md px-2 py-1 text-left text-foreground transition-colors pr-8 select-none',
isActive ? 'bg-interactive-selection/70' : 'hover:bg-interactive-hover/40'
isDropTarget
? 'bg-interactive-selection ring-2 ring-inset ring-primary'
: (isActive ? 'bg-interactive-selection/70' : 'hover:bg-interactive-hover/40')
)}
>
{isDir ? (
@@ -424,12 +518,16 @@ const areFileRowPropsEqual = (prev: FileRowProps, next: FileRowProps): boolean =
&& prev.isBrowserClient === next.isBrowserClient
&& prev.status === next.status
&& prev.badge === next.badge
&& prev.isDropTarget === next.isDropTarget
&& prev.canUpload === next.canUpload
&& prev.permissions === next.permissions
&& prev.downloadFile === next.downloadFile
&& prev.onSelect === next.onSelect
&& prev.onToggle === next.onToggle
&& prev.onRevealPath === next.onRevealPath
&& prev.onOpenDialog === next.onOpenDialog
&& prev.onSetDropTarget === next.onSetDropTarget
&& prev.onDropFiles === next.onDropFiles
);
const MemoizedFileRow = React.memo(FileRow, areFileRowPropsEqual);
@@ -453,6 +551,12 @@ export const SidebarFilesTree: React.FC = () => {
const searchInputRef = React.useRef<HTMLInputElement>(null);
const [searchResults, setSearchResults] = React.useState<FileNode[]>([]);
const [searching, setSearching] = React.useState(false);
const [dropTarget, setDropTarget] = React.useState<string | null>(null);
const [isUploading, setIsUploading] = React.useState(false);
const [uploadConflicts, setUploadConflicts] = React.useState<UploadConflicts | null>(null);
const uploadingRef = React.useRef(false);
const rootRef = React.useRef(root);
rootRef.current = root;
const [childrenByDir, setChildrenByDir] = React.useState<Record<string, FileNode[]>>({});
const [loadErrorsByDir, setLoadErrorsByDir] = React.useState<Record<string, string>>({});
@@ -466,6 +570,8 @@ export const SidebarFilesTree: React.FC = () => {
// combining the two means the tree re-paints with cached data instead
// of blanking out and re-listing every directory.
React.useEffect(() => {
setDropTarget(null);
setUploadConflicts(null);
if (!root) {
setChildrenByDir({});
setLoadErrorsByDir({});
@@ -553,6 +659,7 @@ export const SidebarFilesTree: React.FC = () => {
const canRename = Boolean(files.rename);
const canDelete = Boolean(files.delete);
const canReveal = Boolean(files.revealPath);
const canUpload = Boolean(files.uploadFile);
const fileRowPermissions = React.useMemo(
() => ({ canRename, canCreateFile, canCreateFolder, canDelete, canReveal }),
@@ -906,6 +1013,110 @@ export const SidebarFilesTree: React.FC = () => {
}
}, [loadDirectory, root, toggleExpandedPath]);
const uploadDroppedFiles = React.useCallback(async (
directory: string,
droppedFiles: File[],
overwrite = false,
) => {
const uploadFile = files.uploadFile;
if (!uploadFile || droppedFiles.length === 0 || uploadingRef.current || !root) return;
const operationRoot = root;
const operationRuntime = getRuntimeKey();
uploadingRef.current = true;
setIsUploading(true);
setDropTarget(directory);
if (overwrite) setUploadConflicts(null);
const outcomes: UploadOutcome[] = [];
for (let index = 0; index < droppedFiles.length; index += MAX_PARALLEL_UPLOADS) {
const batch = droppedFiles.slice(index, index + MAX_PARALLEL_UPLOADS);
const batchOutcomes = await Promise.all(batch.map(async (file): Promise<UploadOutcome> => {
const name = getUploadName(file);
if (!name || getRuntimeKey() !== operationRuntime) return 'failed';
try {
const result = await uploadFile(normalizePath(`${directory}/${name}`), file, {
directory: operationRoot,
overwrite,
});
return result.success ? 'uploaded' : 'failed';
} catch (error) {
if (!overwrite && isFilesystemError(error) && error.reason === 'already-exists') {
return 'conflict';
}
return 'failed';
}
}));
outcomes.push(...batchOutcomes);
}
const uploadedCount = outcomes.filter((outcome) => outcome === 'uploaded').length;
const failedCount = outcomes.filter((outcome) => outcome === 'failed').length;
const conflictingFiles = droppedFiles.filter((_, index) => outcomes[index] === 'conflict');
const uploadedPaths = droppedFiles.flatMap((file, index) => {
const name = getUploadName(file);
return outcomes[index] === 'uploaded' && name
? [normalizePath(`${directory}/${name}`)]
: [];
});
const isCurrentDestination = rootRef.current === operationRoot && getRuntimeKey() === operationRuntime;
try {
if (uploadedPaths.length > 0) {
notifyFileContentInvalidated({ runtimeKey: operationRuntime, paths: uploadedPaths });
}
if (uploadedCount > 0 && isCurrentDestination) {
await refreshDirectory(directory);
}
if (uploadedCount > 0) {
toast.success(t(conflictingFiles.length > 0
? 'sidebarFilesTree.toast.uploadedWithoutConflicts'
: 'sidebarFilesTree.toast.uploaded'));
}
if (failedCount > 0) {
toast.error(t('sidebarFilesTree.toast.uploadFailed'));
}
if (conflictingFiles.length > 0 && isCurrentDestination) {
setUploadConflicts({
directory,
files: conflictingFiles,
runtimeKey: operationRuntime,
workspaceRoot: operationRoot,
});
}
} finally {
uploadingRef.current = false;
setIsUploading(false);
setDropTarget(null);
}
}, [files.uploadFile, refreshDirectory, root, t]);
const handleDropFiles = React.useCallback((directory: string, dataTransfer: DataTransfer) => {
const droppedFiles = getExternalFiles(dataTransfer);
if (droppedFiles.length === 0) return;
void uploadDroppedFiles(directory, droppedFiles);
}, [uploadDroppedFiles]);
const handleRootDragOver = React.useCallback((event: React.DragEvent) => {
if (!canUpload || uploadingRef.current || !root || !hasExternalFiles(event.dataTransfer)) return;
event.preventDefault();
event.dataTransfer.dropEffect = 'copy';
setDropTarget(root);
}, [canUpload, root]);
const handleRootDragLeave = React.useCallback((event: React.DragEvent) => {
if (!hasExternalFiles(event.dataTransfer)) return;
if (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget)) return;
setDropTarget(null);
}, []);
const handleRootDrop = React.useCallback((event: React.DragEvent) => {
if (!canUpload || uploadingRef.current || !root || !hasExternalFiles(event.dataTransfer)) return;
event.preventDefault();
handleDropFiles(root, event.dataTransfer);
}, [canUpload, handleDropFiles, root]);
// --- Dialog submit (matching FilesView) ---
const handleDialogSubmit = React.useCallback(async (e?: React.FormEvent) => {
@@ -1065,12 +1276,16 @@ export const SidebarFilesTree: React.FC = () => {
isBrowserClient={isBrowserClient}
status={!isDir ? getFileStatus(node.path) : undefined}
badge={isDir ? getFolderBadge(node.path) : undefined}
isDropTarget={isDir && dropTarget === node.path}
canUpload={canUpload && !isUploading}
permissions={fileRowPermissions}
downloadFile={files.downloadFile}
onSelect={handleOpenFile}
onToggle={toggleDirectory}
onRevealPath={handleRevealPath}
onOpenDialog={handleOpenDialog}
onSetDropTarget={setDropTarget}
onDropFiles={handleDropFiles}
/>
{isDir && isExpanded && (
<ul className="flex flex-col gap-1 ml-3 pl-3 border-l border-border/40 relative">
@@ -1093,6 +1308,7 @@ export const SidebarFilesTree: React.FC = () => {
const hasTree = Boolean(root && childrenByDir[root]);
const rootLoadError = root ? loadErrorsByDir[root] : null;
const dropTargetLabel = dropTarget ? getDropTargetLabel(root, dropTarget) : '';
return (
<section className="flex h-full min-h-0 flex-col overflow-hidden">
@@ -1191,7 +1407,15 @@ export const SidebarFilesTree: React.FC = () => {
</div>
</div>
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="p-2">
<div className="relative flex-1 min-h-0">
<ScrollableOverlay
outerClassName="h-full min-h-0"
className={cn('p-2', dropTarget === root && 'bg-interactive-selection/10')}
onDragEnter={handleRootDragOver}
onDragOver={handleRootDragOver}
onDragLeave={handleRootDragLeave}
onDrop={handleRootDrop}
>
<ul className="flex flex-col">
{searching ? (
<li className="flex items-center gap-1.5 px-2 py-1 typography-meta text-muted-foreground">
@@ -1251,7 +1475,52 @@ export const SidebarFilesTree: React.FC = () => {
<li className="px-2 py-1 typography-meta text-muted-foreground">{t('sidebarFilesTree.state.loading')}</li>
)}
</ul>
</ScrollableOverlay>
</ScrollableOverlay>
{dropTarget ? (
<div className="pointer-events-none absolute left-2 right-2 top-2 z-50 flex items-center gap-2 rounded-md border border-primary bg-background/95 px-2 py-1.5 shadow-sm">
<Icon name={isUploading ? 'loader-4' : 'folder-received'} className={cn('size-4 flex-shrink-0', isUploading && 'animate-spin')} />
<span className="min-w-0 truncate typography-meta" title={dropTargetLabel}>
{t(isUploading ? 'sidebarFilesTree.drop.uploading' : 'sidebarFilesTree.drop.target', { path: dropTargetLabel })}
</span>
</div>
) : null}
</div>
<Dialog open={Boolean(uploadConflicts)} onOpenChange={(open: boolean) => !open && setUploadConflicts(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('sidebarFilesTree.dialog.uploadConflicts.title')}</DialogTitle>
<DialogDescription>
{t('sidebarFilesTree.dialog.uploadConflicts.description', { path: uploadConflicts?.directory ?? '' })}
</DialogDescription>
</DialogHeader>
<ScrollableOverlay outerClassName="max-h-52" className="flex flex-col gap-1 pr-2">
{uploadConflicts?.files.map((file, index) => (
<div key={`${file.name}-${file.size}-${index}`} className="truncate rounded-md bg-muted px-2 py-1 typography-meta" title={file.name}>
{file.name}
</div>
))}
</ScrollableOverlay>
<DialogFooter>
<Button variant="outline" onClick={() => setUploadConflicts(null)} disabled={isUploading}>
{t('sidebarFilesTree.dialog.cancel')}
</Button>
<Button
onClick={() => {
if (!uploadConflicts) return;
if (uploadConflicts.runtimeKey !== getRuntimeKey() || uploadConflicts.workspaceRoot !== root) {
setUploadConflicts(null);
return;
}
void uploadDroppedFiles(uploadConflicts.directory, uploadConflicts.files, true);
}}
disabled={isUploading}
>
{t('sidebarFilesTree.dialog.uploadConflicts.replace')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* CRUD dialogs (matching FilesView) */}
<Dialog open={!!activeDialog} onOpenChange={(open) => !open && setActiveDialog(null)}>
@@ -28,7 +28,6 @@ const ICON_BUTTON_CLASS =
export const TitlebarLeftControls: React.FC = () => {
const { t } = useI18n();
const toggleSidebar = useUIStore((state) => state.toggleSidebar);
const isSidebarOpen = useUIStore((state) => state.isSidebarOpen);
const shortcutOverrides = useUIStore((state) => state.shortcutOverrides);
const projectActionsContext = useProjectActionsContext();
const clusterRef = React.useRef<HTMLDivElement | null>(null);
@@ -129,10 +128,6 @@ export const TitlebarLeftControls: React.FC = () => {
<ProjectActionsButton
projectRef={projectActionsContext.projectRef}
directory={projectActionsContext.directory}
// While the sidebar is open the controls sit over the frosted
// sidebar — let the pill share its translucency instead of painting
// an opaque surface (handled under [data-oc-vibrancy] in CSS).
className={isSidebarOpen ? 'oc-vibrancy-pill' : undefined}
/>
) : null}
</div>
@@ -6,8 +6,10 @@ import { ChatView } from '@/components/views/ChatView';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useViewportStore } from '@/sync/viewport-store';
import { useSessions, useDirectorySync, useSessionMessages, useSessionMessagesResolved } from '@/sync/sync-context';
import { useSubagentCostRollup } from '@/components/chat/work-status/useSubagentCostRollup';
import { useConfigStore } from '@/stores/useConfigStore';
import { resolveGlobalSessionDirectory, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
import { contextTokensFromBreakdown } from '@/stores/utils/tokenUtils';
import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay';
import { McpDropdown } from '@/components/mcp/McpDropdown';
import { ArchiveAllDropdown } from '@/components/session/ArchiveAllDropdown';
@@ -30,9 +32,8 @@ import { useI18n } from '@/lib/i18n';
import { toast } from '@/components/ui';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { UsageProgressBar } from '@/components/sections/usage/UsageProgressBar';
import { PaceIndicator } from '@/components/sections/usage/PaceIndicator';
import { Icon } from "@/components/icon/Icon";
import { formatQuotaValueLabel, formatQuotaResetLabel, formatWindowLabel, QUOTA_PROVIDERS, calculatePace, calculateExpectedUsagePercent } from '@/lib/quota';
import { formatQuotaValueLabel, formatQuotaResetLabel, formatWindowLabel, QUOTA_PROVIDERS } from '@/lib/quota';
import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
import { updateDesktopSettings } from '@/lib/persistence';
import { formatTimeForPreference } from '@/lib/timeFormat';
@@ -41,6 +42,7 @@ import type { Session } from '@opencode-ai/sdk/v2';
import type { UsageWindow } from '@/types';
import type { SessionContextUsage } from '@/stores/types/sessionTypes';
import { useUIStore, type TimeFormatPreference } from '@/stores/useUIStore';
import { useSessionListSync } from '@/components/session/sidebar/list/useSessionListSync';
const SettingsView = lazyWithChunkRecovery(() => import('@/components/views/SettingsView').then(m => ({ default: m.SettingsView })));
@@ -55,10 +57,10 @@ const formatTime = (timestamp: number | null, timeFormatPreference: TimeFormatPr
// Width threshold for mobile vs desktop layout in settings
const MOBILE_WIDTH_THRESHOLD = 550;
// Width threshold for expanded layout (sidebar + chat side by side)
const EXPANDED_LAYOUT_THRESHOLD = 1400;
// Sessions sidebar width in expanded layout
const SESSIONS_SIDEBAR_WIDTH = 280;
// Keep enough room for the chat after adding the persistent sessions sidebar.
const EXPANDED_LAYOUT_THRESHOLD = SESSIONS_SIDEBAR_WIDTH + 520;
const SESSIONS_SIDEBAR_MIN_WIDTH = Math.round(SESSIONS_SIDEBAR_WIDTH * 0.7);
const SESSIONS_SIDEBAR_MAX_WIDTH = 520;
@@ -526,8 +528,11 @@ export const VSCodeLayout: React.FC = () => {
}
}, [usesExpandedLayout, currentView, viewMode]);
useSessionListSync({ isVSCode: true });
return (
<div ref={containerRef} className="h-full w-full bg-background text-foreground flex flex-col">
<>
<div ref={containerRef} className="h-full w-full bg-background text-foreground flex flex-col">
{viewMode === 'editor' ? (
// Editor mode: just chat, no sidebar
<div className="flex flex-col h-full">
@@ -639,7 +644,8 @@ export const VSCodeLayout: React.FC = () => {
</>
)}
<SessionDialogs />
</div>
</div>
</>
);
};
@@ -666,6 +672,9 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
const providers = useConfigStore((state) => state.providers);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
// Same rollup the work-status panel reports, so the header and the panel
// never disagree about what this session has cost.
const { totalCost: sessionTotalCost } = useSubagentCostRollup(currentSessionId ?? null);
const currentSessionMessages = useSessionMessages(currentSessionId ?? '');
const currentSessionMessagesResolved = useSessionMessagesResolved(currentSessionId ?? '');
const quotaResults = useQuotaStore((state) => state.results);
@@ -673,7 +682,6 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
const isQuotaLoading = useQuotaStore((state) => state.isLoading);
const quotaLastUpdated = useQuotaStore((state) => state.lastUpdated);
const quotaDisplayMode = useQuotaStore((state) => state.displayMode);
const showPredValues = useQuotaStore((state) => state.showPredValues);
const timeFormatPreference = useUIStore((state) => state.timeFormatPreference);
const dropdownProviderIds = useQuotaStore((state) => state.dropdownProviderIds);
const loadQuotaSettings = useQuotaStore((state) => state.loadSettings);
@@ -704,7 +712,7 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
}
if (!lastTokens && message.tokens) {
const total = message.tokens.input + message.tokens.output + message.tokens.reasoning + (message.tokens.cache?.read ?? 0) + (message.tokens.cache?.write ?? 0);
const total = contextTokensFromBreakdown(message.tokens);
if (total > 0) {
lastTokens = message.tokens;
lastMessageId = (currentSessionMessages[i] as { id?: string }).id;
@@ -732,7 +740,7 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
}
const lastTokens = headerMessageSummary.lastTokens;
const totalTokens = lastTokens.input + lastTokens.output + lastTokens.reasoning + (lastTokens.cache?.read ?? 0) + (lastTokens.cache?.write ?? 0);
const totalTokens = contextTokensFromBreakdown(lastTokens);
const thresholdLimit = contextLimit > 0 ? contextLimit : 200000;
const percentage = contextLimit > 0 ? Math.round((totalTokens / contextLimit) * 100) : 0;
const normalizedOutput = outputLimit > 0 ? Math.round((lastTokens.output / outputLimit) * 100) : undefined;
@@ -975,12 +983,6 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
const displayPercent = quotaDisplayMode === 'remaining'
? window.remainingPercent
: window.usedPercent;
const paceInfo = calculatePace(window.usedPercent, window.resetAt, window.windowSeconds, label);
const expectedMarker = paceInfo?.dailyAllocationPercent != null
? (quotaDisplayMode === 'remaining'
? 100 - calculateExpectedUsagePercent(paceInfo.elapsedRatio)
: calculateExpectedUsagePercent(paceInfo.elapsedRatio))
: null;
const metricLabel = formatQuotaValueLabel(window.valueLabel, displayPercent);
return (
<DropdownMenuItem
@@ -999,13 +1001,7 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
percent={displayPercent}
tonePercent={window.usedPercent}
className="h-1"
expectedMarkerPercent={expectedMarker}
/>
{paceInfo && showPredValues && (
<div className="mt-0.5">
<PaceIndicator paceInfo={paceInfo} compact />
</div>
)}
<span className="flex items-center justify-between typography-micro text-muted-foreground text-[10px]">
<span>{formatQuotaResetLabel(window.resetAt, window.resetAfterFormatted ?? window.resetAtFormatted, timeFormatPreference)}</span>
</span>
@@ -1035,6 +1031,7 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
percentage={stableContextUsage.percentage}
contextLimit={stableContextUsage.contextLimit}
outputLimit={stableContextUsage.outputLimit ?? 0}
cost={(sessionTotalCost ?? 0) > 0 ? sessionTotalCost : null}
className="h-9 shrink-0 pl-1 pr-1 typography-ui-label"
valueClassName="font-semibold leading-none"
hideIcon
@@ -0,0 +1,212 @@
/**
* Regression coverage for https://github.com/openchamber/openchamber/issues/2815
*
* A full ContextPanel mount is not available in bun test because its import
* graph includes a Vite worker URL. This test follows the source-level guard
* pattern in contextPanelEscapeClosesTerminal.test.ts and uses the real store.
*/
import { afterAll, beforeEach, describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { getDefaultTheme } from '@/lib/theme/themes';
import { useUIStore } from '@/stores/useUIStore';
import {
buildEmbeddedSessionChatURL,
getActiveEmbeddedSessionChatTab,
resetEmbeddedSessionChatCache,
} from '../contextPanelEmbeddedChat';
const __dirname = dirname(fileURLToPath(import.meta.url));
const appSource = readFileSync(join(__dirname, '..', '..', '..', 'App.tsx'), 'utf-8');
const contextPanelSource = readFileSync(join(__dirname, '..', 'ContextPanel.tsx'), 'utf-8');
type FixtureTab = {
id: string;
mode: 'chat' | 'git' | 'diff' | 'plan';
targetPath: string | null;
dedupeKey: string;
label: string | null;
sessionTitleFallback: string | null;
readOnly: boolean;
stagedDiff: boolean;
diffScope: 'working';
touchedAt: number;
};
const DIRECTORY = '/path/to/repository';
const originalWindow = globalThis.window;
const installWindowLocation = () => {
const url = new URL('http://127.0.0.1:3000/');
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
location: {
href: url.toString(),
origin: url.origin,
pathname: url.pathname,
search: url.search,
},
},
});
};
const buildTab = (mode: FixtureTab['mode'], id: string): FixtureTab => ({
id: mode === 'chat' ? `chat:session:${id}` : id,
mode,
targetPath: null,
dedupeKey: mode === 'chat' ? `session:${id}` : id,
label: mode === 'chat' ? `Session ${id}` : null,
sessionTitleFallback: null,
readOnly: mode === 'chat',
stagedDiff: false,
diffScope: 'working',
touchedAt: Date.now(),
});
const sessionChatTabs = Array.from({ length: 8 }, (_, index) => buildTab('chat', `ses_${index + 1}`));
const issueScenarioTabs = [
...sessionChatTabs,
buildTab('git', 'git'),
buildTab('diff', 'diff'),
buildTab('plan', 'plan'),
];
const installIssueScenario = () => {
useUIStore.setState({
contextPanelByDirectory: {
[DIRECTORY]: {
isOpen: true,
expanded: false,
tabs: issueScenarioTabs,
activeTabId: sessionChatTabs[0].id,
widthByMode: {},
touchedAt: Date.now(),
},
} as never,
});
};
beforeEach(() => {
installWindowLocation();
resetEmbeddedSessionChatCache();
useUIStore.setState({ contextPanelByDirectory: {}, contextRailOrder: [] });
installIssueScenario();
});
afterAll(() => {
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: originalWindow,
});
});
describe('issue #2815 active-only chat iframe source guard', () => {
test('does not map persisted chat tabs to iframe elements', () => {
expect(contextPanelSource).not.toContain('{chatTabs.map((tab) => {');
});
test('renders the iframe only when an active chat has a session and URL', () => {
const start = contextPanelSource.indexOf('{activeChatTab && activeChatSessionID && activeChatSrc ? (');
expect(start).toBeGreaterThan(-1);
const end = contextPanelSource.indexOf(') : null}', start);
expect(end).toBeGreaterThan(start);
const block = contextPanelSource.slice(start, end);
expect(block).toContain('<iframe');
expect(block).toContain('key={activeChatTab.id}');
expect(block).toContain('src={activeChatSrc}');
expect(block).toContain('postEmbeddedVisibilityToChats();');
expect(block).not.toContain("'block' : 'hidden'");
});
test('does not select a chat iframe when the context panel is closed', () => {
expect(contextPanelSource).toContain(
"const activeChatTabID = isOpen && activeTab?.mode === 'chat' ? activeTab.id : null;",
);
expect(contextPanelSource).toContain(
"const activeChatSessionID = isOpen && activeTab?.mode === 'chat'",
);
});
test('answers the mounted iframe visibility handshake from the active tab', () => {
expect(contextPanelSource).toContain('data?.type === EMBEDDED_VISIBILITY_REQUEST');
expect(contextPanelSource).toContain('frame.contentWindow === event.source');
expect(contextPanelSource).toContain('payload: { visible: activeChatTabID === tabID }');
});
test('requests authoritative visibility after installing the iframe listener', () => {
const effectStart = appSource.indexOf('const applyVisibility = (payload?: EmbeddedVisibilityPayload) => {');
const listenerIndex = appSource.indexOf("window.addEventListener('message', handleMessage);", effectStart);
const requestIndex = appSource.indexOf('requestEmbeddedSessionVisibility();', effectStart);
expect(effectStart).toBeGreaterThan(-1);
expect(listenerIndex).toBeGreaterThan(effectStart);
expect(requestIndex).toBeGreaterThan(listenerIndex);
});
test('gates embedded chat background work on visibility but keeps message history enabled', () => {
expect(appSource).toContain(
'const embeddedBackgroundWorkEnabled = !embeddedSessionChat || isEmbeddedVisible;',
);
expect(appSource).toContain('active={embeddedBackgroundWorkEnabled}');
expect(appSource).toContain('messagesEnabled={true}');
expect(appSource).toContain(
'useWebNotificationStream({ enabled: embeddedBackgroundWorkEnabled });',
);
});
});
describe('issue #2815 persisted scenario', () => {
test('keeps all tab records but selects one chat for mounting', () => {
const panel = useUIStore.getState().contextPanelByDirectory[DIRECTORY];
const chatTabs = panel.tabs.filter((tab) => tab.mode === 'chat');
const activeTab = getActiveEmbeddedSessionChatTab(chatTabs, panel.activeTabId);
expect(panel.tabs).toHaveLength(11);
expect(chatTabs).toHaveLength(8);
expect(activeTab?.id).toBe(sessionChatTabs[0].id);
});
test('produces one live embedded URL for eight persisted chats', () => {
const panel = useUIStore.getState().contextPanelByDirectory[DIRECTORY];
const chatTabs = panel.tabs.filter((tab) => tab.mode === 'chat');
const activeTab = getActiveEmbeddedSessionChatTab(chatTabs, panel.activeTabId);
const frames = activeTab ? [buildEmbeddedSessionChatURL('ses_1', DIRECTORY, activeTab.readOnly, {
mode: 'system',
lightThemeId: 'light',
darkThemeId: 'dark',
currentTheme: getDefaultTheme(true),
})] : [];
expect(frames).toHaveLength(1);
const url = new URL(frames[0]);
expect(url.searchParams.get('ocPanel')).toBe('session-chat');
expect(url.searchParams.get('sessionId')).toBe('ses_1');
expect(url.searchParams.get('readOnly')).toBe('1');
});
test('selects another single chat after a tab switch', () => {
useUIStore.getState().setActiveContextPanelTab(DIRECTORY, sessionChatTabs[6].id);
const panel = useUIStore.getState().contextPanelByDirectory[DIRECTORY];
const chatTabs = panel.tabs.filter((tab) => tab.mode === 'chat');
const activeTab = getActiveEmbeddedSessionChatTab(chatTabs, panel.activeTabId);
expect(activeTab?.id).toBe(sessionChatTabs[6].id);
expect(chatTabs.filter((tab) => tab.id === activeTab?.id)).toHaveLength(1);
});
test('selects no chat after the panel closes', () => {
useUIStore.getState().closeContextPanel(DIRECTORY);
const panel = useUIStore.getState().contextPanelByDirectory[DIRECTORY];
const chatTabs = panel.tabs.filter((tab) => tab.mode === 'chat');
const activeTabID = panel.isOpen ? panel.activeTabId : null;
expect(panel.isOpen).toBe(false);
expect(getActiveEmbeddedSessionChatTab(chatTabs, activeTabID)).toBeNull();
});
});
@@ -0,0 +1,50 @@
/**
* Regression coverage for https://github.com/openchamber/openchamber/issues/3175
*
* A full ContextPanel mount is not available in bun test because its import
* graph includes a Vite worker URL. This test follows the source-level guard
* pattern used by the neighboring ContextPanel regression tests and exercises
* the real store behavior that the registered opener delegates to.
*/
import { beforeEach, describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { useUIStore } from '@/stores/useUIStore';
const __dirname = dirname(fileURLToPath(import.meta.url));
const contextPanelSource = readFileSync(join(__dirname, '..', 'ContextPanel.tsx'), 'utf-8');
const browserPaneSource = readFileSync(join(__dirname, '..', '..', 'browser', 'BrowserPane.tsx'), 'utf-8');
const DIRECTORY = '/path/to/repository';
beforeEach(() => {
useUIStore.setState({ contextPanelByDirectory: {}, contextRailOrder: [] });
});
describe('issue #3175 browser capture while the context panel is closed', () => {
test('registers the agent browser opener without suppressing panel reveal', () => {
expect(contextPanelSource).toContain(
'registerBrowserOpener((url) => openContextBrowser(effectiveDirectory, url))',
);
expect(contextPanelSource).not.toContain(
'openContextBrowser(effectiveDirectory, url, { reveal: false })',
);
});
test('opening the agent browser gives its webview a visible panel surface', () => {
useUIStore.getState().openContextBrowser(DIRECTORY, 'https://example.com');
const panel = useUIStore.getState().contextPanelByDirectory[DIRECTORY];
expect(panel.isOpen).toBe(true);
expect(panel.tabs).toHaveLength(1);
expect(panel.tabs[0]?.mode).toBe('browser');
expect(panel.tabs[0]?.targetPath).toBe('https://example.com');
});
test('reveals the browser again if it was closed before capture', () => {
expect(browserPaneSource).toContain(
'openContextBrowser(directory, webview.getURL())',
);
});
});
@@ -1,48 +0,0 @@
import { describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const mainLayoutSource = readFileSync(
join(__dirname, '..', 'MainLayout.tsx'),
'utf-8',
);
const sessionSidebarSource = readFileSync(
join(__dirname, '..', '..', 'session', 'SessionSidebar.tsx'),
'utf-8',
);
describe('MainLayout mobile SessionSidebar mount (issue #1695 regression guard)', () => {
test('mobile SessionSidebar is not conditionally mounted on mobileLeftDrawerVisible', () => {
const mobileSidebarIndex = mainLayoutSource.indexOf('<SessionSidebar mobileVariant');
expect(mobileSidebarIndex).toBeGreaterThan(-1);
const windowStart = Math.max(0, mobileSidebarIndex - 400);
const precedingWindow = mainLayoutSource.slice(windowStart, mobileSidebarIndex);
expect(/\{\s*mobileLeftDrawerVisible\s*&&\s*\(/.test(precedingWindow)).toBe(false);
expect(precedingWindow.includes('pointer-events-none')).toBe(true);
expect(mainLayoutSource.slice(mobileSidebarIndex, mobileSidebarIndex + 120)).toContain('isVisible={mobileLeftDrawerVisible}');
});
test('desktop SessionSidebar is rendered inside Sidebar without drawer-visibility gating', () => {
const desktopSidebarIndex = mainLayoutSource.indexOf('<SessionSidebar isVisible={isSidebarOpen} />');
expect(desktopSidebarIndex).toBeGreaterThan(-1);
const windowStart = Math.max(0, desktopSidebarIndex - 300);
const precedingWindow = mainLayoutSource.slice(windowStart, desktopSidebarIndex);
expect(precedingWindow).toContain('<Sidebar');
expect(/mobileLeftDrawerVisible\s*&&/.test(precedingWindow)).toBe(false);
});
test('hidden sidebars disable render-only subscriptions and effects', () => {
expect(sessionSidebarSource).toContain('useGitAllBranches(isVisible)');
expect(sessionSidebarSource).toContain('useGitRepoStatusMap(isVisible ? normalizedProjectPaths : EMPTY_STRING_ARRAY)');
expect(sessionSidebarSource).toContain('enabled: isVisible,\n isSessionSearchOpen');
expect(sessionSidebarSource).toContain('enabled: isVisible,\n isDesktopShellRuntime');
expect(sessionSidebarSource).toContain('if (!isVisible) return EMPTY_STRING_ARRAY;');
});
});
@@ -5,10 +5,13 @@ import {
buildEmbeddedSessionChatURL,
EMBEDDED_RUNTIME_BOOTSTRAP_REQUEST,
EMBEDDED_RUNTIME_BOOTSTRAP_RESPONSE,
EMBEDDED_VISIBILITY_REQUEST,
getOrCreateEmbeddedSessionChatURL,
getActiveEmbeddedSessionChatTab,
getEmbeddedSessionChatOriginSessionId,
isEmbeddedSessionChat,
requestEmbeddedSessionRuntimeBootstrap,
requestEmbeddedSessionVisibility,
resetEmbeddedSessionChatCache,
type EmbeddedSessionChatURLCacheEntry,
} from './contextPanelEmbeddedChat';
@@ -127,6 +130,17 @@ describe('embedded session chat URL', () => {
expect(new URL(second).searchParams.get('themeVariant')).toBe('dark');
});
test('bootstraps subagent prompting before the embedded chat first renders', () => {
const src = buildEmbeddedSessionChatURL('ses_1', '/repo', false, {
mode: 'system',
lightThemeId: 'light-a',
darkThemeId: 'dark-a',
currentTheme: makeTheme('dark-a', 'dark'),
}, { allowPromptingSubagentSessions: true });
expect(new URL(src).searchParams.get('allowPromptingSubagentSessions')).toBe('1');
});
test('rebuilds cached src when readOnly changes for an existing tab', () => {
const cache = new Map<string, EmbeddedSessionChatURLCacheEntry>();
const theme = {
@@ -145,6 +159,38 @@ describe('embedded session chat URL', () => {
});
});
describe('active embedded session chat', () => {
const tabs = Array.from({ length: 8 }, (_, index) => ({
id: `chat-${index + 1}`,
sessionID: `ses_${index + 1}`,
}));
test('selects one tab from persisted chat tabs', () => {
expect(getActiveEmbeddedSessionChatTab(tabs, 'chat-5')).toEqual(tabs[4]);
});
test('selects no tab when a chat is not active', () => {
expect(getActiveEmbeddedSessionChatTab(tabs, null)).toBeNull();
expect(getActiveEmbeddedSessionChatTab(tabs, 'missing-chat')).toBeNull();
});
test('requests authoritative visibility from the same-origin parent', () => {
installWindowLocation('http://127.0.0.1:5173/app?ocPanel=session-chat&sessionId=ses_1');
resetEmbeddedSessionChatCache();
const calls: Array<{ message: unknown; origin: string }> = [];
(window as unknown as { parent: { postMessage: (message: unknown, origin: string) => void } }).parent = {
postMessage: (message, origin) => calls.push({ message, origin }),
};
requestEmbeddedSessionVisibility();
expect(calls).toEqual([{
message: { type: EMBEDDED_VISIBILITY_REQUEST },
origin: 'http://127.0.0.1:5173',
}]);
});
});
describe('isEmbeddedSessionChat', () => {
test('is true only for the session-chat panel search param', () => {
installWindowLocation('http://127.0.0.1:5173/app?ocPanel=session-chat&sessionId=ses_1');
@@ -8,6 +8,10 @@ export type EmbeddedSessionChatThemeBootstrap = {
currentTheme: Theme;
};
export type EmbeddedSessionChatSettingsBootstrap = {
allowPromptingSubagentSessions: boolean;
};
export type EmbeddedSessionChatURLCacheEntry = {
signature: string;
src: string;
@@ -24,6 +28,8 @@ export type EmbeddedSessionRuntimeBootstrap = {
export const EMBEDDED_RUNTIME_BOOTSTRAP_REQUEST = 'openchamber:embedded-runtime-bootstrap-request';
export const EMBEDDED_RUNTIME_BOOTSTRAP_RESPONSE = 'openchamber:embedded-runtime-bootstrap-response';
export const EMBEDDED_VISIBILITY_REQUEST = 'openchamber:embedded-visibility-request';
export const EMBEDDED_VISIBILITY_UPDATE = 'openchamber:embedded-visibility';
const EMBEDDED_RUNTIME_BOOTSTRAP_TIMEOUT_MS = 5_000;
const EMBEDDED_RUNTIME_BOOTSTRAP_RETRY_MS = 100;
@@ -100,6 +106,13 @@ export const requestEmbeddedSessionRuntimeBootstrap = (): Promise<EmbeddedSessio
});
};
export const requestEmbeddedSessionVisibility = (): void => {
if (!isEmbeddedSessionChat() || typeof window === 'undefined' || !window.parent || window.parent === window) {
return;
}
window.parent.postMessage({ type: EMBEDDED_VISIBILITY_REQUEST }, window.location.origin);
};
const buildEmbeddedSessionChatURLSignature = (
sessionID: string,
directory: string | null,
@@ -111,6 +124,7 @@ export const buildEmbeddedSessionChatURL = (
directory: string | null,
readOnly: boolean,
theme: EmbeddedSessionChatThemeBootstrap,
settings?: EmbeddedSessionChatSettingsBootstrap,
): string => {
if (typeof window === 'undefined') {
return '';
@@ -134,6 +148,9 @@ export const buildEmbeddedSessionChatURL = (
url.searchParams.set('lightThemeId', theme.lightThemeId);
url.searchParams.set('darkThemeId', theme.darkThemeId);
url.searchParams.set('themeVariant', theme.currentTheme.metadata.variant === 'dark' ? 'dark' : 'light');
if (settings) {
url.searchParams.set('allowPromptingSubagentSessions', settings.allowPromptingSubagentSessions ? '1' : '0');
}
url.hash = '';
return url.toString();
@@ -146,6 +163,7 @@ export const getOrCreateEmbeddedSessionChatURL = (
directory: string | null,
readOnly: boolean,
theme: EmbeddedSessionChatThemeBootstrap,
settings?: EmbeddedSessionChatSettingsBootstrap,
): string => {
const signature = buildEmbeddedSessionChatURLSignature(sessionID, directory, readOnly);
const existing = cache.get(tabID);
@@ -153,11 +171,22 @@ export const getOrCreateEmbeddedSessionChatURL = (
return existing.src;
}
const src = buildEmbeddedSessionChatURL(sessionID, directory, readOnly, theme);
const src = buildEmbeddedSessionChatURL(sessionID, directory, readOnly, theme, settings);
cache.set(tabID, { signature, src });
return src;
};
export const getActiveEmbeddedSessionChatTab = <T extends { id: string }>(
tabs: T[],
activeTabID: string | null,
): T | null => {
if (!activeTabID) {
return null;
}
return tabs.find((tab) => tab.id === activeTabID) ?? null;
};
/**
* True when the current document is the embedded session-chat iframe
* (`?ocPanel=session-chat`). Used to distinguish the embedded iframe from