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>
+68 -11
View File
@@ -10,10 +10,19 @@ import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
import { useConfigStore } from '@/stores/useConfigStore';
import { canUseElectronDesktopIPC, invokeDesktop, isVSCodeRuntime } from '@/lib/desktop';
import { showOpenCodeStatus } from '@/lib/openCodeStatus';
import { eventMatchesShortcut, getEffectiveShortcutCombo, normalizeCombo } from '@/lib/shortcuts';
import {
eventMatchesShortcut,
eventMatchesShortcutPrefix,
getEffectiveShortcutCombo,
getEffectiveShortcutPrefix,
normalizeCombo,
} from '@/lib/shortcuts';
import { getVisibleContextRailSurfaces } from '@/lib/surfaces/registry';
import { readEmbeddedThemeSearchParams } from '@/contexts/theme-embedded-bootstrap';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { getCycledPrimaryAgentName } from '@/components/chat/mobileControlsUtils';
import { focusChatInput } from '@/components/chat/composer/editor/dom';
import { addSelectionToChat } from '@/lib/addSelectionToChat';
@@ -29,6 +38,7 @@ export const useKeyboardShortcuts = () => {
const toggleHelpDialog = useUIStore((s) => s.toggleHelpDialog);
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
const currentShortcutDirectory = useDirectoryStore((s) => s.currentDirectory);
const effectiveDirectory = useEffectiveDirectory();
// The terminal lives in the context panel; these mirror the rail behavior.
const toggleTerminalSurface = React.useCallback(() => {
@@ -64,6 +74,9 @@ export const useKeyboardShortcuts = () => {
const abortPrimedUntilRef = React.useRef<number | null>(null);
const abortPrimedTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const themeModeRef = React.useRef(themeMode);
// Currently held physical keys (lowercased), used to match chord prefixes
// whose primary key must be held while the activating key is pressed.
const heldKeysRef = React.useRef<Set<string>>(new Set());
React.useEffect(() => {
themeModeRef.current = themeMode;
@@ -80,6 +93,7 @@ export const useKeyboardShortcuts = () => {
React.useEffect(() => {
const combo = (actionId: string) => getEffectiveShortcutCombo(actionId, shortcutOverrides);
const switchSurfacePrefix = getEffectiveShortcutPrefix('switch_context_surface', shortcutOverrides);
const dropdownTargetSelector = [
'[data-slot="dropdown-menu-content"]',
'[data-slot="select-content"]',
@@ -448,16 +462,6 @@ export const useKeyboardShortcuts = () => {
return;
}
if (eventMatchesShortcut(e, combo('open_diff_panel'))) {
const state = useUIStore.getState();
if (state.isMobile || !currentDirectory) {
return;
}
e.preventDefault();
state.openContextSurface(normalizeContextPanelDirectoryKey(currentDirectory), 'diff');
return;
}
if (eventMatchesShortcut(e, combo('toggle_terminal'))) {
const { isMobile } = useUIStore.getState();
if (isMobile) {
@@ -478,6 +482,39 @@ export const useKeyboardShortcuts = () => {
return;
}
// Configured prefix + digit (default: Cmd/Ctrl + 1..9, with 0 for the
// 10th surface): open/close the matching context panel rail surface. The
// digit maps to the currently visible rail order, matching the number
// badges shown while holding the modifier. `e.repeat` guard keeps
// holding a digit from toggling.
const switchSurfaceDigit = e.key.length === 1 && e.key >= '0' && e.key <= '9'
? (e.key === '0' ? 10 : Number(e.key))
: null;
if (switchSurfaceDigit !== null
&& !e.repeat
&& eventMatchesShortcutPrefix(e, switchSurfacePrefix, heldKeysRef.current)) {
const state = useUIStore.getState();
if (state.isMobile || !effectiveDirectory) {
return;
}
const directory = normalizeContextPanelDirectoryKey(effectiveDirectory);
const panelState = state.contextPanelByDirectory[directory];
const visibleSurfaces = getVisibleContextRailSurfaces({
railOrder: state.contextRailOrder,
planModeEnabled: useFeatureFlagsStore.getState().planModeEnabled,
isVSCode: isVSCodeRuntime(),
screenWidth: window.innerWidth,
tabs: panelState?.tabs ?? [],
});
const target = visibleSurfaces[switchSurfaceDigit - 1];
if (!target) {
return;
}
e.preventDefault();
state.openContextSurface(directory, target.mode);
return;
}
// Cmd/Ctrl+Shift+M: Open model selector (same conditions as double-ESC: chat tab, no overlays)
if (eventMatchesShortcut(e, combo('open_model_selector'))) {
const {
@@ -618,11 +655,30 @@ export const useKeyboardShortcuts = () => {
};
// Track held physical keys so chord prefixes (e.g. a configured
// `mod+p`) can require their primary key to stay held. Capture phase runs
// before handleKeyDown, so the set is current when chord matching runs.
const handleKeyHoldDown = (e: KeyboardEvent) => {
heldKeysRef.current.add(e.key.toLowerCase());
};
const handleKeyUp = (e: KeyboardEvent) => {
heldKeysRef.current.delete(e.key.toLowerCase());
};
const handleWindowBlur = () => {
heldKeysRef.current.clear();
};
window.addEventListener('keydown', handleKeyHoldDown, true);
window.addEventListener('keyup', handleKeyUp, true);
window.addEventListener('blur', handleWindowBlur);
window.addEventListener('keydown', handleTerminalShortcutCapture, true);
window.addEventListener('keydown', handleEscapeKeyDownCapture, true);
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyHoldDown, true);
window.removeEventListener('keyup', handleKeyUp, true);
window.removeEventListener('blur', handleWindowBlur);
window.removeEventListener('keydown', handleTerminalShortcutCapture, true);
window.removeEventListener('keydown', handleEscapeKeyDownCapture, true);
window.removeEventListener('keydown', handleKeyDown);
@@ -650,6 +706,7 @@ export const useKeyboardShortcuts = () => {
resetAbortPriming,
currentSessionId,
currentDirectory,
effectiveDirectory,
activeProject?.id,
activeProject?.path,
shortcutOverrides,
@@ -1043,6 +1043,8 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'Rechte Seitenleiste umschalten',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Git-Tab der rechten Seitenleiste öffnen',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': 'Datei-Tab der rechten Seitenleiste öffnen',
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': 'Kontextpanel-Oberfläche wechseln',
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0',
'settings.openchamber.keyboardShortcuts.action.new_chat.label': 'Neue Sitzung',
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': 'Neuer Worktree-Entwurf',
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': 'Neues Mini-Chat-Fenster',
+1 -1
View File
@@ -1558,7 +1558,7 @@ export const dict = {
'helpDialog.item.toggleTerminalExpanded': 'Terminal erweitert umschalten',
'helpDialog.item.togglePlanContextPanel': 'Plan-Kontext-Panel umschalten',
'helpDialog.item.cycleTheme': 'Thema wechseln (Hell → Dunkel → System)',
'helpDialog.item.switchProject': 'Projekt wechseln',
'helpDialog.item.switchContextSurface': 'Kontextpanel-Oberfläche wechseln (Zahlentaste)',
'helpDialog.item.toggleServicesMenu': 'Dienstemenü umschalten',
'helpDialog.item.cycleServicesTab': 'Dienste-Registerkarte durchgehen',
'helpDialog.item.openSettings': 'Einstellungen öffnen',
@@ -1108,6 +1108,8 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'Toggle context panel',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Open Git surface',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': 'Open Files surface',
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': 'Switch context panel surface',
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0',
'settings.openchamber.keyboardShortcuts.action.new_chat.label': 'New session',
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': 'New worktree draft',
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': 'New Mini Chat window',
+1 -1
View File
@@ -1710,8 +1710,8 @@ export const dict = {
'helpDialog.item.toggleTerminalDock': 'Toggle Terminal Dock',
'helpDialog.item.toggleTerminalExpanded': 'Toggle Terminal Expanded',
'helpDialog.item.togglePlanContextPanel': 'Toggle Plan Context Panel',
'helpDialog.item.switchContextSurface': 'Switch Context Panel Surface (number key)',
'helpDialog.item.cycleTheme': 'Cycle Theme (Light → Dark → System)',
'helpDialog.item.switchProject': 'Switch Project',
'helpDialog.item.toggleServicesMenu': 'Toggle Services Menu',
'helpDialog.item.cycleServicesTab': 'Cycle Services Tab',
'helpDialog.item.openSettings': 'Open Settings',
@@ -1075,6 +1075,8 @@ export const settingsDict = {
"settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label": 'Alternar panel de contexto',
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label": 'Abrir superficie de Git',
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label": 'Abrir superficie de archivos',
"settings.openchamber.keyboardShortcuts.action.switch_context_surface.label": "Cambiar superficie del panel de contexto",
"settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix": " + 1…0",
"settings.openchamber.keyboardShortcuts.action.new_chat.label": "Nueva sesión",
"settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label": "Nuevo borrador de worktree",
"settings.openchamber.keyboardShortcuts.action.new_mini_chat.label": "Nueva ventana Mini Chat",
+1 -1
View File
@@ -1689,7 +1689,7 @@ export const dict: Record<I18nKey, string> = {
"helpDialog.item.toggleTerminalExpanded": "Expandir o contraer terminal",
"helpDialog.item.togglePlanContextPanel": "Alternar panel de contexto del plan",
"helpDialog.item.cycleTheme": "Cambiar tema (Claro → Oscuro → Sistema)",
"helpDialog.item.switchProject": "Cambiar proyecto",
"helpDialog.item.switchContextSurface": "Cambiar superficie del panel de contexto (tecla numérica)",
"helpDialog.item.toggleServicesMenu": "Mostrar u ocultar menú de servicios",
"helpDialog.item.cycleServicesTab": "Cambiar pestaña de servicios",
"helpDialog.item.openSettings": "Abrir configuración",
@@ -996,6 +996,8 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'Afficher/masquer le panneau de contexte',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Ouvrir la surface Git',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': 'Ouvrir la surface Fichiers',
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': 'Basculer la surface du panneau contextuel',
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0',
'settings.openchamber.keyboardShortcuts.action.new_chat.label': 'Nouvelle session',
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': 'Nouvelle ébauche d\'worktree',
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': 'Nouvelle fenêtre de mini-chat',
+1 -1
View File
@@ -1524,7 +1524,7 @@ export const dict = {
'helpDialog.item.toggleTerminalExpanded': 'Terminal à bascule étendu',
'helpDialog.item.togglePlanContextPanel': 'Toggle Panneau contextuel du plan',
'helpDialog.item.cycleTheme': 'Basculer le thème (clair → sombre → système)',
'helpDialog.item.switchProject': 'Changer de projet',
'helpDialog.item.switchContextSurface': 'Basculer la surface du panneau contextuel (touche numérique)',
'helpDialog.item.toggleServicesMenu': 'Basculer le menu des services',
'helpDialog.item.cycleServicesTab': 'Onglet Services de vélo',
'helpDialog.item.openSettings': 'Ouvrir les paramètres',
@@ -1108,6 +1108,8 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'コンテキストパネルの表示切替',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Git サーフェスを開く',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': 'ファイルサーフェスを開く',
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': 'コンテキストパネルのサーフェスを切り替え',
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0',
'settings.openchamber.keyboardShortcuts.action.new_chat.label': '新しい Session',
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': '新しい Worktree 下書き',
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': '新しいミニチャットウィンドウ',
+1 -1
View File
@@ -1707,7 +1707,7 @@ export const dict: Record<I18nKey, string> = {
'helpDialog.item.toggleTerminalExpanded': 'ターミナル展開の切り替え',
'helpDialog.item.togglePlanContextPanel': '計画コンテキストパネルの切り替え',
'helpDialog.item.cycleTheme': 'テーマ切り替え(ライト→ダーク→システム)',
'helpDialog.item.switchProject': 'プロジェクトを切り替え',
'helpDialog.item.switchContextSurface': 'コンテキストパネルのサーフェスを切り替え(数字キー)',
'helpDialog.item.toggleServicesMenu': 'サービスの切り替え',
'helpDialog.item.cycleServicesTab': 'サービス変数の切り替え',
'helpDialog.item.openSettings': '設定を開く',
@@ -1075,6 +1075,8 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': '컨텍스트 패널 표시 전환',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Git 서피스 열기',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': '파일 서피스 열기',
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': '컨텍스트 패널 서피스 전환',
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0',
'settings.openchamber.keyboardShortcuts.action.new_chat.label': '새 세션',
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': '새 worktree 초안',
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': '새 Mini Chat 창',
+1 -1
View File
@@ -1713,7 +1713,7 @@ export const dict: Record<I18nKey, string> = {
'helpDialog.item.toggleTerminalExpanded': '터미널 펼치기/접기',
'helpDialog.item.togglePlanContextPanel': '플랜 컨텍스트 패널 전환',
'helpDialog.item.cycleTheme': '테마 순환(라이트 → 다크 → 시스템)',
'helpDialog.item.switchProject': '프로젝트 전환',
'helpDialog.item.switchContextSurface': '컨텍스트 패널 서피스 전환(숫자 키)',
'helpDialog.item.toggleServicesMenu': '서비스 메뉴 전환',
'helpDialog.item.cycleServicesTab': '서비스 탭 순환',
'helpDialog.item.openSettings': '설정 열기',
@@ -820,6 +820,8 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.action.open_go_to_line.label': 'Przejdź do linii (edytor plików)',
'settings.openchamber.keyboardShortcuts.action.open_help.label': 'Otwórz skróty klawiszowe',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': 'Otwórz powierzchnię plików',
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': 'Przełącz powierzchnię panelu kontekstu',
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Otwórz powierzchnię Git',
'settings.openchamber.keyboardShortcuts.action.open_settings.label': 'Otwórz ustawienia',
'settings.openchamber.keyboardShortcuts.action.toggle_context_plan.label': 'Przełącz panel kontekstu planu',
+1 -1
View File
@@ -2333,7 +2333,7 @@ export const dict: Record<I18nKey, string> = {
'helpDialog.item.openRightSidebarGitTab': 'Otwórz powierzchnię Git',
'helpDialog.item.openSettings': 'Otwórz ustawienia',
'helpDialog.item.showKeyboardShortcuts': 'Pokaż skróty klawiaturowe (to okno)',
'helpDialog.item.switchProject': 'Przełącz projekt',
'helpDialog.item.switchContextSurface': 'Przełącz powierzchnię panelu kontekstu (klawisz liczbowy)',
'helpDialog.item.togglePlanContextPanel': 'Przełącz panel kontekstu planu',
'helpDialog.item.toggleRightSidebar': 'Przełącz panel kontekstu',
'helpDialog.item.toggleServicesMenu': 'Przełącz menu usług',
@@ -1075,6 +1075,8 @@ export const settingsDict = {
"settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label": 'Alternar painel de contexto',
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label": 'Abrir superfície do Git',
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label": 'Abrir superfície de arquivos',
"settings.openchamber.keyboardShortcuts.action.switch_context_surface.label": "Alternar superfície do painel de contexto",
"settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix": " + 1…0",
"settings.openchamber.keyboardShortcuts.action.new_chat.label": "Nova sessão",
"settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label": "Novo rascunho de worktree",
"settings.openchamber.keyboardShortcuts.action.new_mini_chat.label": "Nova janela Mini Chat",
+1 -1
View File
@@ -1689,7 +1689,7 @@ export const dict: Record<I18nKey, string> = {
"helpDialog.item.toggleTerminalExpanded": "Expandir ou recolher o terminal",
"helpDialog.item.togglePlanContextPanel": "Alternar painel de contexto do plano",
"helpDialog.item.cycleTheme": "Alternar tema (Claro → Escuro → Sistema)",
"helpDialog.item.switchProject": "Alternar projeto",
"helpDialog.item.switchContextSurface": "Alternar superfície do painel de contexto (tecla numérica)",
"helpDialog.item.toggleServicesMenu": "Mostrar ou ocultar menu de serviços",
"helpDialog.item.cycleServicesTab": "Alternar aba de serviços",
"helpDialog.item.openSettings": "Abrir configurações",
@@ -1075,6 +1075,8 @@ export const settingsDict = {
"settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label": 'Перемкнути контекстну панель',
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label": 'Відкрити поверхню Git',
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label": 'Відкрити поверхню файлів',
"settings.openchamber.keyboardShortcuts.action.switch_context_surface.label": "Перемкнути поверхню панелі контексту",
"settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix": " + 1…0",
"settings.openchamber.keyboardShortcuts.action.new_chat.label": "Нова сесія",
"settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label": "Нова чернетка worktree",
"settings.openchamber.keyboardShortcuts.action.new_mini_chat.label": "Нове вікно Mini Chat",
+1 -1
View File
@@ -1689,7 +1689,7 @@ export const dict: Record<I18nKey, string> = {
"helpDialog.item.toggleTerminalExpanded": "Розгорнути або згорнути термінал",
"helpDialog.item.togglePlanContextPanel": "Перемкнути панель контексту плану",
"helpDialog.item.cycleTheme": "Перемкнути тему (Світла → Темна → Системна)",
"helpDialog.item.switchProject": "Перемкнути проєкт",
"helpDialog.item.switchContextSurface": "Перемкнути поверхню панелі контексту (цифрова клавіша)",
"helpDialog.item.toggleServicesMenu": "Перемкнути меню сервісів",
"helpDialog.item.cycleServicesTab": "Перемкнути вкладку сервісів",
"helpDialog.item.openSettings": "Відкрити налаштування",
@@ -1075,6 +1075,8 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': '切换上下文面板',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': '打开 Git 界面',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': '打开文件界面',
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': '切换上下文面板界面',
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0',
'settings.openchamber.keyboardShortcuts.action.new_chat.label': '新建会话',
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': '新建工作树草稿',
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': '新建 Mini Chat 窗口',
+1 -1
View File
@@ -1677,7 +1677,7 @@ export const dict: Record<I18nKey, string> = {
'helpDialog.item.toggleTerminalExpanded': '切换终端展开状态',
'helpDialog.item.togglePlanContextPanel': '切换计划上下文面板',
'helpDialog.item.cycleTheme': '循环切换主题(浅色 → 深色 → 跟随系统)',
'helpDialog.item.switchProject': '切换项目',
'helpDialog.item.switchContextSurface': '切换上下文面板界面(数字键)',
'helpDialog.item.toggleServicesMenu': '切换服务菜单',
'helpDialog.item.cycleServicesTab': '循环服务标签',
'helpDialog.item.openSettings': '打开设置',
@@ -982,6 +982,8 @@
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': '切換上下文面板',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': '開啟 Git 介面',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': '開啟檔案介面',
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': '切換上下文面板介面',
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0',
'settings.openchamber.keyboardShortcuts.action.new_chat.label': '新建工作階段',
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': '新增 worktree 草稿',
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': '新建 Mini Chat 視窗',
+1 -1
View File
@@ -1681,7 +1681,7 @@ export const dict: Record<I18nKey, string> = {
'helpDialog.item.toggleTerminalExpanded': '切換終端機展開狀態',
'helpDialog.item.togglePlanContextPanel': '切換計畫上下文面板',
'helpDialog.item.cycleTheme': '循環切換主題(淺色 → 深色 → 跟隨系統)',
'helpDialog.item.switchProject': '切換專案',
'helpDialog.item.switchContextSurface': '切換上下文面板介面(數字鍵)',
'helpDialog.item.toggleServicesMenu': '切換服務選單',
'helpDialog.item.cycleServicesTab': '循環服務標籤',
'helpDialog.item.openSettings': '開啟設定',
+80
View File
@@ -0,0 +1,80 @@
import { describe, expect, test } from 'bun:test';
import {
eventMatchesShortcutPrefix,
getEffectiveShortcutPrefix,
isShortcutPrefixHeld,
UNASSIGNED_SHORTCUT,
} from './shortcuts';
describe('getEffectiveShortcutPrefix', () => {
test('falls back to the action default (bare mod) when unset', () => {
expect(getEffectiveShortcutPrefix('switch_context_surface', {})).toBe('mod');
});
test('honors modifier + key overrides', () => {
expect(getEffectiveShortcutPrefix('switch_context_surface', { switch_context_surface: 'mod+p' })).toBe('mod+p');
});
test('honors modifier-only overrides', () => {
expect(getEffectiveShortcutPrefix('switch_context_surface', { switch_context_surface: 'shift' })).toBe('shift');
});
test('returns UNASSIGNED for an explicit unassignment', () => {
expect(
getEffectiveShortcutPrefix('switch_context_surface', { switch_context_surface: UNASSIGNED_SHORTCUT }),
).toBe(UNASSIGNED_SHORTCUT);
});
test('returns empty string for an unknown action', () => {
expect(getEffectiveShortcutPrefix('does_not_exist', {})).toBe('');
});
});
describe('isShortcutPrefixHeld', () => {
test('false for an unassigned prefix', () => {
expect(isShortcutPrefixHeld(UNASSIGNED_SHORTCUT, new Set(['control']))).toBe(false);
});
test('requires the prefix primary key to be held', () => {
expect(isShortcutPrefixHeld('mod+p', new Set(['control']))).toBe(false);
expect(isShortcutPrefixHeld('mod+p', new Set(['control', 'p']))).toBe(true);
});
test('requires every prefix modifier to be held', () => {
expect(isShortcutPrefixHeld('mod+shift', new Set(['control']))).toBe(false);
expect(isShortcutPrefixHeld('mod+shift', new Set(['control', 'shift']))).toBe(true);
});
});
const keydown = (key: string, mods: { meta?: boolean; ctrl?: boolean; shift?: boolean; alt?: boolean }): KeyboardEvent =>
({
key,
metaKey: mods.meta ?? false,
ctrlKey: mods.ctrl ?? false,
shiftKey: mods.shift ?? false,
altKey: mods.alt ?? false,
}) as KeyboardEvent;
describe('eventMatchesShortcutPrefix', () => {
test('matches a bare mod prefix when the primary modifier is held', () => {
expect(eventMatchesShortcutPrefix(keydown('1', { ctrl: true }), 'mod')).toBe(true);
});
test('rejects a bare mod prefix without the primary modifier', () => {
expect(eventMatchesShortcutPrefix(keydown('1', {}), 'mod')).toBe(false);
});
test('rejects when the event carries modifiers the prefix does not expect', () => {
expect(eventMatchesShortcutPrefix(keydown('1', { ctrl: true, shift: true }), 'mod')).toBe(false);
});
test('requires the prefix primary key to be held at match time', () => {
expect(eventMatchesShortcutPrefix(keydown('1', { ctrl: true }), 'mod+p', new Set(['control']))).toBe(false);
expect(eventMatchesShortcutPrefix(keydown('1', { ctrl: true }), 'mod+p', new Set(['control', 'p']))).toBe(true);
});
test('false for an unassigned prefix', () => {
expect(eventMatchesShortcutPrefix(keydown('1', { ctrl: true }), UNASSIGNED_SHORTCUT)).toBe(false);
});
});
+141 -72
View File
@@ -39,6 +39,17 @@ const DISPLAY_LABEL_MAP: Record<ShortcutModifier, string> = {
'ctrl': '⌃',
};
// Physical `event.key` values (lowercased) that satisfy each modifier while a
// chord is being held. `mod` maps to the platform primary key; on web macOS it
// accepts either Meta or Ctrl, matching eventMatchesShortcut.
const MODIFIER_KEY_ALIASES: Record<ShortcutModifier, readonly string[]> = {
'mod': isMacOS() && isDesktopShell() ? ['meta'] : isMacOS() ? ['meta', 'control'] : ['control'],
'shift': ['shift'],
'alt': ['alt'],
'option': ['alt'],
'ctrl': ['control'],
};
const KEY_LABEL_MAP: Record<string, string> = {
'comma': ',',
'period': '.',
@@ -207,6 +218,13 @@ const SHORTCUT_ACTIONS: ReadonlyArray<ShortcutAction> = [
description: 'Open right sidebar and select Files',
customizable: true,
},
{
id: 'switch_context_surface',
defaultCombo: 'mod',
label: 'Switch context panel surface',
description: 'Hold the modifier and press a number to open or close the matching rail icon',
customizable: true,
},
{
id: 'new_chat',
defaultCombo: 'mod+n',
@@ -240,24 +258,6 @@ const SHORTCUT_ACTIONS: ReadonlyArray<ShortcutAction> = [
label: 'Clear input',
description: 'Clear the input field',
},
{
id: 'open_diff_panel',
defaultCombo: 'mod+2',
label: 'Open diff panel',
description: 'Switch to the diff panel',
},
{
id: 'open_terminal_panel',
defaultCombo: 'mod+3',
label: 'Open terminal panel',
description: 'Switch to the terminal panel',
},
{
id: 'open_git_panel',
defaultCombo: 'mod+4',
label: 'Open git panel',
description: 'Switch to the git panel',
},
{
id: 'open_help',
defaultCombo: 'mod+.',
@@ -347,60 +347,6 @@ const SHORTCUT_ACTIONS: ReadonlyArray<ShortcutAction> = [
label: 'Abort active run',
description: 'Abort the currently running task (double press)',
},
{
id: 'switch_tab_1',
defaultCombo: 'mod+1',
label: 'Switch to tab 1',
description: 'Switch to the first tab or project',
},
{
id: 'switch_tab_2',
defaultCombo: 'mod+2',
label: 'Switch to tab 2',
description: 'Switch to the second tab or project',
},
{
id: 'switch_tab_3',
defaultCombo: 'mod+3',
label: 'Switch to tab 3',
description: 'Switch to the third tab or project',
},
{
id: 'switch_tab_4',
defaultCombo: 'mod+4',
label: 'Switch to tab 4',
description: 'Switch to the fourth tab or project',
},
{
id: 'switch_tab_5',
defaultCombo: 'mod+5',
label: 'Switch to tab 5',
description: 'Switch to the fifth tab or project',
},
{
id: 'switch_tab_6',
defaultCombo: 'mod+6',
label: 'Switch to tab 6',
description: 'Switch to the sixth tab or project',
},
{
id: 'switch_tab_7',
defaultCombo: 'mod+7',
label: 'Switch to tab 7',
description: 'Switch to the seventh tab or project',
},
{
id: 'switch_tab_8',
defaultCombo: 'mod+8',
label: 'Switch to tab 8',
description: 'Switch to the eighth tab or project',
},
{
id: 'switch_tab_9',
defaultCombo: 'mod+9',
label: 'Switch to tab 9',
description: 'Switch to the ninth tab or project',
},
] as const;
export function normalizeCombo(combo: ShortcutCombo): ShortcutCombo {
@@ -610,3 +556,126 @@ export function eventMatchesShortcut(
export function getModifierLabel(): string {
return isMacOS() && isDesktopShell() ? '⌘' : 'Ctrl';
}
/**
* Resolves the configurable prefix for chord-style shortcuts such as
* "switch context panel surface", where a trailing digit key completes the
* combo. Unlike getEffectiveShortcutCombo, modifier-only overrides (e.g. the
* bare `mod` primary key) are honored so the prefix can omit a primary key.
* Returns UNASSIGNED_SHORTCUT when the user explicitly unassigned the prefix.
*/
export function getEffectiveShortcutPrefix(
actionId: string,
overrides?: Record<string, ShortcutCombo>,
): ShortcutCombo {
const action = getShortcutAction(actionId);
if (!action) {
return '';
}
const override = overrides?.[actionId];
if (typeof override === 'string' && override.trim() !== '') {
const normalized = normalizeCombo(override);
if (normalized === UNASSIGNED_SHORTCUT) {
return UNASSIGNED_SHORTCUT;
}
if (normalized) {
const parsed = parseShortcut(normalized);
if (parsed.modifiers.size > 0 || parsed.key) {
return normalized;
}
}
}
return action.defaultCombo;
}
/**
* True when the physical keys required to "arm" a prefix combo are currently
* held. For modifiers with multiple aliases (e.g. `mod` on web macOS), at
* least one alias must be held.
*/
export function isShortcutPrefixHeld(prefixCombo: ShortcutCombo, heldKeys: ReadonlySet<string>): boolean {
if (isUnassignedShortcut(prefixCombo)) {
return false;
}
const parsed = parseShortcut(prefixCombo);
for (const modifier of parsed.modifiers) {
const aliases = MODIFIER_KEY_ALIASES[modifier];
if (!aliases.some((alias) => heldKeys.has(alias))) {
return false;
}
}
if (parsed.key && !heldKeys.has(parsed.key.toLowerCase())) {
return false;
}
return true;
}
/**
* Matches an activating keydown (the caller checks the event's own key, e.g. a
* digit) against a chord prefix: the event's modifier state must match the
* prefix's modifiers, and when the prefix has a primary key that key must
* currently be held.
*/
export function eventMatchesShortcutPrefix(
event: KeyboardEvent | React.KeyboardEvent,
prefixCombo: ShortcutCombo,
heldKeys?: ReadonlySet<string>,
): boolean {
if (isUnassignedShortcut(prefixCombo)) {
return false;
}
const parsed = parseShortcut(prefixCombo);
const expectedMod = parsed.modifiers.has('mod');
const expectedShift = parsed.modifiers.has('shift');
const expectedAlt = parsed.modifiers.has('alt');
const expectedCtrl = parsed.modifiers.has('ctrl');
const isDesktopMac = isMacOS() && isDesktopShell();
const isMac = isMacOS();
const modMatches = isDesktopMac
? event.metaKey
: isMac
? (event.metaKey || event.ctrlKey)
: event.ctrlKey;
if (expectedMod && !modMatches) {
return false;
}
if (!expectedMod && event.metaKey) {
return false;
}
if (expectedShift !== event.shiftKey) {
return false;
}
if (expectedAlt !== event.altKey) {
return false;
}
if (expectedCtrl) {
if (!event.ctrlKey) {
return false;
}
} else {
const ctrlUsedAsMod = expectedMod && !isDesktopMac && event.ctrlKey;
if (event.ctrlKey && !ctrlUsedAsMod) {
return false;
}
}
if (parsed.key && (!heldKeys || !heldKeys.has(parsed.key.toLowerCase()))) {
return false;
}
return true;
}
@@ -20,6 +20,13 @@ edge (`components/layout/ContextPanelRail.tsx`) and rendered by
- Rail order is user-reorderable and persisted globally in
`useUIStore.contextRailOrder`; `sortContextSurfaces` applies it on top of the
registry's default order and appends any missing surfaces.
- `getVisibleContextRailSurfaces` is the single visibility filter shared by the
rail and the global surface-switch shortcut (`switch_context_surface` in
`lib/shortcuts.ts`): it drops the plan surface unless plan mode is enabled,
drops the walkthrough on VS Code and below `WALKTHROUGH_MIN_WIDTH`, and hides
`has-content` surfaces until a tab of their mode exists. Both consumers use
it so the digit shown on a rail badge always maps to the same surface the
shortcut opens.
## Adding a surface
@@ -0,0 +1,53 @@
import { describe, expect, test } from 'bun:test';
import {
CONTEXT_SURFACES,
getVisibleContextRailSurfaces,
WALKTHROUGH_MIN_WIDTH,
} from './registry';
const baseOptions = {
railOrder: [],
planModeEnabled: true,
isVSCode: false,
screenWidth: 1200,
tabs: [],
} as const;
describe('getVisibleContextRailSurfaces', () => {
test('hides the plan surface while plan mode is disabled', () => {
const surfaces = getVisibleContextRailSurfaces({ ...baseOptions, planModeEnabled: false });
expect(surfaces.some((surface) => surface.id === 'plan')).toBe(false);
expect(surfaces.some((surface) => surface.id === 'context')).toBe(true);
});
test('shows the plan surface while plan mode is enabled', () => {
const surfaces = getVisibleContextRailSurfaces({ ...baseOptions, planModeEnabled: true });
expect(surfaces.some((surface) => surface.id === 'plan')).toBe(true);
});
test('hides the walkthrough on VS Code and below the min width', () => {
expect(getVisibleContextRailSurfaces({ ...baseOptions, isVSCode: true }).some((s) => s.id === 'walkthrough')).toBe(false);
expect(
getVisibleContextRailSurfaces({ ...baseOptions, screenWidth: WALKTHROUGH_MIN_WIDTH - 1 }).some((s) => s.id === 'walkthrough'),
).toBe(false);
expect(
getVisibleContextRailSurfaces({ ...baseOptions, screenWidth: WALKTHROUGH_MIN_WIDTH }).some((s) => s.id === 'walkthrough'),
).toBe(true);
});
test('hides content-driven surfaces until a matching tab exists', () => {
const preview = CONTEXT_SURFACES.find((surface) => surface.id === 'preview');
if (!preview) {
throw new Error('preview surface missing from registry');
}
expect(preview.availability).toBe('has-content');
expect(getVisibleContextRailSurfaces(baseOptions).some((s) => s.id === 'preview')).toBe(false);
expect(getVisibleContextRailSurfaces({ ...baseOptions, tabs: [{ mode: preview.mode }] }).some((s) => s.id === 'preview')).toBe(true);
});
test('respects the persisted user rail order', () => {
const surfaces = getVisibleContextRailSurfaces({ ...baseOptions, railOrder: ['git', 'context'] });
expect(surfaces.slice(0, 2).map((surface) => surface.id)).toEqual(['git', 'context']);
});
});
+37
View File
@@ -152,6 +152,10 @@ export const CONTEXT_SURFACES: readonly ContextSurfaceDescriptor[] = [
const SURFACE_BY_ID = new Map(CONTEXT_SURFACES.map((surface) => [surface.id, surface]));
const FRACTION_BY_MODE = new Map(CONTEXT_SURFACES.map((surface) => [surface.mode, surface.defaultWidthFraction]));
// 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.
export const WALKTHROUGH_MIN_WIDTH = 768;
export const getContextSurfaceWidthFraction = (mode: ContextPanelMode): number => {
return FRACTION_BY_MODE.get(mode) ?? 1 / 2;
};
@@ -187,3 +191,36 @@ export const sortContextSurfaces = (railOrder: readonly string[]): ContextSurfac
return ordered;
};
type VisibleRailSurfacesOptions = {
railOrder: readonly string[];
planModeEnabled: boolean;
isVSCode: boolean;
screenWidth: number;
tabs: readonly { mode: ContextPanelMode }[];
};
/**
* The context panel rail's visible, user-ordered surfaces. Shared by the rail
* (for rendering and number badges) and the global surface-switch shortcut so
* both agree on which surface each digit maps to.
*
* Content-driven surfaces are hidden (not disabled) until content exists; an
* existing tab keeps them visible even if the content source went away.
*/
export const getVisibleContextRailSurfaces = (options: VisibleRailSurfacesOptions): ContextSurfaceDescriptor[] => {
return sortContextSurfaces(options.railOrder).filter((surface) => {
if (surface.id === 'plan' && !options.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' && (options.isVSCode || options.screenWidth < WALKTHROUGH_MIN_WIDTH)) {
return false;
}
if (surface.availability === 'has-content') {
return options.tabs.some((tab) => tab.mode === surface.mode);
}
return true;
});
};