Merge upstream main into feat/subagent-cost-rollup
This commit is contained in:
@@ -452,10 +452,13 @@ export const ContextPanel: React.FC = () => {
|
||||
|
||||
// Lets an agent's browser.open create the tab it needs when none is open yet.
|
||||
// Registered from the panel because opening a tab is panel state, not
|
||||
// something the browser view itself can do before it exists.
|
||||
// something the browser view itself can do before it exists. Background on
|
||||
// purpose: an agent working a page must not pop the panel open (or steal
|
||||
// the active surface) under the user — the tab mounts invisibly, and the
|
||||
// rail is where the user opens it when curious.
|
||||
React.useEffect(() => {
|
||||
if (!effectiveDirectory) return;
|
||||
return registerBrowserOpener((url) => openContextBrowser(effectiveDirectory, url));
|
||||
return registerBrowserOpener((url) => openContextBrowser(effectiveDirectory, url, { reveal: false }));
|
||||
}, [effectiveDirectory, openContextBrowser]);
|
||||
const reorderContextPanelTabs = useUIStore((state) => state.reorderContextPanelTabs);
|
||||
const setSelectedFilePath = useFilesViewTabsStore((state) => state.setSelectedPath);
|
||||
|
||||
@@ -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
|
||||
@@ -161,6 +162,7 @@ export const ContextPanelRail: React.FC = () => {
|
||||
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);
|
||||
@@ -256,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;
|
||||
@@ -331,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>
|
||||
);
|
||||
};
|
||||
@@ -38,7 +38,8 @@ import { WindowsWindowControls } from '@/components/desktop/WindowsWindowControl
|
||||
import { UpdateDialog } from '@/components/ui/UpdateDialog';
|
||||
import { useDeviceInfo, useTabletStandalonePwaRuntime } from '@/lib/device';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { eventMatchesShortcut, formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts';
|
||||
import { formatShortcutForDisplay, getEffectiveShortcutCombo, type ShortcutActionId } from '@/lib/shortcuts';
|
||||
import { useKeybinds } from '@/hooks/useKeybind';
|
||||
import {
|
||||
} from '@/lib/quota/model-families';
|
||||
|
||||
@@ -256,7 +257,7 @@ type DesktopServicesMenuProps = {
|
||||
isDesktopServicesOpen: boolean;
|
||||
setIsDesktopServicesOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
refreshCurrentInstanceLabel: () => Promise<void>;
|
||||
shortcutLabel: (actionId: string) => string;
|
||||
shortcutLabel: (actionId: ShortcutActionId) => string;
|
||||
remoteUpdateInfo: UpdateInfo | null;
|
||||
remoteUpdateChecking: boolean;
|
||||
remoteUpdateError: string | null;
|
||||
@@ -433,7 +434,6 @@ export const Header: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const isSidebarOpen = useUIStore((state) => state.isSidebarOpen);
|
||||
const openContextOverview = useUIStore((state) => state.openContextOverview);
|
||||
const openContextPlan = useUIStore((state) => state.openContextPlan);
|
||||
const closeContextPanel = useUIStore((state) => state.closeContextPanel);
|
||||
const shortcutOverrides = useUIStore((state) => state.shortcutOverrides);
|
||||
const sessionTabsEnabled = useUIStore((state) => state.sessionTabsEnabled);
|
||||
@@ -485,8 +485,6 @@ export const Header: React.FC = () => {
|
||||
const pathSegments = activeProject.path.split(/[\\/]/).filter(Boolean);
|
||||
return pathSegments[pathSegments.length - 1] ?? null;
|
||||
}, [activeProject]);
|
||||
const quotaResults = useQuotaStore((state) => state.results);
|
||||
const fetchAllQuotas = useQuotaStore((state) => state.fetchAllQuotas);
|
||||
const loadQuotaSettings = useQuotaStore((state) => state.loadSettings);
|
||||
|
||||
const { isMobile } = useDeviceInfo();
|
||||
@@ -1264,21 +1262,6 @@ export const Header: React.FC = () => {
|
||||
const isContextPanelActive = activeContextMode === 'context';
|
||||
|
||||
|
||||
const handleOpenContextPlan = React.useCallback(() => {
|
||||
const directory = normalize(openDirectory || '');
|
||||
if (!directory) {
|
||||
return;
|
||||
}
|
||||
|
||||
const panelState = useUIStore.getState().contextPanelByDirectory[directory];
|
||||
if (getActiveContextMode(panelState) === 'plan') {
|
||||
closeContextPanel(directory);
|
||||
return;
|
||||
}
|
||||
|
||||
openContextPlan(directory);
|
||||
}, [closeContextPanel, openContextPlan, openDirectory]);
|
||||
|
||||
|
||||
const desktopHeaderIconButtonClass = DESKTOP_HEADER_ICON_BUTTON_CLASS;
|
||||
// Left padding the header needs to clear the OS window controls (macOS
|
||||
@@ -1445,67 +1428,26 @@ export const Header: React.FC = () => {
|
||||
}
|
||||
}, [isDesktopApp]);
|
||||
|
||||
const shortcutLabel = React.useCallback((actionId: string) => {
|
||||
const shortcutLabel = React.useCallback((actionId: ShortcutActionId) => {
|
||||
return formatShortcutForDisplay(getEffectiveShortcutCombo(actionId, shortcutOverrides));
|
||||
}, [shortcutOverrides]);
|
||||
|
||||
// Desktop keeps instances only: quota and MCP now live in the work-status
|
||||
// panel, which reports them per session rather than per window. The mobile
|
||||
// menu below is untouched — it has no panel to defer to.
|
||||
const servicesTabs = React.useMemo(() => {
|
||||
const base: Array<{ value: 'instance' | 'usage' | 'mcp'; label: string; icon: React.ReactNode }> = [];
|
||||
if (isDesktopApp) {
|
||||
base.push({ value: 'instance', label: t('layout.services.instance'), icon: <Icon name="server" className="h-3.5 w-3.5" /> });
|
||||
}
|
||||
return base;
|
||||
}, [isDesktopApp, t]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const toggleServicesCombo = getEffectiveShortcutCombo('toggle_services_menu', shortcutOverrides);
|
||||
if (eventMatchesShortcut(e, toggleServicesCombo)) {
|
||||
e.preventDefault();
|
||||
|
||||
if (isDesktopServicesOpen) {
|
||||
setIsDesktopServicesOpen(false);
|
||||
} else {
|
||||
setIsDesktopServicesOpen(true);
|
||||
void refreshCurrentInstanceLabel();
|
||||
}
|
||||
useKeybinds({
|
||||
rename_current_session: () => {
|
||||
if (!currentSessionId || isMobile) return false;
|
||||
beginHeaderSessionRename();
|
||||
},
|
||||
toggle_services_menu: () => {
|
||||
if (isDesktopServicesOpen) {
|
||||
setIsDesktopServicesOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// The desktop menu holds one destination now, so this shortcut opens it
|
||||
// rather than cycling. The binding is kept: it is user-configurable and
|
||||
// silently dropping it would break existing setups.
|
||||
const cycleServicesCombo = getEffectiveShortcutCombo('cycle_services_tab', shortcutOverrides);
|
||||
if (eventMatchesShortcut(e, cycleServicesCombo)) {
|
||||
e.preventDefault();
|
||||
if (servicesTabs.length === 0) return;
|
||||
setIsDesktopServicesOpen(true);
|
||||
void refreshCurrentInstanceLabel();
|
||||
return;
|
||||
}
|
||||
|
||||
const toggleContextPlanCombo = getEffectiveShortcutCombo('toggle_context_plan', shortcutOverrides);
|
||||
if (eventMatchesShortcut(e, toggleContextPlanCombo)) {
|
||||
e.preventDefault();
|
||||
handleOpenContextPlan();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [
|
||||
shortcutOverrides,
|
||||
isDesktopServicesOpen,
|
||||
servicesTabs,
|
||||
quotaResults.length,
|
||||
fetchAllQuotas,
|
||||
refreshCurrentInstanceLabel,
|
||||
handleOpenContextPlan,
|
||||
]);
|
||||
setIsDesktopServicesOpen(true);
|
||||
void refreshCurrentInstanceLabel();
|
||||
},
|
||||
});
|
||||
|
||||
const desktopSidebarActions = (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user