feat(ui): numbered context-panel surface switching with configurable prefix

- Add switch_context_surface shortcut (default Cmd/Ctrl + 1..9, 0 for the 10th
  surface) that opens/closes/switches context panel rail surfaces by their
  visible order, configurable and persisted in Settings -> Shortcuts.
- Show order-number badges on rail icons while the modifier is held >500ms;
  dismiss on release, blur, or a number press until the next press-and-hold.
- Remove the legacy mod+2/3/4 (diff/terminal/git) and switch_tab_1..9 bindings
  so numbered surface switching goes only through the new mechanism.
- Replace the help-dialog 'Switch Project' row with the surface-switch row and
  update the shortcuts footer/header icons to the command icon.
This commit is contained in:
Bohdan Triapitsyn
2026-08-04 20:39:45 +03:00
parent 67965ced2f
commit f3dd894209
32 changed files with 574 additions and 124 deletions
@@ -24,18 +24,23 @@ import { useDeviceInfo } from '@/lib/device';
import { isVSCodeRuntime } from '@/lib/desktop';
import { useI18n } from '@/lib/i18n';
import {
getVisibleContextRailSurfaces,
sortContextSurfaces,
type ContextSurfaceDescriptor,
} from '@/lib/surfaces/registry';
import {
getEffectiveShortcutPrefix,
isShortcutPrefixHeld,
} from '@/lib/shortcuts';
import { cn } from '@/lib/utils';
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
import { useGitStatus } from '@/stores/useGitStore';
import { normalizeContextPanelDirectoryKey, useUIStore } from '@/stores/useUIStore';
const RAIL_TOOLTIP_DELAY_MS = 150;
// Tablet width and up: below this the walkthrough cannot show a stop and its
// code side by side, which is the whole point of the surface.
const WALKTHROUGH_MIN_WIDTH = 768;
// Hold the surface-switch modifier for this long before revealing the order
// number badges on the rail icons.
const RAIL_NUMBER_HOLD_DELAY_MS = 500;
const EMPTY_TABS: never[] = [];
type RailItemProps = {
@@ -44,6 +49,8 @@ type RailItemProps = {
showActivityDot: boolean;
label: string;
description: string;
orderNumber?: number | null;
showOrderNumber?: boolean;
onSelect: (surface: ContextSurfaceDescriptor) => void;
};
@@ -53,6 +60,8 @@ const ContextPanelRailItem: React.FC<RailItemProps> = ({
showActivityDot,
label,
description,
orderNumber,
showOrderNumber,
onSelect,
}) => {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
@@ -86,12 +95,20 @@ const ContextPanelRailItem: React.FC<RailItemProps> = ({
) : (
<Icon name={surface.icon} className="h-[18px] w-[18px]" />
)}
{showActivityDot ? (
{showActivityDot && !showOrderNumber ? (
<span
aria-hidden="true"
className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-[var(--status-info)]"
/>
) : null}
{showOrderNumber && orderNumber != null ? (
<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"
>
{orderNumber === 10 ? '0' : orderNumber}
</span>
) : null}
</button>
</TooltipTrigger>
<TooltipContent side="left" sideOffset={8}>
@@ -114,10 +131,86 @@ export const ContextPanelRail: React.FC = () => {
const contextRailOrder = useUIStore((state) => state.contextRailOrder);
const setContextRailOrder = useUIStore((state) => state.setContextRailOrder);
const openContextSurface = useUIStore((state) => state.openContextSurface);
const shortcutOverrides = useUIStore((state) => state.shortcutOverrides);
const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled);
const { screenWidth } = useDeviceInfo();
const gitStatus = useGitStatus(directoryKey || null);
const surfaceSwitchPrefix = React.useMemo(
() => getEffectiveShortcutPrefix('switch_context_surface', shortcutOverrides),
[shortcutOverrides],
);
const [revealNumbers, setRevealNumbers] = React.useState(false);
// While the surface-switch modifier is held for RAIL_NUMBER_HOLD_DELAY_MS,
// reveal the order number badges so users can see which digit maps to which
// rail icon. Releasing (or losing focus) dismisses them, and pressing a
// number key while the chord is armed consumes them for this hold — they
// only come back on the next press-and-hold.
React.useEffect(() => {
const held = new Set<string>();
let timer: ReturnType<typeof setTimeout> | null = null;
let consumedWhileHeld = false;
const isDigitKey = (key: string) => key.length === 1 && key >= '0' && key <= '9';
const update = () => {
const armed = isShortcutPrefixHeld(surfaceSwitchPrefix, held);
if (armed) {
if (!consumedWhileHeld && timer === null) {
timer = setTimeout(() => setRevealNumbers(true), RAIL_NUMBER_HOLD_DELAY_MS);
}
} else {
consumedWhileHeld = false;
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
setRevealNumbers(false);
}
};
const onKeyDown = (e: KeyboardEvent) => {
held.add(e.key.toLowerCase());
if (isDigitKey(e.key) && isShortcutPrefixHeld(surfaceSwitchPrefix, held)) {
consumedWhileHeld = true;
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
setRevealNumbers(false);
return;
}
update();
};
const onKeyUp = (e: KeyboardEvent) => {
held.delete(e.key.toLowerCase());
update();
};
const onWindowBlur = () => {
held.clear();
consumedWhileHeld = false;
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
setRevealNumbers(false);
};
window.addEventListener('keydown', onKeyDown, true);
window.addEventListener('keyup', onKeyUp, true);
window.addEventListener('blur', onWindowBlur);
return () => {
window.removeEventListener('keydown', onKeyDown, true);
window.removeEventListener('keyup', onKeyUp, true);
window.removeEventListener('blur', onWindowBlur);
if (timer !== null) {
clearTimeout(timer);
}
};
}, [surfaceSwitchPrefix]);
const sensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 8 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 6 } }),
@@ -128,22 +221,13 @@ export const ContextPanelRail: React.FC = () => {
const activeMode = panelState?.isOpen ? activeTab?.mode ?? null : null;
const changedFilesCount = gitStatus?.files.length ?? 0;
// Content-driven surfaces are hidden (not disabled) until content exists;
// an existing tab keeps them visible even if the content source went away.
const surfaces = React.useMemo(() => {
return sortContextSurfaces(contextRailOrder).filter((surface) => {
if (surface.id === 'plan' && !planModeEnabled) {
return false;
}
// The walkthrough needs room for a stop list beside real code, and its
// diffs come from OpenChamber's Git routes, which VS Code does not serve.
if (surface.id === 'walkthrough' && (isVSCodeRuntime() || screenWidth < WALKTHROUGH_MIN_WIDTH)) {
return false;
}
if (surface.availability === 'has-content') {
return tabs.some((tab) => tab.mode === surface.mode);
}
return true;
return getVisibleContextRailSurfaces({
railOrder: contextRailOrder,
planModeEnabled,
isVSCode: isVSCodeRuntime(),
screenWidth,
tabs,
});
}, [contextRailOrder, planModeEnabled, screenWidth, tabs]);
@@ -174,7 +258,7 @@ export const ContextPanelRail: React.FC = () => {
>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={surfaces.map((surface) => surface.id)} strategy={verticalListSortingStrategy}>
{surfaces.map((surface) => (
{surfaces.map((surface, index) => (
<ContextPanelRailItem
key={surface.id}
surface={surface}
@@ -182,6 +266,8 @@ export const ContextPanelRail: React.FC = () => {
showActivityDot={surface.id === 'git' && changedFilesCount > 0}
label={t(surface.labelKey)}
description={t(surface.descriptionKey)}
orderNumber={index + 1}
showOrderNumber={revealNumbers}
onSelect={(selected) => openContextSurface(directoryKey, selected.mode)}
/>
))}