feat(ui): add session history navigation, permission keys, and review shortcuts

mod+alt+arrows step through this window's session-open history (or between
neighbouring tabs when session tabs are on), mod+k r renames the current
session inline, and mod+k a toggles permission auto-accept. Pending
permission cards respond to alt+enter / alt+shift+enter / alt+backspace with
the keys printed on the buttons. The commit message box commits on
mod+enter, alt+arrows step the diff review between changed files, and the
command palette gains search-only commands for rare actions so the initial
list stays short.
This commit is contained in:
Bohdan Triapitsyn
2026-08-26 15:46:44 +03:00
parent 7977842e1e
commit f2ec9b1003
36 changed files with 474 additions and 2 deletions
@@ -77,6 +77,7 @@ import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { usePermissionStore } from '@/stores/permissionStore';
import { togglePermissionAutoAccept } from './permissionAutoAccept';
import { useKeybind } from '@/hooks/useKeybind';
import { extractGitChangedFiles } from './changedFiles';
import { useI18n } from '@/lib/i18n';
import { sessionEvents } from '@/lib/sessionEvents';
@@ -2562,6 +2563,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
t,
]);
useKeybind('toggle_permission_auto_accept', () => {
if (!isPermissionAutoAcceptInteractive) return false;
handlePermissionAutoAcceptToggle();
});
React.useEffect(() => {
const pendingAbortBanner = Boolean(abortPromptSessionId) && abortPromptSessionId === currentSessionId;
if (!prevWasAbortedRef.current && pendingAbortBanner && !showAbortStatus) {
@@ -10,6 +10,10 @@ import { Icon } from "@/components/icon/Icon";
import { DiffPreview, WritePreview } from './DiffPreview';
import { useI18n } from '@/lib/i18n';
import { getVisiblePermissionPatterns } from './permissionCardPatterns';
import { formatShortcutForDisplay } from '@/lib/shortcuts';
// Newest pending card owns the keyboard; older cards wait their turn.
const activePermissionCardIds: string[] = [];
const PERMISSION_BASH_CUSTOM_STYLE: React.CSSProperties = {
margin: 0,
@@ -126,6 +130,33 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
}
};
const handleResponseRef = React.useRef(handleResponse);
handleResponseRef.current = handleResponse;
React.useEffect(() => {
if (hasResponded) return;
activePermissionCardIds.push(permission.id);
const handleKeyDown = (event: KeyboardEvent) => {
if (activePermissionCardIds.at(-1) !== permission.id) return;
if (!event.altKey || event.metaKey || event.ctrlKey) return;
const response = event.key === 'Enter'
? (event.shiftKey ? 'always' as const : 'once' as const)
: event.key === 'Backspace' && !event.shiftKey
? 'reject' as const
: null;
if (!response) return;
event.preventDefault();
event.stopPropagation();
void handleResponseRef.current(response);
};
window.addEventListener('keydown', handleKeyDown, true);
return () => {
window.removeEventListener('keydown', handleKeyDown, true);
const index = activePermissionCardIds.lastIndexOf(permission.id);
if (index !== -1) activePermissionCardIds.splice(index, 1);
};
}, [hasResponded, permission.id]);
if (hasResponded) {
return null;
}
@@ -380,6 +411,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
>
<Icon name="check" className="h-3.5 w-3.5 sm:h-3 sm:w-3 flex-shrink-0" />
Allow Once
<kbd className="ml-1 hidden sm:inline typography-micro opacity-60">{formatShortcutForDisplay('alt+enter')}</kbd>
</button>
{permission.always.length > 0 ? (
@@ -436,6 +468,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
>
<Icon name="time" className="h-3.5 w-3.5 sm:h-3 sm:w-3 flex-shrink-0" />
Always Allow
<kbd className="ml-1 hidden sm:inline typography-micro opacity-60">{formatShortcutForDisplay('alt+shift+enter')}</kbd>
</button>
)}
@@ -459,6 +492,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
>
<Icon name="close" className="h-3.5 w-3.5 sm:h-3 sm:w-3 flex-shrink-0" />
Deny
<kbd className="ml-1 hidden sm:inline typography-micro opacity-60">{formatShortcutForDisplay('alt+backspace')}</kbd>
</button>
{isResponding && (
@@ -1463,6 +1463,10 @@ export const Header: React.FC = () => {
useKeybinds({
rename_current_session: () => {
if (!currentSessionId || isMobile) return false;
beginHeaderSessionRename();
},
toggle_services_menu: () => {
if (isDesktopServicesOpen) {
setIsDesktopServicesOpen(false);
@@ -50,6 +50,7 @@ import { scoreByFuzzyQuery } from '@/lib/search/fuzzySearch';
import { truncatePathMiddle } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { sessionEvents } from '@/lib/sessionEvents';
import { copyTextToClipboard } from '@/lib/clipboard';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { buildCommandPaletteFileSearchKey, scoreCommandPaletteFiles } from './commandPaletteFilesState';
@@ -59,6 +60,9 @@ type CommandEntry = {
icon: React.ReactNode;
shortcutId?: string;
searchText: string;
/** Search-only command: reachable by typing, hidden from the initial list
so the first screen stays scroll-free. */
secondary?: boolean;
onSelect: () => void;
};
@@ -90,9 +94,14 @@ export const CommandPalette: React.FC = () => {
const openContextSurface = useUIStore((s) => s.openContextSurface);
const openContextFile = useUIStore((s) => s.openContextFile);
const shortcutOverrides = useUIStore((s) => s.shortcutOverrides);
const openMultiRunLauncher = useUIStore((s) => s.openMultiRunLauncher);
const setArchivePageOpen = useUIStore((s) => s.setArchivePageOpen);
const setProjectContextTab = useUIStore((s) => s.setProjectContextTab);
const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft);
const setCurrentSession = useSessionUIStore((s) => s.setCurrentSession);
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
const togglePinnedSession = useSessionPinnedStore((s) => s.toggle);
const activeSessions = useGlobalSessionsStore(React.useCallback(
(state) => isCommandPaletteOpen ? state.activeSessions : EMPTY_SESSIONS,
@@ -233,6 +242,7 @@ export const CommandPalette: React.FC = () => {
},
{
id: 'cycle-theme',
secondary: true,
title: t('commandPalette.item.cycleTheme'),
icon: <Icon name="palette" className="mr-2 h-4 w-4" />,
shortcutId: 'cycle_theme',
@@ -243,6 +253,7 @@ export const CommandPalette: React.FC = () => {
},
{
id: 'open-status',
secondary: true,
title: t('commandPalette.item.showOpenCodeStatus'),
icon: <Icon name="pulse" className="mr-2 h-4 w-4" />,
searchText: t('commandPalette.item.showOpenCodeStatus'),
@@ -259,8 +270,90 @@ export const CommandPalette: React.FC = () => {
onSelect: run(() => setSettingsDialogOpen(true)),
},
];
list.push(
{
id: 'pin-session',
secondary: true,
title: t('commandPalette.item.pinSession'),
icon: <Icon name="pushpin" className="mr-2 h-4 w-4" />,
searchText: t('commandPalette.item.pinSession'),
onSelect: run(() => {
if (currentSessionId && currentDirectory) {
togglePinnedSession({ directory: currentDirectory, sessionId: currentSessionId });
}
}),
},
{
id: 'copy-session-id',
secondary: true,
title: t('commandPalette.item.copySessionId'),
icon: <Icon name="file-copy" className="mr-2 h-4 w-4" />,
searchText: t('commandPalette.item.copySessionId'),
onSelect: run(() => {
if (!currentSessionId) return;
void copyTextToClipboard(currentSessionId)
.then((result) => {
if (result.ok) {
toast.success(t('sessions.sidebar.session.copyId.success'));
return;
}
toast.error(t('sessions.sidebar.session.copyId.error'));
})
.catch(() => toast.error(t('sessions.sidebar.session.copyId.error')));
}),
},
{
id: 'open-multi-run',
secondary: true,
title: t('commandPalette.item.openMultiRun'),
icon: <Icon name="checkbox-multiple" className="mr-2 h-4 w-4" />,
searchText: t('commandPalette.item.openMultiRun'),
onSelect: run(() => {
setSessionSwitcherOpen(false);
openMultiRunLauncher();
}),
},
{
id: 'open-archive',
secondary: true,
title: t('commandPalette.item.openArchive'),
icon: <Icon name="archive" className="mr-2 h-4 w-4" />,
searchText: t('commandPalette.item.openArchive'),
onSelect: run(() => {
setSessionSwitcherOpen(false);
setArchivePageOpen(true);
}),
},
{
id: 'open-notes',
secondary: true,
title: t('commandPalette.item.openNotes'),
icon: <Icon name="sticky-note" className="mr-2 h-4 w-4" />,
searchText: t('commandPalette.item.openNotes'),
onSelect: run(() => {
if (currentDirectory) {
setProjectContextTab('notes');
openContextSurface(currentDirectory, 'notes');
}
}),
},
{
id: 'open-todos',
secondary: true,
title: t('commandPalette.item.openTodos'),
icon: <Icon name="checkbox-circle" className="mr-2 h-4 w-4" />,
searchText: t('commandPalette.item.openTodos'),
onSelect: run(() => {
if (currentDirectory) {
setProjectContextTab('todos');
openContextSurface(currentDirectory, 'notes');
}
}),
},
);
list.push({
id: 'toggle-memory-debug',
secondary: true,
title: t('commandPalette.item.toggleMemoryDebug'),
icon: <Icon name="bug" className="mr-2 h-4 w-4" />,
searchText: t('commandPalette.item.toggleMemoryDebug'),
@@ -299,6 +392,11 @@ export const CommandPalette: React.FC = () => {
setSettingsDialogOpen,
activeProject?.id,
activeProject?.path,
currentSessionId,
togglePinnedSession,
openMultiRunLauncher,
setArchivePageOpen,
setProjectContextTab,
]);
// ---------------------------------------------------------------------------
@@ -407,7 +505,9 @@ export const CommandPalette: React.FC = () => {
const hasQuery = liveTrimmed.length > 0;
const scoredCommands = React.useMemo(() => {
if (!hasQuery) return commands.map((item) => ({ item, score: 0 }));
if (!hasQuery) {
return commands.filter((item) => !item.secondary).map((item) => ({ item, score: 0 }));
}
return scoreByFuzzyQuery(commands, liveTrimmed, (c) => c.searchText, {
limit: 7,
noFuzzy: true,
@@ -1768,6 +1768,37 @@ export const DiffView: React.FC<DiffViewProps> = ({
scrollToFile(value);
}, [cancelPendingScrollAlignment, expandStackedFile, scrollToFile]);
// Step review to the adjacent changed file (alt+arrow): selects, expands
// a collapsed section, and scrolls to it. Window-level because the diff
// surface has no persistent focus target; guarded off editable fields.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (!event.altKey || event.metaKey || event.ctrlKey || event.shiftKey) return;
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
const target = event.target;
if (target instanceof HTMLElement && (
target.isContentEditable
|| target.tagName === 'INPUT'
|| target.tagName === 'TEXTAREA'
|| target.closest('[role="dialog"]')
)) {
return;
}
if (changedFiles.length === 0) return;
const delta = event.key === 'ArrowDown' ? 1 : -1;
const index = displayFile ? changedFiles.findIndex((file) => file.path === displayFile) : -1;
const nextIndex = index === -1
? (delta > 0 ? 0 : changedFiles.length - 1)
: index + delta;
const next = changedFiles[nextIndex];
if (!next) return;
event.preventDefault();
handleSelectFileAndScroll(next.path);
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [changedFiles, displayFile, handleSelectFileAndScroll]);
const handleHeaderLayoutChange = React.useCallback((mode: DiffViewMode) => {
const nextLayout: 'inline' | 'side-by-side' =
mode === 'side-by-side' ? 'side-by-side' : 'inline';
@@ -6,6 +6,7 @@ import { useI18n } from '@/lib/i18n';
interface CommitInputProps {
value: string;
onChange: (value: string) => void;
onSubmit?: () => void;
placeholder?: string;
disabled?: boolean;
hasTouchInput?: boolean;
@@ -18,6 +19,7 @@ const MAX_HEIGHT = 200;
export const CommitInput: React.FC<CommitInputProps> = ({
value,
onChange,
onSubmit,
placeholder,
disabled = false,
hasTouchInput = false,
@@ -58,6 +60,12 @@ export const CommitInput: React.FC<CommitInputProps> = ({
ref={textareaRef}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey) {
e.preventDefault();
onSubmit?.();
}
}}
placeholder={placeholder ?? t('gitView.commit.messagePlaceholder')}
rows={1}
disabled={disabled}
@@ -68,6 +68,9 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
<CommitInput
value={commitMessage}
onChange={onCommitMessageChange}
onSubmit={() => {
if (canCommit && !isGeneratingMessage) onCommit();
}}
placeholder={t('gitView.commit.messagePlaceholder')}
disabled={commitAction !== null}
hasTouchInput={hasTouchInput}
+10 -1
View File
@@ -1,7 +1,8 @@
import React from 'react';
import { isTerminalEventTarget } from '@/lib/terminalFocus';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { activateSessionTabByIndex, closeSessionTabAndActivateNeighbour } from '@/lib/sessionTabs';
import { activateAdjacentSessionTab, activateSessionTabByIndex, closeSessionTabAndActivateNeighbour } from '@/lib/sessionTabs';
import { navigateSessionHistory } from '@/lib/sessionNavigationHistory';
import { useSelectionStore } from '@/sync/selection-store';
import * as sessionActions from '@/sync/session-actions';
import { normalizeContextPanelDirectoryKey, useUIStore } from '@/stores/useUIStore';
@@ -169,6 +170,14 @@ export const useKeyboardShortcuts = () => {
console.warn('[keyboard-shortcuts] failed to open draft mini chat window', error);
});
},
switch_session_previous: () => {
if (!isVSCodeRuntime() && useUIStore.getState().sessionTabsEnabled && activateAdjacentSessionTab(-1)) return;
return navigateSessionHistory(-1) ? undefined : false;
},
switch_session_next: () => {
if (!isVSCodeRuntime() && useUIStore.getState().sessionTabsEnabled && activateAdjacentSessionTab(1)) return;
return navigateSessionHistory(1) ? undefined : false;
},
close_session_tab: () => {
if (isVSCodeRuntime() || !useUIStore.getState().sessionTabsEnabled) return false;
if (currentSessionId) {
@@ -1085,6 +1085,10 @@ export const settingsDict = {
'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.switch_session_previous.label': 'Vorherige Sitzung',
'settings.openchamber.keyboardShortcuts.action.switch_session_next.label': 'Nächste Sitzung',
'settings.openchamber.keyboardShortcuts.action.rename_current_session.label': 'Aktuelle Sitzung umbenennen',
'settings.openchamber.keyboardShortcuts.action.toggle_permission_auto_accept.label': 'Auto-Genehmigung umschalten',
'settings.openchamber.keyboardShortcuts.action.close_session_tab.label': 'Sitzungs-Tab schließen',
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': 'Neuer Worktree-Entwurf',
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': 'Neues Mini-Chat-Fenster',
+6
View File
@@ -2292,6 +2292,12 @@ export const dict = {
'commandPalette.item.cycleTheme': 'Thema wechseln',
'commandPalette.item.showOpenCodeStatus': 'OpenCode-Status anzeigen',
'commandPalette.item.toggleMemoryDebug': 'Memory-Debug-Panel umschalten',
'commandPalette.item.pinSession': 'Sitzung anheften oder lösen',
'commandPalette.item.copySessionId': 'Sitzungs-ID kopieren',
'commandPalette.item.openMultiRun': 'Multi-Run-Launcher öffnen',
'commandPalette.item.openArchive': 'Archivierte Sitzungen öffnen',
'commandPalette.item.openNotes': 'Notizbereich öffnen',
'commandPalette.item.openTodos': 'To-do-Bereich öffnen',
'commandPalette.item.openSettings': 'Einstellungen öffnen...',
'commandPalette.session.untitled': 'Unbenannte Sitzung',
'openCodeStatusDialog.title': 'OpenCode-Status',
@@ -1147,6 +1147,10 @@ export const settingsDict = {
'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.switch_session_previous.label': 'Previous session',
'settings.openchamber.keyboardShortcuts.action.switch_session_next.label': 'Next session',
'settings.openchamber.keyboardShortcuts.action.rename_current_session.label': 'Rename current session',
'settings.openchamber.keyboardShortcuts.action.toggle_permission_auto_accept.label': 'Toggle permission auto-accept',
'settings.openchamber.keyboardShortcuts.action.close_session_tab.label': 'Close session tab',
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': 'New worktree draft',
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': 'New Mini Chat window',
+6
View File
@@ -2482,6 +2482,12 @@ export const dict = {
'commandPalette.item.cycleTheme': 'Cycle theme',
'commandPalette.item.showOpenCodeStatus': 'Show OpenCode status',
'commandPalette.item.toggleMemoryDebug': 'Toggle memory debug panel',
'commandPalette.item.pinSession': 'Pin or unpin session',
'commandPalette.item.copySessionId': 'Copy session ID',
'commandPalette.item.openMultiRun': 'Open multi-run launcher',
'commandPalette.item.openArchive': 'Open archived sessions',
'commandPalette.item.openNotes': 'Open notes surface',
'commandPalette.item.openTodos': 'Open todos surface',
'commandPalette.item.openSettings': 'Open Settings...',
'commandPalette.session.untitled': 'Untitled Session',
'openCodeStatusDialog.title': 'OpenCode Status',
@@ -1115,6 +1115,10 @@ export const settingsDict = {
"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.switch_session_previous.label": "Sesión anterior",
"settings.openchamber.keyboardShortcuts.action.switch_session_next.label": "Sesión siguiente",
"settings.openchamber.keyboardShortcuts.action.rename_current_session.label": "Renombrar sesión actual",
"settings.openchamber.keyboardShortcuts.action.toggle_permission_auto_accept.label": "Alternar aprobación automática",
"settings.openchamber.keyboardShortcuts.action.close_session_tab.label": "Cerrar pestaña de 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",
+6
View File
@@ -2448,6 +2448,12 @@ export const dict: Record<I18nKey, string> = {
"commandPalette.item.cycleTheme": "Cambiar tema",
"commandPalette.item.showOpenCodeStatus": "Mostrar estado de OpenCode",
"commandPalette.item.toggleMemoryDebug": "Alternar panel de depuración de memoria",
"commandPalette.item.pinSession": "Anclar o desanclar sesión",
"commandPalette.item.copySessionId": "Copiar ID de sesión",
"commandPalette.item.openMultiRun": "Abrir lanzador multi-run",
"commandPalette.item.openArchive": "Abrir sesiones archivadas",
"commandPalette.item.openNotes": "Abrir panel de notas",
"commandPalette.item.openTodos": "Abrir panel de tareas",
"commandPalette.item.openSettings": "Abrir configuración...",
"commandPalette.session.untitled": "Sesión sin título",
"openCodeStatusDialog.title": "Estado de OpenCode",
@@ -1033,6 +1033,10 @@ export const settingsDict = {
'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.switch_session_previous.label': 'Session précédente',
'settings.openchamber.keyboardShortcuts.action.switch_session_next.label': 'Session suivante',
'settings.openchamber.keyboardShortcuts.action.rename_current_session.label': 'Renommer la session actuelle',
'settings.openchamber.keyboardShortcuts.action.toggle_permission_auto_accept.label': 'Basculer lapprobation automatique',
'settings.openchamber.keyboardShortcuts.action.close_session_tab.label': 'Fermer longlet de 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',
+6
View File
@@ -2186,6 +2186,12 @@ export const dict = {
'commandPalette.item.cycleTheme': 'Changer de thème',
'commandPalette.item.showOpenCodeStatus': 'Afficher le statut OpenCode',
'commandPalette.item.toggleMemoryDebug': 'Basculer le panneau de débogage mémoire',
'commandPalette.item.pinSession': 'Épingler ou désépingler la session',
'commandPalette.item.copySessionId': 'Copier l\'ID de session',
'commandPalette.item.openMultiRun': 'Ouvrir le lanceur multi-run',
'commandPalette.item.openArchive': 'Ouvrir les sessions archivées',
'commandPalette.item.openNotes': 'Ouvrir le panneau de notes',
'commandPalette.item.openTodos': 'Ouvrir le panneau de tâches',
'commandPalette.item.openSettings': 'Ouvrez les paramètres...',
'commandPalette.session.untitled': 'Session sans titre',
'openCodeStatusDialog.title': 'Statut OpenCode',
@@ -1148,6 +1148,10 @@ export const settingsDict = {
'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.switch_session_previous.label': '前のセッション',
'settings.openchamber.keyboardShortcuts.action.switch_session_next.label': '次のセッション',
'settings.openchamber.keyboardShortcuts.action.rename_current_session.label': '現在のセッション名を変更',
'settings.openchamber.keyboardShortcuts.action.toggle_permission_auto_accept.label': '権限の自動承認を切り替え',
'settings.openchamber.keyboardShortcuts.action.close_session_tab.label': 'セッションタブを閉じる',
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': '新しい Worktree 下書き',
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': '新しいミニチャットウィンドウ',
+6
View File
@@ -2481,6 +2481,12 @@ export const dict: Record<I18nKey, string> = {
'commandPalette.item.cycleTheme': 'テーマを順に切替',
'commandPalette.item.showOpenCodeStatus': 'OpenCode のステータスを表示',
'commandPalette.item.toggleMemoryDebug': 'メモリデバッグパネルの切替',
'commandPalette.item.pinSession': 'セッションをピン留め/解除',
'commandPalette.item.copySessionId': 'セッションIDをコピー',
'commandPalette.item.openMultiRun': 'マルチラン起動画面を開く',
'commandPalette.item.openArchive': 'アーカイブ済みセッションを開く',
'commandPalette.item.openNotes': 'ノートパネルを開く',
'commandPalette.item.openTodos': 'ToDoパネルを開く',
'commandPalette.item.openSettings': '設定を開く...',
'commandPalette.session.untitled': '無題のセッション',
'openCodeStatusDialog.title': 'OpenCodeステータス',
@@ -1115,6 +1115,10 @@ export const settingsDict = {
'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.switch_session_previous.label': '이전 세션',
'settings.openchamber.keyboardShortcuts.action.switch_session_next.label': '다음 세션',
'settings.openchamber.keyboardShortcuts.action.rename_current_session.label': '현재 세션 이름 바꾸기',
'settings.openchamber.keyboardShortcuts.action.toggle_permission_auto_accept.label': '권한 자동 승인 전환',
'settings.openchamber.keyboardShortcuts.action.close_session_tab.label': '세션 탭 닫기',
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': '새 worktree 초안',
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': '새 Mini Chat 창',
+6
View File
@@ -2482,6 +2482,12 @@ export const dict: Record<I18nKey, string> = {
'commandPalette.item.cycleTheme': '테마 순환',
'commandPalette.item.showOpenCodeStatus': 'OpenCode 상태 표시',
'commandPalette.item.toggleMemoryDebug': '메모리 디버그 패널 토글',
'commandPalette.item.pinSession': '세션 고정 또는 고정 해제',
'commandPalette.item.copySessionId': '세션 ID 복사',
'commandPalette.item.openMultiRun': '멀티 런 런처 열기',
'commandPalette.item.openArchive': '보관된 세션 열기',
'commandPalette.item.openNotes': '노트 패널 열기',
'commandPalette.item.openTodos': '할 일 패널 열기',
'commandPalette.item.openSettings': '설정... 열기',
'commandPalette.session.untitled': '제목 없는 세션',
'openCodeStatusDialog.title': 'OpenCode 상태',
@@ -824,6 +824,10 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.action.toggle_prompt_navigator.label': 'Przełącz nawigator promptów',
'settings.openchamber.keyboardShortcuts.action.focus_input.label': 'Skup pole wprowadzania',
'settings.openchamber.keyboardShortcuts.action.new_chat.label': 'Nowa sesja',
'settings.openchamber.keyboardShortcuts.action.switch_session_previous.label': 'Poprzednia sesja',
'settings.openchamber.keyboardShortcuts.action.switch_session_next.label': 'Następna sesja',
'settings.openchamber.keyboardShortcuts.action.rename_current_session.label': 'Zmień nazwę bieżącej sesji',
'settings.openchamber.keyboardShortcuts.action.toggle_permission_auto_accept.label': 'Przełącz automatyczne zatwierdzanie',
'settings.openchamber.keyboardShortcuts.action.close_session_tab.label': 'Zamknij kartę sesji',
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': 'Nowy szkic obszaru roboczego',
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': 'Nowe okno Mini Chat',
+6
View File
@@ -1455,6 +1455,12 @@ export const dict: Record<I18nKey, string> = {
'commandPalette.item.cycleTheme': 'Przełącz motyw',
'commandPalette.item.showOpenCodeStatus': 'Pokaż status OpenCode',
'commandPalette.item.toggleMemoryDebug': 'Przełącz panel debugowania pamięci',
'commandPalette.item.pinSession': 'Przypnij lub odepnij sesję',
'commandPalette.item.copySessionId': 'Kopiuj ID sesji',
'commandPalette.item.openMultiRun': 'Otwórz panel multi-run',
'commandPalette.item.openArchive': 'Otwórz zarchiwizowane sesje',
'commandPalette.item.openNotes': 'Otwórz panel notatek',
'commandPalette.item.openTodos': 'Otwórz panel zadań',
'commandPalette.session.untitled': 'Nienazwana sesja',
'commandPalette.title': 'Paleta poleceń',
'contextPanel.actions.closePanel': 'Zamknij panel',
@@ -1115,6 +1115,10 @@ export const settingsDict = {
"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.switch_session_previous.label": "Sessão anterior",
"settings.openchamber.keyboardShortcuts.action.switch_session_next.label": "Próxima sessão",
"settings.openchamber.keyboardShortcuts.action.rename_current_session.label": "Renomear sessão atual",
"settings.openchamber.keyboardShortcuts.action.toggle_permission_auto_accept.label": "Alternar aprovação automática",
"settings.openchamber.keyboardShortcuts.action.close_session_tab.label": "Fechar aba da 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",
@@ -2448,6 +2448,12 @@ export const dict: Record<I18nKey, string> = {
"commandPalette.item.cycleTheme": "Alternar tema",
"commandPalette.item.showOpenCodeStatus": "Mostrar status do OpenCode",
"commandPalette.item.toggleMemoryDebug": "Alternar painel de depuração de memória",
"commandPalette.item.pinSession": "Fixar ou desafixar sessão",
"commandPalette.item.copySessionId": "Copiar ID da sessão",
"commandPalette.item.openMultiRun": "Abrir lançador multi-run",
"commandPalette.item.openArchive": "Abrir sessões arquivadas",
"commandPalette.item.openNotes": "Abrir painel de notas",
"commandPalette.item.openTodos": "Abrir painel de tarefas",
"commandPalette.item.openSettings": "Abrir configurações...",
"commandPalette.session.untitled": "Sessão sem título",
"openCodeStatusDialog.title": "Status do OpenCode",
@@ -1115,6 +1115,10 @@ export const settingsDict = {
"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.switch_session_previous.label": "Попередня сесія",
"settings.openchamber.keyboardShortcuts.action.switch_session_next.label": "Наступна сесія",
"settings.openchamber.keyboardShortcuts.action.rename_current_session.label": "Перейменувати поточну сесію",
"settings.openchamber.keyboardShortcuts.action.toggle_permission_auto_accept.label": "Перемкнути авто-дозволи",
"settings.openchamber.keyboardShortcuts.action.close_session_tab.label": "Закрити вкладку сесії",
"settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label": "Нова чернетка worktree",
"settings.openchamber.keyboardShortcuts.action.new_mini_chat.label": "Нове вікно Mini Chat",
+6
View File
@@ -2448,6 +2448,12 @@ export const dict: Record<I18nKey, string> = {
"commandPalette.item.cycleTheme": "Перемкнути тему",
"commandPalette.item.showOpenCodeStatus": "Показати статус OpenCode",
"commandPalette.item.toggleMemoryDebug": "Показати/сховати панель memory debug",
"commandPalette.item.pinSession": "Прикріпити або відкріпити сесію",
"commandPalette.item.copySessionId": "Скопіювати ID сесії",
"commandPalette.item.openMultiRun": "Відкрити лаунчер multi-run",
"commandPalette.item.openArchive": "Відкрити архівовані сесії",
"commandPalette.item.openNotes": "Відкрити панель нотаток",
"commandPalette.item.openTodos": "Відкрити панель завдань",
"commandPalette.item.openSettings": "Відкрити налаштування...",
"commandPalette.session.untitled": "Сесія без назви",
"openCodeStatusDialog.title": "Статус OpenCode",
@@ -1115,6 +1115,10 @@ export const settingsDict = {
'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.switch_session_previous.label': '上一个会话',
'settings.openchamber.keyboardShortcuts.action.switch_session_next.label': '下一个会话',
'settings.openchamber.keyboardShortcuts.action.rename_current_session.label': '重命名当前会话',
'settings.openchamber.keyboardShortcuts.action.toggle_permission_auto_accept.label': '切换权限自动批准',
'settings.openchamber.keyboardShortcuts.action.close_session_tab.label': '关闭会话标签页',
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': '新建工作树草稿',
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': '新建 Mini Chat 窗口',
@@ -2448,6 +2448,12 @@ export const dict: Record<I18nKey, string> = {
'commandPalette.item.cycleTheme': '轮换主题',
'commandPalette.item.showOpenCodeStatus': '显示 OpenCode 状态',
'commandPalette.item.toggleMemoryDebug': '切换内存调试面板',
'commandPalette.item.pinSession': '固定或取消固定会话',
'commandPalette.item.copySessionId': '复制会话 ID',
'commandPalette.item.openMultiRun': '打开多任务启动器',
'commandPalette.item.openArchive': '打开已归档会话',
'commandPalette.item.openNotes': '打开笔记面板',
'commandPalette.item.openTodos': '打开待办面板',
'commandPalette.item.openSettings': '打开设置...',
'commandPalette.session.untitled': '未命名会话',
'openCodeStatusDialog.title': 'OpenCode 状态',
@@ -1022,6 +1022,10 @@ export const settingsDict = {
'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.switch_session_previous.label': '上一個工作階段',
'settings.openchamber.keyboardShortcuts.action.switch_session_next.label': '下一個工作階段',
'settings.openchamber.keyboardShortcuts.action.rename_current_session.label': '重新命名目前的工作階段',
'settings.openchamber.keyboardShortcuts.action.toggle_permission_auto_accept.label': '切換權限自動核准',
'settings.openchamber.keyboardShortcuts.action.close_session_tab.label': '關閉工作階段分頁',
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': '新增 worktree 草稿',
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': '新建 Mini Chat 視窗',
@@ -2452,6 +2452,12 @@ export const dict: Record<I18nKey, string> = {
'commandPalette.item.cycleTheme': '輪換主題',
'commandPalette.item.showOpenCodeStatus': '顯示 OpenCode 狀態',
'commandPalette.item.toggleMemoryDebug': '切換記憶體偵錯面板',
'commandPalette.item.pinSession': '釘選或取消釘選會話',
'commandPalette.item.copySessionId': '複製會話 ID',
'commandPalette.item.openMultiRun': '開啟多任務啟動器',
'commandPalette.item.openArchive': '開啟已封存會話',
'commandPalette.item.openNotes': '開啟筆記面板',
'commandPalette.item.openTodos': '開啟待辦面板',
'commandPalette.item.openSettings': '開啟設定...',
'commandPalette.session.untitled': '未命名會話',
'openCodeStatusDialog.title': 'OpenCode 狀態',
@@ -0,0 +1,50 @@
import { describe, expect, test } from 'bun:test';
import type { Session } from '@opencode-ai/sdk/v2';
import { navigateSessionHistory } from './sessionNavigationHistory';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
// SAFETY: the history module only reads a session's id and directory metadata.
const session = (id: string): Session => ({
id,
title: id,
directory: '/repo',
projectID: 'p1',
version: '1',
time: { created: 1, updated: 1 },
} as Session);
describe('sessionNavigationHistory', () => {
test('steps back and forward through the visit order', () => {
useGlobalSessionsStore.setState({ activeSessions: [session('s1'), session('s2'), session('s3')] });
useSessionUIStore.setState({ currentSessionId: 's1' });
useSessionUIStore.setState({ currentSessionId: 's2' });
useSessionUIStore.setState({ currentSessionId: 's3' });
expect(navigateSessionHistory(-1)).toBe(true);
expect(useSessionUIStore.getState().currentSessionId).toBe('s2');
expect(navigateSessionHistory(-1)).toBe(true);
expect(useSessionUIStore.getState().currentSessionId).toBe('s1');
expect(navigateSessionHistory(-1)).toBe(false);
expect(navigateSessionHistory(1)).toBe(true);
expect(useSessionUIStore.getState().currentSessionId).toBe('s2');
});
test('a fresh visit truncates the forward branch', () => {
// Continues from the previous test's state: at s2 with s3 forward.
useSessionUIStore.setState({ currentSessionId: 's1' });
expect(navigateSessionHistory(1)).toBe(false);
expect(navigateSessionHistory(-1)).toBe(true);
expect(useSessionUIStore.getState().currentSessionId).toBe('s2');
});
test('skips and drops entries whose session no longer exists', () => {
useSessionUIStore.setState({ currentSessionId: 's3' });
useGlobalSessionsStore.setState({ activeSessions: [session('s1'), session('s3')] });
// History behind s3 contains s2 (dead) then s1 (alive).
expect(navigateSessionHistory(-1)).toBe(true);
expect(useSessionUIStore.getState().currentSessionId).toBe('s1');
});
});
@@ -0,0 +1,61 @@
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useGlobalSessionsStore, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
// Browser-style back/forward over the order sessions were opened in this
// window. A normal session switch truncates the forward part and appends;
// stepping through history moves only the cursor, so back stays back even
// after several presses. In-memory by design: the stack describes this
// window's journey, not durable state.
const MAX_HISTORY = 100;
let visitedSessionIds: string[] = [];
let cursor = -1;
let navigating = false;
const recordVisit = (sessionId: string): void => {
if (visitedSessionIds[cursor] === sessionId) return;
visitedSessionIds = [...visitedSessionIds.slice(0, cursor + 1), sessionId].slice(-MAX_HISTORY);
cursor = visitedSessionIds.length - 1;
};
useSessionUIStore.subscribe((state, previousState) => {
if (state.currentSessionId === previousState.currentSessionId) return;
if (!state.currentSessionId || navigating) return;
recordVisit(state.currentSessionId);
});
/**
* Steps the current session back (-1) or forward (+1) through this window's
* open history. Entries whose session no longer exists in the loaded list are
* skipped and dropped. Returns false when there is nowhere to go.
*/
export const navigateSessionHistory = (delta: -1 | 1): boolean => {
const sessionsById = new Map(
useGlobalSessionsStore.getState().activeSessions.map((session) => [session.id, session] as const),
);
let nextCursor = cursor + delta;
while (nextCursor >= 0 && nextCursor < visitedSessionIds.length) {
const session = sessionsById.get(visitedSessionIds[nextCursor]);
if (session) {
cursor = nextCursor;
navigating = true;
try {
useSessionUIStore.getState().setCurrentSession(session.id, resolveGlobalSessionDirectory(session));
} finally {
navigating = false;
}
return true;
}
// Drop the dead entry at nextCursor and keep scanning in the same
// direction: a removal shifts later entries one index down, so the next
// forward candidate lands on the same index while a backward scan steps.
visitedSessionIds = [
...visitedSessionIds.slice(0, nextCursor),
...visitedSessionIds.slice(nextCursor + 1),
];
if (nextCursor < cursor) cursor -= 1;
if (delta < 0) nextCursor -= 1;
}
return false;
};
+22
View File
@@ -26,6 +26,28 @@ export const activateSessionTabByIndex = (index: number): boolean => {
return true;
};
/**
* Activate the tab one step right (+1) or left (-1) of the current session
* in the rendered strip order, wrapping around the ends. Returns false when
* the current session has no tab or there is nothing to move to.
*/
export const activateAdjacentSessionTab = (delta: -1 | 1): boolean => {
const { tabIds } = useSessionTabsStore.getState();
const { currentSessionId, setCurrentSession } = useSessionUIStore.getState();
const sessionsById = new Map(
useGlobalSessionsStore.getState().activeSessions.map((session) => [session.id, session] as const),
);
const renderable = tabIds.filter((id) => sessionsById.has(id));
if (!currentSessionId || renderable.length < 2) return false;
const index = renderable.indexOf(currentSessionId);
if (index === -1) return false;
const nextId = renderable[(index + delta + renderable.length) % renderable.length];
const next = sessionsById.get(nextId);
if (!next) return false;
setCurrentSession(next.id, resolveGlobalSessionDirectory(next));
return true;
};
export const closeSessionTabAndActivateNeighbour = (sessionId: string): void => {
const { tabIds, closeTab } = useSessionTabsStore.getState();
if (!tabIds.includes(sessionId)) return;
+28
View File
@@ -51,6 +51,34 @@ const SHORTCUT_GROUPS = {
customizable: true,
settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.new_chat.label',
},
{
id: 'switch_session_previous',
defaultBinding: 'mod+alt+arrowleft',
customizable: true,
settingsLabelKey:
'settings.openchamber.keyboardShortcuts.action.switch_session_previous.label',
},
{
id: 'switch_session_next',
defaultBinding: 'mod+alt+arrowright',
customizable: true,
settingsLabelKey:
'settings.openchamber.keyboardShortcuts.action.switch_session_next.label',
},
{
id: 'rename_current_session',
defaultBinding: 'mod+k r',
customizable: true,
settingsLabelKey:
'settings.openchamber.keyboardShortcuts.action.rename_current_session.label',
},
{
id: 'toggle_permission_auto_accept',
defaultBinding: 'mod+k a',
customizable: true,
settingsLabelKey:
'settings.openchamber.keyboardShortcuts.action.toggle_permission_auto_accept.label',
},
{
id: 'close_session_tab',
defaultBinding: 'alt+w',
+1
View File
@@ -8,6 +8,7 @@
- Chat: @ file mentions now rank files and directories together by how well they match, so the file you typed is at the top instead of below unrelated directories. Multi-word queries match in any order, and long paths keep the folder next to the file name visible.
- Search: Ctrl/Cmd+P now matches the whole file path, not just the file name — searching a folder name finds the files inside it.
- Search in dropdowns: searchable pickers (agents, models, providers, branches) now put the best matches first, match multi-word queries in any order, and ignore punctuation (so "gpt4o" finds "gpt-4o").
- Permission cards answer to the keyboard: Alt+Enter allows once, Alt+Shift+Enter allows always, Alt+Backspace denies; the keys are printed on the buttons.
- Keyboard: dropdown menus and pickers answer Ctrl+N/Ctrl+P for down/up, the session switcher opens focused on your current session, and shortcut labels in tooltips and menus show the binding you actually have set (thanks @ChangeHow).
- Chat: OpenCode notices now share one style.
- The timeline dialog now fits small windows instead of squeezing the message list to a couple of rows (thanks to @gaojunran).