From f2ec9b1003fd844b22495690b2be87c0cb750410 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 26 Aug 2026 15:46:44 +0300 Subject: [PATCH 1/4] 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. --- CHANGELOG.md | 5 + packages/ui/src/components/chat/ChatInput.tsx | 6 ++ .../ui/src/components/chat/PermissionCard.tsx | 34 ++++++ packages/ui/src/components/layout/Header.tsx | 4 + .../ui/src/components/ui/CommandPalette.tsx | 102 +++++++++++++++++- packages/ui/src/components/views/DiffView.tsx | 31 ++++++ .../src/components/views/git/CommitInput.tsx | 8 ++ .../components/views/git/CommitSection.tsx | 3 + packages/ui/src/hooks/useKeyboardShortcuts.ts | 11 +- .../ui/src/lib/i18n/messages/de.settings.ts | 4 + packages/ui/src/lib/i18n/messages/de.ts | 6 ++ .../ui/src/lib/i18n/messages/en.settings.ts | 4 + packages/ui/src/lib/i18n/messages/en.ts | 6 ++ .../ui/src/lib/i18n/messages/es.settings.ts | 4 + packages/ui/src/lib/i18n/messages/es.ts | 6 ++ .../ui/src/lib/i18n/messages/fr.settings.ts | 4 + packages/ui/src/lib/i18n/messages/fr.ts | 6 ++ .../ui/src/lib/i18n/messages/ja.settings.ts | 4 + packages/ui/src/lib/i18n/messages/ja.ts | 6 ++ .../ui/src/lib/i18n/messages/ko.settings.ts | 4 + packages/ui/src/lib/i18n/messages/ko.ts | 6 ++ .../ui/src/lib/i18n/messages/pl.settings.ts | 4 + packages/ui/src/lib/i18n/messages/pl.ts | 6 ++ .../src/lib/i18n/messages/pt-BR.settings.ts | 4 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 6 ++ .../ui/src/lib/i18n/messages/uk.settings.ts | 4 + packages/ui/src/lib/i18n/messages/uk.ts | 6 ++ .../src/lib/i18n/messages/zh-CN.settings.ts | 4 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 6 ++ .../src/lib/i18n/messages/zh-TW.settings.ts | 4 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 6 ++ .../src/lib/sessionNavigationHistory.test.ts | 50 +++++++++ .../ui/src/lib/sessionNavigationHistory.ts | 61 +++++++++++ packages/ui/src/lib/sessionTabs.ts | 22 ++++ packages/ui/src/lib/shortcuts/config.ts | 28 +++++ packages/vscode/CHANGELOG.md | 1 + 36 files changed, 474 insertions(+), 2 deletions(-) create mode 100644 packages/ui/src/lib/sessionNavigationHistory.test.ts create mode 100644 packages/ui/src/lib/sessionNavigationHistory.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 89e2b34e..9688673e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ All notable changes to this project will be documented in this file. - **Session tabs (opt-in):** the web/desktop header can show your open sessions as browser-style tabs — turn them on in Settings → General → Navigation → Session tabs. Every session you open joins the strip, clicking a tab switches the whole workspace (chat, project, panels), and closing one (its × button, middle-click, or Alt+W — rebindable in Shortcuts) never touches the session itself. Tabs reorder by drag, scroll behind the header buttons when there are many and carry the sidebar's running/unread dot. Each tab has the full session menu — on the "..." button or right-click — plus Close other tabs; renaming works right in the tab. - **Keyboard shortcuts learned sequences** (thanks @ChangeHow): shortcuts can now be two steps — press Cmd/Ctrl+S, then a letter: P opens the draft's project picker, G its branch picker, L the session list. A held sequence shows no menu but forgives you: Escape or three quiet seconds cancel it, and typing into a text field never triggers one armed elsewhere. Dropdown menus and pickers now also answer Ctrl+N/Ctrl+P for down/up, the session switcher opens focused on your current session, and every tooltip and menu label shows the binding you actually have set, not the default. - **Keyboard shortcuts redesigned.** Defaults now follow one model: single chords for everyday actions, a two-step Cmd/Ctrl+K leader for open/go actions (K then P/G/L — project picker, branch picker, session list; T timeline, N prompt navigator, I services, H shortcut help, C theme), held Cmd/Ctrl+digit for header session tabs and held Cmd/Ctrl+Option+digit for context panel surfaces. Cmd/Ctrl+B now toggles the sidebar; when it's already open, the session-list shortcut jumps into its search. Rare actions moved into the command palette instead of carrying obscure default bindings, and custom bindings recorded under the old layout are reset once. Shortcuts also stopped requiring an English keyboard layout — bindings follow the physical key on non-Latin layouts (and Option-modified digits on macOS), including when recording custom ones. +- **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, and the newest pending card is the one that listens. The auto-accept toggle also got a shortcut (Cmd/Ctrl+K, A). +- Sessions: Cmd/Ctrl+Alt+Left/Right steps back and forward through the sessions you opened in this window, browser-history style; with session tabs enabled it moves between neighbouring tabs instead. Cmd/Ctrl+K, R renames the current session right in the header. +- Git: Cmd/Ctrl+Enter in the commit message box commits, like every git client. +- Diff: Alt+Down/Up jumps review to the next or previous changed file, expanding it if collapsed. +- Command palette: rarely used commands (pin session, copy session ID, multi-run launcher, archived sessions, notes, todos, status, theme, memory debug) are now found by typing but stay off the first screen, which keeps the initial list scroll-free. - Chat: comment on a reply — select text in a chat message (or in a rendered markdown preview in Files) and choose Comment to attach exactly that quote, with a source line range when it can be located, plus your note to the next message. The selection stays highlighted while you type, and the selection menu was restyled — Add to chat is now Add to input. - Diff: comment like a review — hovering a line shows a + button in the gutter; clicking it, clicking a line, or dragging across lines opens the comment editor for that line or range. The comment editor and saved-comment cards match the chat's comment style. - Composer: hovering or tapping a context chip above the input opens a stacked preview of everything attached, where a comment can be edited in place or an item removed before sending. diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index f088d16d..2cf2578b 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -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 = ({ t, ]); + useKeybind('toggle_permission_auto_accept', () => { + if (!isPermissionAutoAcceptInteractive) return false; + handlePermissionAutoAcceptToggle(); + }); + React.useEffect(() => { const pendingAbortBanner = Boolean(abortPromptSessionId) && abortPromptSessionId === currentSessionId; if (!prevWasAbortedRef.current && pendingAbortBanner && !showAbortStatus) { diff --git a/packages/ui/src/components/chat/PermissionCard.tsx b/packages/ui/src/components/chat/PermissionCard.tsx index cd63576b..851d70d3 100644 --- a/packages/ui/src/components/chat/PermissionCard.tsx +++ b/packages/ui/src/components/chat/PermissionCard.tsx @@ -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 = ({ } }; + 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 = ({ > Allow Once + {formatShortcutForDisplay('alt+enter')} {permission.always.length > 0 ? ( @@ -436,6 +468,7 @@ export const PermissionCard: React.FC = ({ > Always Allow + {formatShortcutForDisplay('alt+shift+enter')} )} @@ -459,6 +492,7 @@ export const PermissionCard: React.FC = ({ > Deny + {formatShortcutForDisplay('alt+backspace')} {isResponding && ( diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 09fad963..90573996 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -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); diff --git a/packages/ui/src/components/ui/CommandPalette.tsx b/packages/ui/src/components/ui/CommandPalette.tsx index 3acc62b4..5f2d5c86 100644 --- a/packages/ui/src/components/ui/CommandPalette.tsx +++ b/packages/ui/src/components/ui/CommandPalette.tsx @@ -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: , shortcutId: 'cycle_theme', @@ -243,6 +253,7 @@ export const CommandPalette: React.FC = () => { }, { id: 'open-status', + secondary: true, title: t('commandPalette.item.showOpenCodeStatus'), icon: , 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: , + 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: , + 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: , + searchText: t('commandPalette.item.openMultiRun'), + onSelect: run(() => { + setSessionSwitcherOpen(false); + openMultiRunLauncher(); + }), + }, + { + id: 'open-archive', + secondary: true, + title: t('commandPalette.item.openArchive'), + icon: , + searchText: t('commandPalette.item.openArchive'), + onSelect: run(() => { + setSessionSwitcherOpen(false); + setArchivePageOpen(true); + }), + }, + { + id: 'open-notes', + secondary: true, + title: t('commandPalette.item.openNotes'), + icon: , + 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: , + 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: , 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, diff --git a/packages/ui/src/components/views/DiffView.tsx b/packages/ui/src/components/views/DiffView.tsx index 28a15721..90e370a4 100644 --- a/packages/ui/src/components/views/DiffView.tsx +++ b/packages/ui/src/components/views/DiffView.tsx @@ -1768,6 +1768,37 @@ export const DiffView: React.FC = ({ 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'; diff --git a/packages/ui/src/components/views/git/CommitInput.tsx b/packages/ui/src/components/views/git/CommitInput.tsx index a724cb69..304629c7 100644 --- a/packages/ui/src/components/views/git/CommitInput.tsx +++ b/packages/ui/src/components/views/git/CommitInput.tsx @@ -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 = ({ value, onChange, + onSubmit, placeholder, disabled = false, hasTouchInput = false, @@ -58,6 +60,12 @@ export const CommitInput: React.FC = ({ 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} diff --git a/packages/ui/src/components/views/git/CommitSection.tsx b/packages/ui/src/components/views/git/CommitSection.tsx index 7c7ac7b8..a8cf0b34 100644 --- a/packages/ui/src/components/views/git/CommitSection.tsx +++ b/packages/ui/src/components/views/git/CommitSection.tsx @@ -68,6 +68,9 @@ export const CommitSection: React.FC = ({ { + if (canCommit && !isGeneratingMessage) onCommit(); + }} placeholder={t('gitView.commit.messagePlaceholder')} disabled={commitAction !== null} hasTouchInput={hasTouchInput} diff --git a/packages/ui/src/hooks/useKeyboardShortcuts.ts b/packages/ui/src/hooks/useKeyboardShortcuts.ts index 09ef0327..d04a1a9e 100644 --- a/packages/ui/src/hooks/useKeyboardShortcuts.ts +++ b/packages/ui/src/hooks/useKeyboardShortcuts.ts @@ -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) { diff --git a/packages/ui/src/lib/i18n/messages/de.settings.ts b/packages/ui/src/lib/i18n/messages/de.settings.ts index 6f8c5352..cf91d54d 100644 --- a/packages/ui/src/lib/i18n/messages/de.settings.ts +++ b/packages/ui/src/lib/i18n/messages/de.settings.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index adc8076b..815186a0 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index ef9d6265..8990d2ec 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 686698de..62ab1973 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index 63f646fb..6cf7245c 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -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", diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 031d9b82..4760f8a1 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -2448,6 +2448,12 @@ export const dict: Record = { "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", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index f4df769b..4e1af53b 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -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 l’approbation automatique', 'settings.openchamber.keyboardShortcuts.action.close_session_tab.label': 'Fermer l’onglet 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', diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 982cfd30..34528808 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index 63e54c6b..4f664809 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -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': '新しいミニチャットウィンドウ', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index d0c55cfc..daedcf79 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -2481,6 +2481,12 @@ export const dict: Record = { '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ステータス', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index a9ec57e3..32d73070 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -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 창', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index cba5551d..da927fe9 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -2482,6 +2482,12 @@ export const dict: Record = { '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 상태', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index b14793e1..6ca6a1f1 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 43d81bad..dcd5e758 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1455,6 +1455,12 @@ export const dict: Record = { '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', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index 6f5a4319..47ccf4f8 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -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", diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index cdc7c27c..4da1628e 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -2448,6 +2448,12 @@ export const dict: Record = { "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", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index 0af768cb..3e1ac7a9 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -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", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 8d17da5a..f7c6adcd 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -2448,6 +2448,12 @@ export const dict: Record = { "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", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index f898b951..8bebdd26 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -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 窗口', diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 3e0d8295..267b2ab7 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -2448,6 +2448,12 @@ export const dict: Record = { '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 状态', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index fe7d3153..c38af529 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -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 視窗', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 52468dab..f392a509 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -2452,6 +2452,12 @@ export const dict: Record = { '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 狀態', diff --git a/packages/ui/src/lib/sessionNavigationHistory.test.ts b/packages/ui/src/lib/sessionNavigationHistory.test.ts new file mode 100644 index 00000000..b9be6857 --- /dev/null +++ b/packages/ui/src/lib/sessionNavigationHistory.test.ts @@ -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'); + }); +}); diff --git a/packages/ui/src/lib/sessionNavigationHistory.ts b/packages/ui/src/lib/sessionNavigationHistory.ts new file mode 100644 index 00000000..6f4edf9a --- /dev/null +++ b/packages/ui/src/lib/sessionNavigationHistory.ts @@ -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; +}; diff --git a/packages/ui/src/lib/sessionTabs.ts b/packages/ui/src/lib/sessionTabs.ts index a1b490b0..62d22bfe 100644 --- a/packages/ui/src/lib/sessionTabs.ts +++ b/packages/ui/src/lib/sessionTabs.ts @@ -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; diff --git a/packages/ui/src/lib/shortcuts/config.ts b/packages/ui/src/lib/shortcuts/config.ts index 0cdf024b..c9d8dbe3 100644 --- a/packages/ui/src/lib/shortcuts/config.ts +++ b/packages/ui/src/lib/shortcuts/config.ts @@ -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', diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index d905260d..dbd27b32 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -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). From 2e49e442054f75f3e4498fc0c46ea4b28f4e5357 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 26 Aug 2026 16:18:15 +0300 Subject: [PATCH 2/4] feat(ui): let users hide context rail surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A trailing configure button on the rail — outside the sortable list and the digit shortcuts — opens a dialog that toggles each surface. The choice is stored as the hidden set so newly added surfaces appear for everyone, and the rail and the mod+alt+digit switcher share the same visibility filter, so badges and shortcuts always agree. Hidden surfaces keep their data and stay reachable from the command palette. --- .../components/layout/ContextPanelRail.tsx | 25 +++++- .../layout/ContextRailSurfacesDialog.tsx | 78 +++++++++++++++++++ packages/ui/src/hooks/useKeyboardShortcuts.ts | 1 + packages/ui/src/lib/i18n/messages/de.ts | 5 ++ packages/ui/src/lib/i18n/messages/en.ts | 5 ++ packages/ui/src/lib/i18n/messages/es.ts | 5 ++ packages/ui/src/lib/i18n/messages/fr.ts | 5 ++ packages/ui/src/lib/i18n/messages/ja.ts | 5 ++ packages/ui/src/lib/i18n/messages/ko.ts | 5 ++ packages/ui/src/lib/i18n/messages/pl.ts | 5 ++ packages/ui/src/lib/i18n/messages/pt-BR.ts | 5 ++ packages/ui/src/lib/i18n/messages/uk.ts | 5 ++ packages/ui/src/lib/i18n/messages/zh-CN.ts | 5 ++ packages/ui/src/lib/i18n/messages/zh-TW.ts | 5 ++ packages/ui/src/lib/surfaces/DOCUMENTATION.md | 5 +- packages/ui/src/lib/surfaces/registry.ts | 6 ++ packages/ui/src/stores/useUIStore.ts | 27 +++++++ 17 files changed, 195 insertions(+), 2 deletions(-) create mode 100644 packages/ui/src/components/layout/ContextRailSurfacesDialog.tsx diff --git a/packages/ui/src/components/layout/ContextPanelRail.tsx b/packages/ui/src/components/layout/ContextPanelRail.tsx index 6b3c147a..23308012 100644 --- a/packages/ui/src/components/layout/ContextPanelRail.tsx +++ b/packages/ui/src/components/layout/ContextPanelRail.tsx @@ -36,6 +36,7 @@ import { cn } from '@/lib/utils'; import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore'; import { useGitStatus } from '@/stores/useGitStore'; import { normalizeContextPanelDirectoryKey, useUIStore } from '@/stores/useUIStore'; +import { ContextRailSurfacesDialog } from './ContextRailSurfacesDialog'; const RAIL_TOOLTIP_DELAY_MS = 150; // Hold the surface-switch modifier for this long before revealing the order @@ -161,6 +162,7 @@ export const ContextPanelRail: React.FC = () => { const panelState = useUIStore((state) => (directoryKey ? state.contextPanelByDirectory[directoryKey] : undefined)); const workStatusPanelVisible = useUIStore((state) => state.workStatusPanelVisible); const contextRailOrder = useUIStore((state) => state.contextRailOrder); + const contextRailHiddenSurfaces = useUIStore((state) => state.contextRailHiddenSurfaces); const setContextRailOrder = useUIStore((state) => state.setContextRailOrder); const openContextSurface = useUIStore((state) => state.openContextSurface); const shortcutOverrides = useUIStore((state) => state.shortcutOverrides); @@ -256,12 +258,15 @@ export const ContextPanelRail: React.FC = () => { const surfaces = React.useMemo(() => { return getVisibleContextRailSurfaces({ railOrder: contextRailOrder, + hiddenSurfaces: contextRailHiddenSurfaces, planModeEnabled, isVSCode: isVSCodeRuntime(), screenWidth, tabs, }); - }, [contextRailOrder, planModeEnabled, screenWidth, tabs]); + }, [contextRailHiddenSurfaces, contextRailOrder, planModeEnabled, screenWidth, tabs]); + + const [isSurfacesDialogOpen, setIsSurfacesDialogOpen] = React.useState(false); const handleDragEnd = React.useCallback((event: DragEndEvent) => { const { active, over } = event; @@ -331,6 +336,24 @@ export const ContextPanelRail: React.FC = () => { })} + {/* Outside the sortable list on purpose: this button takes no digit, + cannot be dragged, and configures the rail rather than living on it. */} + + + + + + {t('contextRail.configure.open')} + + + ); }; diff --git a/packages/ui/src/components/layout/ContextRailSurfacesDialog.tsx b/packages/ui/src/components/layout/ContextRailSurfacesDialog.tsx new file mode 100644 index 00000000..211b465a --- /dev/null +++ b/packages/ui/src/components/layout/ContextRailSurfacesDialog.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { useI18n } from '@/lib/i18n'; +import { useUIStore } from '@/stores/useUIStore'; +import { SettingsCheckboxRow } from '@/components/sections/shared/SettingsSection'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { sortContextSurfaces } from '@/lib/surfaces/registry'; + +/** + * Which surfaces the context rail shows. Everything is on by default and the + * choice is stored as the *hidden* set, so a surface added in a later release + * appears for everyone rather than staying invisible to whoever had saved + * settings before it existed. Hidden surfaces also leave the digit shortcuts + * (the rail and the shortcut share one visibility filter). + */ +export const ContextRailSurfacesDialog: React.FC<{ + open: boolean; + onOpenChange: (open: boolean) => void; +}> = ({ open, onOpenChange }) => { + const { t } = useI18n(); + const contextRailOrder = useUIStore((state) => state.contextRailOrder); + const hidden = useUIStore((state) => state.contextRailHiddenSurfaces); + const setSurfaceVisible = useUIStore((state) => state.setContextRailSurfaceVisible); + const setHiddenSurfaces = useUIStore((state) => state.setContextRailHiddenSurfaces); + + // The full registry in the user's rail order — including surfaces a runtime + // filter currently drops, so a choice made on desktop is editable anywhere. + const surfaces = React.useMemo(() => sortContextSurfaces(contextRailOrder), [contextRailOrder]); + + const allVisible = hidden.length === 0; + const noneVisible = surfaces.every((surface) => hidden.includes(surface.id)); + + return ( + + + + {t('contextRail.configure.dialogTitle')} + {t('contextRail.configure.dialogDescription')} + + +
+ {surfaces.map((surface) => ( + setSurfaceVisible(surface.id, checked)} + label={t(surface.labelKey)} + ariaLabel={t(surface.labelKey)} + /> + ))} +
+ + {!allVisible ? ( +
+ {noneVisible ? ( + {t('contextRail.configure.noneWarning')} + ) : } + +
+ ) : null} +
+
+ ); +}; diff --git a/packages/ui/src/hooks/useKeyboardShortcuts.ts b/packages/ui/src/hooks/useKeyboardShortcuts.ts index d04a1a9e..0c99f3b0 100644 --- a/packages/ui/src/hooks/useKeyboardShortcuts.ts +++ b/packages/ui/src/hooks/useKeyboardShortcuts.ts @@ -499,6 +499,7 @@ export const useKeyboardShortcuts = () => { const panel = state.contextPanelByDirectory[directory]; const visibleSurfaces = getVisibleContextRailSurfaces({ railOrder: state.contextRailOrder, + hiddenSurfaces: state.contextRailHiddenSurfaces, planModeEnabled: useFeatureFlagsStore.getState().planModeEnabled, isVSCode: isVSCodeRuntime(), screenWidth: window.innerWidth, diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index 815186a0..64382772 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -2995,6 +2995,11 @@ export const dict = { 'gitView.pr.segment.comments': 'Kommentare', 'gitView.pr.comments.addAll': 'Alle hinzufügen', 'contextPanel.mode.pr': 'PR', + 'contextRail.configure.open': 'Panels konfigurieren', + 'contextRail.configure.dialogTitle': 'Leisten-Panels', + 'contextRail.configure.dialogDescription': 'Wähle, welche Panels die Leiste zeigt. Ausgeblendete Panels behalten ihre Daten und bleiben über die Befehlspalette erreichbar.', + 'contextRail.configure.showAll': 'Alle anzeigen', + 'contextRail.configure.noneWarning': 'Alle Panels sind ausgeblendet.', 'contextRail.aria.rail': 'Kontextleiste', 'contextPanel.editorEmpty.title': 'Kein Kontext ausgewählt', 'contextPanel.editorEmpty.description': 'Wählen Sie etwas aus der Seitenleiste aus, um Kontext anzuzeigen.', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 62ab1973..169f4c5f 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -1139,6 +1139,11 @@ export const dict = { 'contextPanel.mode.context': 'Context', 'contextPanel.mode.preview': 'Preview', 'contextPanel.mode.browser': 'Browser', + 'contextRail.configure.open': 'Configure panels', + 'contextRail.configure.dialogTitle': 'Rail panels', + 'contextRail.configure.dialogDescription': 'Choose which panels the rail shows. Hidden panels keep their data and stay reachable from the command palette.', + 'contextRail.configure.showAll': 'Show all', + 'contextRail.configure.noneWarning': 'All panels are hidden.', 'contextRail.aria.rail': 'Panel surfaces', 'contextPanel.editorEmpty.title': 'No file open', 'contextPanel.editorEmpty.description': 'Pick a file from the tree to start editing.', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 4760f8a1..04bbeaa6 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1140,6 +1140,11 @@ export const dict: Record = { "contextPanel.mode.context": "Contexto", "contextPanel.mode.preview": "Vista previa", "contextPanel.mode.browser": "Navegador", + "contextRail.configure.open": "Configurar paneles", + "contextRail.configure.dialogTitle": "Paneles de la barra", + "contextRail.configure.dialogDescription": "Elige qué paneles muestra la barra. Los paneles ocultos conservan sus datos y siguen accesibles desde la paleta de comandos.", + "contextRail.configure.showAll": "Mostrar todos", + "contextRail.configure.noneWarning": "Todos los paneles están ocultos.", "contextRail.aria.rail": "Superficies del panel", "contextPanel.editorEmpty.title": "Ningún archivo abierto", "contextPanel.editorEmpty.description": "Elige un archivo del árbol para empezar a editar.", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 34528808..b35feb84 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -959,6 +959,11 @@ export const dict = { 'contextPanel.mode.context': 'Contexte', 'contextPanel.mode.preview': 'Aperçu', 'contextPanel.mode.browser': 'Navigateur', + 'contextRail.configure.open': 'Configurer les panneaux', + 'contextRail.configure.dialogTitle': 'Panneaux de la barre', + 'contextRail.configure.dialogDescription': 'Choisissez les panneaux affichés par la barre. Les panneaux masqués conservent leurs données et restent accessibles via la palette de commandes.', + 'contextRail.configure.showAll': 'Tout afficher', + 'contextRail.configure.noneWarning': 'Tous les panneaux sont masqués.', 'contextRail.aria.rail': 'Surfaces du panneau', 'contextPanel.editorEmpty.title': 'Aucun fichier ouvert', 'contextPanel.editorEmpty.description': 'Choisissez un fichier dans l’arborescence pour commencer.', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index daedcf79..1eeb39e0 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -1136,6 +1136,11 @@ export const dict: Record = { 'contextPanel.mode.context': 'コンテキスト', 'contextPanel.mode.preview': 'プレビュー', 'contextPanel.mode.browser': 'ブラウザ', + 'contextRail.configure.open': 'パネルを設定', + 'contextRail.configure.dialogTitle': 'レールのパネル', + 'contextRail.configure.dialogDescription': 'レールに表示するパネルを選択します。非表示のパネルもデータは保持され、コマンドパレットから引き続き開けます。', + 'contextRail.configure.showAll': 'すべて表示', + 'contextRail.configure.noneWarning': 'すべてのパネルが非表示です。', 'contextRail.aria.rail': 'パネルサーフェス', 'contextPanel.editorEmpty.title': 'ファイルが開かれていません', 'contextPanel.editorEmpty.description': 'ツリーからファイルを選んで編集を始めましょう。', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index da927fe9..b2c9192a 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1140,6 +1140,11 @@ export const dict: Record = { 'contextPanel.mode.context': '컨텍스트', 'contextPanel.mode.preview': '미리보기', 'contextPanel.mode.browser': '브라우저', + 'contextRail.configure.open': '패널 구성', + 'contextRail.configure.dialogTitle': '레일 패널', + 'contextRail.configure.dialogDescription': '레일에 표시할 패널을 선택하세요. 숨긴 패널의 데이터는 유지되며 명령 팔레트에서 계속 열 수 있습니다.', + 'contextRail.configure.showAll': '모두 표시', + 'contextRail.configure.noneWarning': '모든 패널이 숨겨져 있습니다.', 'contextRail.aria.rail': '패널 서피스', 'contextPanel.editorEmpty.title': '열린 파일 없음', 'contextPanel.editorEmpty.description': '트리에서 파일을 선택해 편집을 시작하세요.', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index dcd5e758..25933ab1 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1478,6 +1478,11 @@ export const dict: Record = { 'contextPanel.mode.pr': 'Pull Request', 'contextPanel.mode.preview': 'Podgląd', 'contextPanel.mode.browser': 'Przeglądarka', + 'contextRail.configure.open': 'Konfiguruj panele', + 'contextRail.configure.dialogTitle': 'Panele paska', + 'contextRail.configure.dialogDescription': 'Wybierz, które panele pokazuje pasek. Ukryte panele zachowują dane i pozostają dostępne z palety poleceń.', + 'contextRail.configure.showAll': 'Pokaż wszystkie', + 'contextRail.configure.noneWarning': 'Wszystkie panele są ukryte.', 'contextRail.aria.rail': 'Powierzchnie panelu', 'contextPanel.editorEmpty.title': 'Brak otwartego pliku', 'contextPanel.editorEmpty.description': 'Wybierz plik z drzewa, aby rozpocząć edycję.', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 4da1628e..932b3ea3 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1140,6 +1140,11 @@ export const dict: Record = { "contextPanel.mode.context": "Contexto", "contextPanel.mode.preview": "Prévia", "contextPanel.mode.browser": "Navegador", + "contextRail.configure.open": "Configurar painéis", + "contextRail.configure.dialogTitle": "Painéis da barra", + "contextRail.configure.dialogDescription": "Escolha quais painéis a barra mostra. Painéis ocultos mantêm seus dados e continuam acessíveis pela paleta de comandos.", + "contextRail.configure.showAll": "Mostrar todos", + "contextRail.configure.noneWarning": "Todos os painéis estão ocultos.", "contextRail.aria.rail": "Superfícies do painel", "contextPanel.editorEmpty.title": "Nenhum arquivo aberto", "contextPanel.editorEmpty.description": "Escolha um arquivo na árvore para começar a editar.", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index f7c6adcd..56f101b1 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1140,6 +1140,11 @@ export const dict: Record = { "contextPanel.mode.context": "Контекст", "contextPanel.mode.preview": "Перегляд", "contextPanel.mode.browser": "Браузер", + "contextRail.configure.open": "Налаштувати панелі", + "contextRail.configure.dialogTitle": "Панелі рейки", + "contextRail.configure.dialogDescription": "Обери, які панелі показує рейка. Приховані панелі зберігають дані й доступні з палітри команд.", + "contextRail.configure.showAll": "Показати всі", + "contextRail.configure.noneWarning": "Усі панелі приховано.", "contextRail.aria.rail": "Поверхні панелі", "contextPanel.editorEmpty.title": "Файл не відкрито", "contextPanel.editorEmpty.description": "Виберіть файл у дереві, щоб почати редагування.", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 267b2ab7..2616b032 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1140,6 +1140,11 @@ export const dict: Record = { 'contextPanel.mode.context': '上下文', 'contextPanel.mode.preview': '预览', 'contextPanel.mode.browser': '浏览器', + 'contextRail.configure.open': '配置面板', + 'contextRail.configure.dialogTitle': '侧栏面板', + 'contextRail.configure.dialogDescription': '选择侧栏显示哪些面板。隐藏的面板会保留数据,仍可通过命令面板打开。', + 'contextRail.configure.showAll': '全部显示', + 'contextRail.configure.noneWarning': '所有面板均已隐藏。', 'contextRail.aria.rail': '面板界面', 'contextPanel.editorEmpty.title': '未打开文件', 'contextPanel.editorEmpty.description': '从文件树中选择一个文件开始编辑。', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index f392a509..4dc07807 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1152,6 +1152,11 @@ export const dict: Record = { 'contextPanel.mode.context': '上下文', 'contextPanel.mode.preview': '預覽', 'contextPanel.mode.browser': '瀏覽器', + 'contextRail.configure.open': '設定面板', + 'contextRail.configure.dialogTitle': '側欄面板', + 'contextRail.configure.dialogDescription': '選擇側欄顯示哪些面板。隱藏的面板會保留資料,仍可透過命令面板開啟。', + 'contextRail.configure.showAll': '全部顯示', + 'contextRail.configure.noneWarning': '所有面板皆已隱藏。', 'contextRail.aria.rail': '面板介面', 'contextPanel.editorEmpty.title': '未開啟檔案', 'contextPanel.editorEmpty.description': '從檔案樹選擇檔案開始編輯。', diff --git a/packages/ui/src/lib/surfaces/DOCUMENTATION.md b/packages/ui/src/lib/surfaces/DOCUMENTATION.md index 9a584ce5..fd750557 100644 --- a/packages/ui/src/lib/surfaces/DOCUMENTATION.md +++ b/packages/ui/src/lib/surfaces/DOCUMENTATION.md @@ -22,7 +22,10 @@ edge (`components/layout/ContextPanelRail.tsx`) and rendered by 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, + `lib/shortcuts`): it drops surfaces the user hid + (`useUIStore.contextRailHiddenSurfaces`, edited from the rail's trailing + configure button — `ContextRailSurfacesDialog`), 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 diff --git a/packages/ui/src/lib/surfaces/registry.ts b/packages/ui/src/lib/surfaces/registry.ts index 730389e8..21d2992f 100644 --- a/packages/ui/src/lib/surfaces/registry.ts +++ b/packages/ui/src/lib/surfaces/registry.ts @@ -187,6 +187,9 @@ export const sortContextSurfaces = (railOrder: readonly string[]): ContextSurfac type VisibleRailSurfacesOptions = { railOrder: readonly string[]; + /** Surfaces the user chose to hide from the rail (and from the digit + shortcuts, which share this filter). */ + hiddenSurfaces?: readonly string[]; planModeEnabled: boolean; isVSCode: boolean; screenWidth: number; @@ -203,6 +206,9 @@ type VisibleRailSurfacesOptions = { */ export const getVisibleContextRailSurfaces = (options: VisibleRailSurfacesOptions): ContextSurfaceDescriptor[] => { return sortContextSurfaces(options.railOrder).filter((surface) => { + if (options.hiddenSurfaces?.includes(surface.id)) { + return false; + } if (surface.id === 'plan' && !options.planModeEnabled) { return false; } diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index c377f27b..b5254f2e 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -607,6 +607,9 @@ interface UIStore { hasManuallyResizedLeftSidebar: boolean; contextPanelByDirectory: Record; contextRailOrder: string[]; + /** Surface ids the user hid from the context rail; stored as the hidden set + so surfaces added later appear for everyone. */ + contextRailHiddenSurfaces: string[]; contextEditorTreeVisible: boolean; contextEditorTreeWidth: number; notesPanelHeight: number; @@ -828,6 +831,8 @@ interface UIStore { setWorkStatusOverlayOpen: (open: boolean) => void; setWorkStatusSectionVisible: (sectionId: string, visible: boolean) => void; setWorkStatusHiddenSections: (sectionIds: string[]) => void; + setContextRailSurfaceVisible: (surfaceId: string, visible: boolean) => void; + setContextRailHiddenSurfaces: (surfaceIds: string[]) => void; setSessionSwitcherOpen: (open: boolean) => void; setSessionDropdownOpen: (open: boolean) => void; setPendingDiffFile: (filePath: string | null, staged?: boolean, scope?: PendingDiffScope | null) => void; @@ -990,6 +995,7 @@ export const useUIStore = create()( hasManuallyResizedLeftSidebar: false, contextPanelByDirectory: {}, contextRailOrder: [], + contextRailHiddenSurfaces: [], contextEditorTreeVisible: true, contextEditorTreeWidth: 240, notesPanelHeight: 112, @@ -1599,6 +1605,23 @@ export const useUIStore = create()( set({ workStatusHiddenSections: [...new Set(sectionIds)] }); }, + setContextRailSurfaceVisible: (surfaceId, visible) => { + set((state) => { + const hidden = state.contextRailHiddenSurfaces; + const isHidden = hidden.includes(surfaceId); + if (visible === !isHidden) return state; + return { + contextRailHiddenSurfaces: visible + ? hidden.filter((entry) => entry !== surfaceId) + : [...hidden, surfaceId], + }; + }); + }, + + setContextRailHiddenSurfaces: (surfaceIds) => { + set({ contextRailHiddenSurfaces: [...new Set(surfaceIds)] }); + }, + setSessionSwitcherOpen: (open) => { if (get().isSessionSwitcherOpen === open) { @@ -2627,6 +2650,9 @@ export const useUIStore = create()( state.autoSaveEnabled = true; } + state.contextRailHiddenSurfaces = Array.isArray(state.contextRailHiddenSurfaces) + ? (state.contextRailHiddenSurfaces as unknown[]).filter((id): id is string => typeof id === 'string' && id.trim() !== '') + : []; state.contextRailOrder = Array.isArray(state.contextRailOrder) ? (state.contextRailOrder as unknown[]).filter((id): id is string => typeof id === 'string' && id.trim() !== '') : []; @@ -2639,6 +2665,7 @@ export const useUIStore = create()( sidebarWidth: state.sidebarWidth, contextPanelByDirectory: state.contextPanelByDirectory, contextRailOrder: state.contextRailOrder, + contextRailHiddenSurfaces: state.contextRailHiddenSurfaces, contextEditorTreeVisible: state.contextEditorTreeVisible, contextEditorTreeWidth: state.contextEditorTreeWidth, notesPanelHeight: state.notesPanelHeight, From 087c148b2eadcc6e8b19b252e97f51839cad3bdd Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 26 Aug 2026 16:42:42 +0300 Subject: [PATCH 3/4] fix(chat): rescue stranded viewports and settle navigation jumps Opening a session (or any relayout that shrinks off-screen size estimates) could leave the viewport in a phantom tail below the measured content, with every row out of reach above; a totalSize-change check now detects the fully blank viewport and returns to the real end, and settling a width resize re-asserts the end for a reader who was on it. Prompt-rail and message jumps land on estimated offsets that shift as the target mounts and measures; a short settle loop now re-aligns the target until layout rests, backing off on the first user gesture. --- .../ui/src/components/chat/MessageList.tsx | 67 +++++++++++++++++-- .../ui/src/hooks/useChatTimelineScroll.ts | 43 +++++++++++- 2 files changed, 102 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index 4197f067..07ce00a3 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -1534,6 +1534,54 @@ const MessageList = React.forwardRef(({ return true; }, [allEntries.length]); + // A navigation scroll lands on estimates: an unmounted target teleports + // to its estimated offset, and even a mounted one drifts when neighbours + // finish measuring a frame later. This settle loop re-aligns the target to + // the requested viewport position until the layout stops moving, and backs + // off the moment the user touches the scroll. + const settleNavigationTarget = React.useCallback(( + findElement: () => HTMLElement | null, + desiredOffsetTop: number, + ) => { + const container = resolveScrollContainer(); + if (!container || typeof window === 'undefined') { + return; + } + let frames = 0; + let stable = 0; + let cancelled = false; + const cancelOnUserInput = () => { + cancelled = true; + container.removeEventListener('touchstart', cancelOnUserInput); + container.removeEventListener('wheel', cancelOnUserInput); + }; + container.addEventListener('touchstart', cancelOnUserInput, { passive: true }); + container.addEventListener('wheel', cancelOnUserInput, { passive: true }); + const step = () => { + if (cancelled) return; + const element = findElement(); + if (element) { + const delta = element.getBoundingClientRect().top + - container.getBoundingClientRect().top + - desiredOffsetTop; + if (Math.abs(delta) > 0.5) { + container.scrollTop += delta; + stable = 0; + } else { + stable += 1; + } + } + frames += 1; + if (stable >= ANCHOR_HOLD_STABLE_FRAMES || frames >= ANCHOR_HOLD_MAX_FRAMES) { + container.removeEventListener('touchstart', cancelOnUserInput); + container.removeEventListener('wheel', cancelOnUserInput); + return; + } + window.requestAnimationFrame(step); + }; + window.requestAnimationFrame(step); + }, [resolveScrollContainer]); + const scrollMessageElementIntoView = React.useCallback((messageId: string, behavior: ScrollBehavior = 'auto') => { const container = resolveScrollContainer(); if (!container) { @@ -1569,14 +1617,19 @@ const MessageList = React.forwardRef(({ if (!container) { return false; } - const turnElement = container.querySelector(`[data-turn-id="${turnId}"]`); + const findTurnElement = () => container.querySelector(`[data-turn-id="${turnId}"]`); + const turnElement = findTurnElement(); if (turnElement) { turnElement.scrollIntoView({ behavior, block: 'start' }); + if (behavior !== 'smooth') settleNavigationTarget(findTurnElement, 0); return true; } - - return scrollHistoryIndexIntoView(index); + if (!scrollHistoryIndexIntoView(index)) { + return false; + } + if (behavior !== 'smooth') settleNavigationTarget(findTurnElement, 0); + return true; }, scrollToMessageId: (messageId: string, options?: { behavior?: ScrollBehavior }) => { @@ -1586,8 +1639,12 @@ const MessageList = React.forwardRef(({ return false; } - return scrollMessageElementIntoView(messageId, behavior) + const didScroll = scrollMessageElementIntoView(messageId, behavior) || scrollHistoryIndexIntoView(index); + if (didScroll && behavior !== 'smooth') { + settleNavigationTarget(() => findMessageElement(messageId), 50); + } + return didScroll; }, holdViewportAnchor: (anchor) => { @@ -1730,7 +1787,7 @@ const MessageList = React.forwardRef(({ return () => { objectRef.current = null; }; - }, [findMessageElement, historyEntries.length, messageIndexMap, resolveScrollContainer, scrollHistoryIndexIntoView, scrollMessageElementIntoView, turnIndexMap, ref]); + }, [findMessageElement, historyEntries.length, messageIndexMap, resolveScrollContainer, scrollHistoryIndexIntoView, scrollMessageElementIntoView, settleNavigationTarget, turnIndexMap, ref]); const anchoredEndSpace = React.useMemo(() => { const resolved = resolveChatListAnchoredEndSpace( diff --git a/packages/ui/src/hooks/useChatTimelineScroll.ts b/packages/ui/src/hooks/useChatTimelineScroll.ts index 161d9eca..544d02af 100644 --- a/packages/ui/src/hooks/useChatTimelineScroll.ts +++ b/packages/ui/src/hooks/useChatTimelineScroll.ts @@ -7,6 +7,7 @@ import { useUIStore } from '@/stores/useUIStore'; import { CHAT_LIST_ANCHOR_OFFSET, getAnchoredTurnMetrics, + getRowBottom, resolveTimelineIsAtEnd, type TimelineListMeasurementState, type TimelineScrollMode, @@ -129,6 +130,8 @@ export const useChatTimelineScroll = ({ // True after a real gesture until an explicit opt back in; drives the // overlay scrollbar suppression instead of the anchor's mere existence. const [userOwnsScroll, setUserOwnsScroll] = React.useState(false); + const userOwnsScrollRef = React.useRef(userOwnsScroll); + userOwnsScrollRef.current = userOwnsScroll; const modeRef = React.useRef('following-end'); const isAtEndRef = React.useRef(true); @@ -547,9 +550,12 @@ export const useChatTimelineScroll = ({ // per-frame row re-measure and the pinned viewport shakes. Corrections // stand down for the whole resize and the visible content is held by the // list's size compensation instead. Deliberately NO snap back to the end - // afterwards: a slow drag settles repeatedly, and each snap reads as the - // very jump this suspension removes — geometry changed, staying where the - // reader is beats re-asserting the edge. + // afterwards for a mid-conversation reader: a slow drag settles + // repeatedly, and each snap reads as the very jump this suspension + // removes. A reader who WAS at the end is the exception — after rows + // re-wrap, stale cached sizes can leave a large phantom gap below the + // last row, so re-asserting the end once on settle is what "staying + // where the reader is" means for them. const widthResizingRef = React.useRef(false); React.useEffect(() => { if (!scrollNode || typeof ResizeObserver === 'undefined') return; @@ -569,6 +575,9 @@ export const useChatTimelineScroll = ({ quietTimer = setTimeout(() => { quietTimer = null; widthResizingRef.current = false; + if (isAtEndRef.current && pendingAnchorRef.current === null) { + void listRef.current?.scrollToEnd({ animated: false }); + } }, 350); }); observer.observe(scrollNode); @@ -580,6 +589,34 @@ export const useChatTimelineScroll = ({ const onTimelineDataChange = React.useCallback(() => { if (widthResizingRef.current) return; + + // Stranded-viewport rescue, independent of any follow mode or + // preference: when off-screen size estimates settle smaller than + // estimated, the measured content can end ABOVE the viewport while + // the scroll offset stays at the stale end — the reader faces a blank + // phantom tail with every row out of reach above. That state is never + // intentional, so it is corrected even when auto-follow is off. Only + // a fully blank viewport qualifies; partial visibility is left alone. + if (!userOwnsScrollRef.current) { + const list = listRef.current; + if (list) { + const state = list.getState(); + const lastIndex = state.data.length - 1; + const lastBottom = lastIndex >= 0 ? getRowBottom(state, lastIndex) : null; + if (lastBottom !== null && state.scroll > lastBottom) { + const visibleLength = Math.max( + 0, + state.scrollLength - composerOverlayHeightRef.current - CHAT_LIST_ANCHOR_OFFSET, + ); + void list.scrollToOffset({ + offset: Math.max(0, lastBottom - visibleLength), + animated: false, + }); + return; + } + } + } + if (!streamingAutoFollowEnabledRef.current) return; if (!isLiveFollowActive()) return; From ba3a604e9ea2cb5048b308e25f0efdfdda4dec83 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 26 Aug 2026 16:49:21 +0300 Subject: [PATCH 4/4] fix(chat): offer the scroll pill when growth leaves the reader behind With streaming auto-follow off nothing moves the viewport, so a growing reply slides below the composer without a single scroll event and the at-end transition that shows the pill never fires. Content growth now doubles as the signal: once the measured last row extends past the visible area by the follow re-arm threshold, the end state clears and the pill is scheduled. --- .../ui/src/hooks/useChatTimelineScroll.ts | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/hooks/useChatTimelineScroll.ts b/packages/ui/src/hooks/useChatTimelineScroll.ts index 544d02af..46da713f 100644 --- a/packages/ui/src/hooks/useChatTimelineScroll.ts +++ b/packages/ui/src/hooks/useChatTimelineScroll.ts @@ -9,6 +9,7 @@ import { getAnchoredTurnMetrics, getRowBottom, resolveTimelineIsAtEnd, + TIMELINE_FOLLOW_REARM_THRESHOLD_PX, type TimelineListMeasurementState, type TimelineScrollMode, } from '@/components/chat/lib/scroll/timelineScrollAnchoring'; @@ -617,7 +618,29 @@ export const useChatTimelineScroll = ({ } } - if (!streamingAutoFollowEnabledRef.current) return; + if (!streamingAutoFollowEnabledRef.current) { + // With auto-follow off nothing moves the viewport, so a growing + // reply slides below the visible area without a single scroll + // event — and the at-end transition that offers the pill never + // fires. Content growth is the signal here: once the real last + // row extends past what the composer leaves visible, the reader + // is factually behind and the pill must say so. + const list = listRef.current; + if (list && isAtEndRef.current) { + const state = list.getState(); + const lastIndex = state.data.length - 1; + const lastBottom = lastIndex >= 0 ? getRowBottom(state, lastIndex) : null; + if (lastBottom !== null) { + const visibleBottom = state.scroll + state.scrollLength - composerOverlayHeightRef.current; + if (lastBottom - visibleBottom > TIMELINE_FOLLOW_REARM_THRESHOLD_PX) { + isAtEndRef.current = false; + setIsPinned(false); + scheduleShowScrollButton(); + } + } + } + return; + } if (!isLiveFollowActive()) return; // Since @legendapp/list 3.3.x, maintainScrollAtEnd follows content @@ -674,7 +697,7 @@ export const useChatTimelineScroll = ({ }); }); - }, [isLiveFollowActive]); + }, [isLiveFollowActive, scheduleShowScrollButton]); // The streaming tail grows inside one row without changing the entries // array, so data-change callbacks are silent for the entire stream. The