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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user