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)}
/>
))}
@@ -13,6 +13,7 @@ import {
formatShortcutForDisplay,
getCustomizableShortcutActions,
getEffectiveShortcutCombo,
getEffectiveShortcutPrefix,
isRiskyBrowserShortcut,
keyToShortcutToken,
normalizeCombo,
@@ -49,6 +50,35 @@ const keyboardEventToCombo = (event: React.KeyboardEvent<HTMLInputElement>): Sho
return normalizeCombo(parts.join('+'));
};
// Prefix capture for chord-style shortcuts (e.g. "switch context panel
// surface"): a bare modifier press is accepted so the prefix can be just the
// primary modifier (default) or a modifier + key chord like `mod+p`.
const keyboardEventToPrefixCombo = (event: React.KeyboardEvent<HTMLInputElement>): ShortcutCombo | null => {
const parts: string[] = [];
if (event.metaKey || event.ctrlKey) {
parts.push('mod');
}
if (event.shiftKey) {
parts.push('shift');
}
if (event.altKey) {
parts.push('alt');
}
if (MODIFIER_KEYS.has(event.key.toLowerCase())) {
return parts.length > 0 ? normalizeCombo(parts.join('+')) : null;
}
const keyToken = keyToShortcutToken(event.key);
if (!keyToken) {
return null;
}
parts.push(keyToken);
return parts.length > 0 ? normalizeCombo(parts.join('+')) : null;
};
export const KeyboardShortcutsSettings: React.FC = () => {
const { t } = useI18n();
const tUnsafe = React.useCallback((key: string) => t(key as Parameters<typeof t>[0]), [t]);
@@ -211,10 +241,19 @@ export const KeyboardShortcutsSettings: React.FC = () => {
<div>
{actions.map((action, index) => {
const effective = getEffectiveShortcutCombo(action.id, shortcutOverrides);
const isSurfaceSwitch = action.id === 'switch_context_surface';
const effective = isSurfaceSwitch
? getEffectiveShortcutPrefix(action.id, shortcutOverrides)
: getEffectiveShortcutCombo(action.id, shortcutOverrides);
const draft = draftByAction[action.id];
const displayCombo = draft ?? effective;
const hasDraft = typeof draft === 'string' && normalizeCombo(draft) !== normalizeCombo(effective);
const isUnassignedDisplay = displayCombo === '' || normalizeCombo(displayCombo) === UNASSIGNED_SHORTCUT;
const displayValue = capturingActionId === action.id
? t('settings.openchamber.keyboardShortcuts.field.pressKeys')
: isSurfaceSwitch && !isUnassignedDisplay
? `${formatShortcutForDisplay(displayCombo)}${t('settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix')}`
: formatShortcutForDisplay(displayCombo);
return (
<div key={action.id} className={cn("py-1.5", index > 0 && "border-t border-border/40")}>
@@ -224,7 +263,7 @@ export const KeyboardShortcutsSettings: React.FC = () => {
>
<Input
readOnly
value={capturingActionId === action.id ? t('settings.openchamber.keyboardShortcuts.field.pressKeys') : formatShortcutForDisplay(displayCombo)}
value={displayValue}
onFocus={() => {
setCapturingActionId(action.id);
setErrorText('');
@@ -243,7 +282,7 @@ export const KeyboardShortcutsSettings: React.FC = () => {
return;
}
const combo = keyboardEventToCombo(event);
const combo = isSurfaceSwitch ? keyboardEventToPrefixCombo(event) : keyboardEventToCombo(event);
if (!combo) {
return;
}
@@ -44,7 +44,7 @@ export function SidebarFooter({
<Tooltip>
<TooltipTrigger asChild>
<button type="button" onClick={onOpenShortcuts} className={footerButtonClassName} aria-label={t('sessions.sidebar.footer.actions.shortcuts')}>
<Icon name="question" className="h-4.5 w-4.5" />
<Icon name="command" className="h-4.5 w-4.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}><p>{t('sessions.sidebar.footer.actions.shortcuts')}</p></TooltipContent>
+6 -6
View File
@@ -175,6 +175,11 @@ export const HelpDialog: React.FC = () => {
icon: "time",
keys: '',
},
{
keys: [`${mod} + 1...0`],
descriptionKey: "helpDialog.item.switchContextSurface",
icon: "layout-right",
},
],
},
{
@@ -186,11 +191,6 @@ export const HelpDialog: React.FC = () => {
icon: "palette",
keys: '',
},
{
keys: [`${mod} + 1...9`],
descriptionKey: "helpDialog.item.switchProject",
icon: "layout-left",
},
{
id: 'toggle_services_menu',
descriptionKey: 'helpDialog.item.toggleServicesMenu',
@@ -218,7 +218,7 @@ export const HelpDialog: React.FC = () => {
<DialogContent className="max-w-2xl w-[min(42rem,calc(100vw-1.5rem))] max-h-[calc(100dvh-2rem)] flex flex-col overflow-hidden">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Icon name="settings-3" className="h-5 w-5" />
<Icon name="command" className="h-5 w-5" />
{t('helpDialog.title')}
</DialogTitle>
<DialogDescription>