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:
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
@@ -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
|
||||
|
||||
+212
@@ -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();
|
||||
});
|
||||
});
|
||||
+50
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user