Merge origin/main into deferred OpenCode restart branch.

Adopt main's providerAuth helpers (OAuth index preservation, OAuth-only API
key hiding, always-load auth methods) while keeping deferred Apply & Restart
for provider mutations.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-08-04 13:29:46 +00:00
co-authored by Serhii Dziupin
133 changed files with 6361 additions and 714 deletions
@@ -0,0 +1,298 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
const focusChatInputCalls: number[] = [];
const pendingInputCalls: Array<{ text: string | null; mode?: string }> = [];
const activeMainTabCalls: string[] = [];
const sessionSwitcherCalls: boolean[] = [];
const codeMirrorDispatches: Array<{ selection: { anchor: number } }> = [];
type MockCodeMirrorView = {
state: {
selection: { main: { from: number; to: number } };
sliceDoc: (from: number, to: number) => string;
};
dispatch: (transaction: { selection: { anchor: number } }) => void;
};
let codeMirrorView: MockCodeMirrorView | null = null;
mock.module('@codemirror/view', () => ({
EditorView: {
findFromDOM: () => codeMirrorView,
},
}));
mock.module('@/components/chat/composer/editor/dom', () => ({
focusChatInput: () => {
focusChatInputCalls.push(1);
},
}));
mock.module('@/sync/input-store', () => ({
useInputStore: {
getState: () => ({
setPendingInputText: (text: string | null, mode?: string) => {
pendingInputCalls.push({ text, mode });
},
}),
},
}));
mock.module('@/stores/useUIStore', () => ({
useUIStore: {
getState: () => ({
setActiveMainTab: (tab: string) => {
activeMainTabCalls.push(tab);
},
setSessionSwitcherOpen: (open: boolean) => {
sessionSwitcherCalls.push(open);
},
}),
},
}));
const { addSelectionToChat, captureSelectionMarkdownForChat } = await import('./addSelectionToChat');
const originalDocument = globalThis.document;
const originalWindow = globalThis.window;
const installSelectionEnvironment = (options: {
activeElement?: Element | null;
focusedCodeMirror?: Element | null;
selection?: Selection | null;
} = {}) => {
const {
activeElement = null,
focusedCodeMirror = null,
selection = null,
} = options;
const documentLike = {
activeElement,
querySelector: (selector: string) => {
if (selector === '.cm-editor.cm-focused') {
return focusedCodeMirror;
}
return null;
},
};
const windowLike = {
getSelection: () => selection,
};
Object.defineProperty(globalThis, 'document', { value: documentLike, configurable: true });
Object.defineProperty(globalThis, 'window', { value: windowLike, configurable: true });
};
const clearCalls = () => {
focusChatInputCalls.length = 0;
pendingInputCalls.length = 0;
activeMainTabCalls.length = 0;
sessionSwitcherCalls.length = 0;
codeMirrorDispatches.length = 0;
codeMirrorView = null;
};
afterEach(() => {
Object.defineProperty(globalThis, 'document', { value: originalDocument, configurable: true });
Object.defineProperty(globalThis, 'window', { value: originalWindow, configurable: true });
});
describe('captureSelectionMarkdownForChat', () => {
beforeEach(() => {
clearCalls();
});
test('returns null when nothing is selected', () => {
installSelectionEnvironment();
expect(captureSelectionMarkdownForChat()).toBeNull();
});
test('captures a textarea selection outside the composer and collapses it', () => {
const textarea = {
tagName: 'TEXTAREA',
value: 'alpha beta gamma',
selectionStart: 6,
selectionEnd: 10,
closest: () => null,
} as unknown as HTMLTextAreaElement;
installSelectionEnvironment({ activeElement: textarea });
expect(captureSelectionMarkdownForChat()).toBe('```md\nbeta\n```');
expect(textarea.selectionStart).toBe(10);
expect(textarea.selectionEnd).toBe(10);
});
test('ignores selections inside the chat composer', () => {
const textarea = {
tagName: 'TEXTAREA',
value: 'draft text',
selectionStart: 0,
selectionEnd: 5,
closest: (selector: string) => (selector === '[data-chat-input="true"]' ? textarea : null),
} as unknown as HTMLTextAreaElement;
installSelectionEnvironment({ activeElement: textarea });
expect(captureSelectionMarkdownForChat()).toBeNull();
});
test('captures a focused CodeMirror selection outside the composer and collapses it', () => {
const focusedEditor = {
closest: () => null,
} as unknown as HTMLElement;
codeMirrorView = {
state: {
selection: { main: { from: 4, to: 11 } },
sliceDoc: (from: number, to: number) => 'const x'.slice(0, to - from),
},
dispatch: (transaction) => {
codeMirrorDispatches.push(transaction);
codeMirrorView!.state.selection.main = {
from: transaction.selection.anchor,
to: transaction.selection.anchor,
};
},
};
// sliceDoc should return the selected slice; use explicit text instead of slice math.
codeMirrorView.state.sliceDoc = () => 'const x';
installSelectionEnvironment({ focusedCodeMirror: focusedEditor });
expect(captureSelectionMarkdownForChat()).toBe('```\nconst x\n```');
expect(codeMirrorDispatches).toEqual([{ selection: { anchor: 11 } }]);
expect(codeMirrorView.state.selection.main).toEqual({ from: 11, to: 11 });
});
test('ignores a focused CodeMirror editor inside the chat composer', () => {
const focusedEditor = {
closest: (selector: string) => (selector === '[data-chat-input="true"]' ? focusedEditor : null),
} as unknown as HTMLElement;
codeMirrorView = {
state: {
selection: { main: { from: 0, to: 5 } },
sliceDoc: () => 'draft',
},
dispatch: (transaction) => {
codeMirrorDispatches.push(transaction);
},
};
installSelectionEnvironment({ focusedCodeMirror: focusedEditor });
expect(captureSelectionMarkdownForChat()).toBeNull();
expect(codeMirrorDispatches).toEqual([]);
});
test('captures a DOM selection from chat-message content and clears it', () => {
const parent = {
closest: (selector: string) => (selector === 'pre code' ? null : null),
};
const textNode = {
nodeType: 3,
parentElement: parent,
};
let rangeCount = 1;
let collapsed = false;
const selection = {
get rangeCount() {
return rangeCount;
},
get isCollapsed() {
return collapsed;
},
toString: () => 'Hello world',
getRangeAt: () => ({
commonAncestorContainer: textNode,
startContainer: textNode,
endContainer: textNode,
cloneContents: () => ({ childNodes: [] }),
}),
removeAllRanges: () => {
rangeCount = 0;
collapsed = true;
},
} as unknown as Selection;
installSelectionEnvironment({ selection });
expect(captureSelectionMarkdownForChat()).toBe('```md\nHello world\n```');
expect(selection.rangeCount).toBe(0);
expect(selection.isCollapsed).toBe(true);
});
test('ignores a DOM selection inside the chat composer', () => {
const composerHost = {};
const parent = {
closest: (selector: string) => (selector === '[data-chat-input="true"]' ? composerHost : null),
};
const textNode = {
nodeType: 3,
parentElement: parent,
};
const selection = {
rangeCount: 1,
isCollapsed: false,
toString: () => 'draft',
getRangeAt: () => ({
commonAncestorContainer: textNode,
startContainer: textNode,
endContainer: textNode,
cloneContents: () => ({ childNodes: [] }),
}),
removeAllRanges: () => undefined,
} as unknown as Selection;
installSelectionEnvironment({ selection });
expect(captureSelectionMarkdownForChat()).toBeNull();
});
});
describe('addSelectionToChat', () => {
beforeEach(() => {
clearCalls();
});
test('appends captured selection and focuses chat input', async () => {
const textarea = {
tagName: 'TEXTAREA',
value: 'selected',
selectionStart: 0,
selectionEnd: 8,
closest: () => null,
} as unknown as HTMLTextAreaElement;
installSelectionEnvironment({ activeElement: textarea });
expect(addSelectionToChat()).toBe(true);
expect(activeMainTabCalls).toEqual(['chat']);
expect(sessionSwitcherCalls).toEqual([false]);
expect(pendingInputCalls).toEqual([{ text: '```md\nselected\n```', mode: 'append' }]);
await Promise.resolve();
expect(focusChatInputCalls.length).toBe(1);
});
test('second capture after textarea collapse does not append again', () => {
const textarea = {
tagName: 'TEXTAREA',
value: 'selected',
selectionStart: 0,
selectionEnd: 8,
closest: () => null,
} as unknown as HTMLTextAreaElement;
installSelectionEnvironment({ activeElement: textarea });
expect(addSelectionToChat()).toBe(true);
expect(addSelectionToChat()).toBe(false);
expect(pendingInputCalls).toEqual([{ text: '```md\nselected\n```', mode: 'append' }]);
});
test('focuses chat input when nothing is selected', async () => {
installSelectionEnvironment();
expect(addSelectionToChat()).toBe(false);
expect(pendingInputCalls).toEqual([]);
expect(activeMainTabCalls).toEqual(['chat']);
await Promise.resolve();
expect(focusChatInputCalls.length).toBe(1);
});
});
+166
View File
@@ -0,0 +1,166 @@
import { EditorView } from '@codemirror/view';
import { focusChatInput } from '@/components/chat/composer/editor/dom';
import {
formatCodeSelectionMarkdown,
rangeToMarkdown,
trimSelectionValue,
wrapMarkdownSelectionForChat,
} from '@/components/chat/message/selectionMarkdown';
import { useInputStore } from '@/sync/input-store';
import { useUIStore } from '@/stores/useUIStore';
const CHAT_INPUT_HOST_SELECTOR = '[data-chat-input="true"]';
const isInsideChatComposer = (node: Node | null): boolean => {
if (!node) {
return false;
}
const asElement = node as Element;
const element = typeof asElement.closest === 'function'
? asElement
: (node as Node).parentElement;
return Boolean(element?.closest(CHAT_INPUT_HOST_SELECTOR));
};
const readTextControlSelection = (element: Element): string | null => {
if (isInsideChatComposer(element)) {
return null;
}
const tag = element.tagName?.toLowerCase();
if (tag === 'textarea') {
const control = element as HTMLTextAreaElement;
const start = control.selectionStart ?? 0;
const end = control.selectionEnd ?? 0;
const text = trimSelectionValue(control.value.slice(start, end));
if (!text) {
return null;
}
// Collapse so a duplicate menu delivery cannot append the same range twice.
control.selectionStart = end;
control.selectionEnd = end;
return text;
}
if (tag === 'input') {
const control = element as HTMLInputElement;
const type = control.type?.toLowerCase() ?? 'text';
if (!['text', 'search', 'url', 'tel', 'password'].includes(type)) {
return null;
}
const start = control.selectionStart ?? 0;
const end = control.selectionEnd ?? 0;
const text = trimSelectionValue(control.value.slice(start, end));
if (!text) {
return null;
}
control.selectionStart = end;
control.selectionEnd = end;
return text;
}
return null;
};
const captureActiveElementSelection = (): string | null => {
if (typeof document === 'undefined') {
return null;
}
const activeElement = document.activeElement;
if (!activeElement || typeof (activeElement as Element).tagName !== 'string') {
return null;
}
const text = readTextControlSelection(activeElement as Element);
return text ? wrapMarkdownSelectionForChat(text) : null;
};
const captureCodeMirrorSelection = (): string | null => {
if (typeof document === 'undefined') {
return null;
}
const focusedEditor = document.querySelector<HTMLElement>('.cm-editor.cm-focused');
if (!focusedEditor || isInsideChatComposer(focusedEditor)) {
return null;
}
const view = EditorView.findFromDOM(focusedEditor);
if (!view) {
return null;
}
const { from, to } = view.state.selection.main;
if (from === to) {
return null;
}
const text = trimSelectionValue(view.state.sliceDoc(from, to));
if (!text) {
return null;
}
view.dispatch({
selection: { anchor: to },
});
return formatCodeSelectionMarkdown(text);
};
const captureDomSelection = (): string | null => {
if (typeof window === 'undefined') {
return null;
}
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
return null;
}
const range = selection.getRangeAt(0);
if (isInsideChatComposer(range.commonAncestorContainer)) {
return null;
}
const plainText = trimSelectionValue(selection.toString());
if (!plainText) {
return null;
}
const markdown = rangeToMarkdown(range, plainText);
selection.removeAllRanges();
return wrapMarkdownSelectionForChat(markdown);
};
/**
* Capture the current non-composer selection as chat-ready markdown.
* Returns null when nothing usable is selected.
*/
export const captureSelectionMarkdownForChat = (): string | null => {
return captureCodeMirrorSelection()
?? captureActiveElementSelection()
?? captureDomSelection();
};
/**
* Append the current selection to the chat composer.
* When nothing is selected, focuses the chat input (Cursor-style Ctrl/Cmd+L).
* Returns true when selected text was appended.
*/
export const addSelectionToChat = (): boolean => {
const markdown = captureSelectionMarkdownForChat();
useUIStore.getState().setActiveMainTab('chat');
useUIStore.getState().setSessionSwitcherOpen(false);
if (markdown) {
useInputStore.getState().setPendingInputText(markdown, 'append');
}
queueMicrotask(() => {
focusChatInput();
});
return markdown !== null;
};
+5 -1
View File
@@ -441,7 +441,11 @@ export const debugUtils = {
const sources = {
attachment,
worktreeMetadata,
authoritative: owningStoreDirectory ?? recordDirectory,
// Record first, matching the resolver: holding a session proves
// containment, not ownership, so the parent repository holds its
// worktrees' sessions too. Reporting membership first made this
// diagnostic contradict the routing it exists to explain.
authoritative: recordDirectory ?? owningStoreDirectory,
selected,
remembered: remembered.runtime,
};
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, test } from 'bun:test';
import { isBrowserClientRuntime } from './desktop';
describe('browser client runtime', () => {
test('uses browser file behavior only outside the Electron shell', () => {
expect(isBrowserClientRuntime('web', false)).toBe(true);
expect(isBrowserClientRuntime('web', true)).toBe(false);
});
test('keeps desktop and VS Code runtime behavior out of browser-only flows', () => {
expect(isBrowserClientRuntime('desktop', false)).toBe(false);
expect(isBrowserClientRuntime('vscode', false)).toBe(false);
});
});
+10 -1
View File
@@ -1,4 +1,4 @@
import type { ProjectEntry, TerminalShell } from '@/lib/api/types';
import type { ProjectEntry, RuntimeAPIs, TerminalShell } from '@/lib/api/types';
import { getInjectedBootOutcome } from '@/lib/desktopBoot';
import type { DraftStarterRef } from '@/lib/draftStarters';
import type { MobileKeyboardMode } from '@/lib/mobileKeyboardMode';
@@ -562,6 +562,15 @@ export const isWebRuntime = (): boolean => {
return !isVSCodeRuntime();
};
/**
* Electron reuses the web RuntimeAPIs implementation, so distinguish a browser
* client from an Electron renderer with both the runtime descriptor and shell.
*/
export const isBrowserClientRuntime = (
platform: RuntimeAPIs['runtime']['platform'],
desktopShell = isDesktopShell(),
): boolean => platform === 'web' && !desktopShell;
export const getDesktopHomeDirectory = async (): Promise<string | null> => {
if (typeof window !== 'undefined') {
const embedded = window.__OPENCHAMBER_HOME__;
@@ -1050,6 +1050,7 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.action.open_settings.label': 'Einstellungen öffnen',
'settings.openchamber.keyboardShortcuts.action.toggle_terminal.label': 'Terminal-Dock umschalten',
'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': 'Terminal erweitert umschalten',
'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label': 'Auswahl zum Chat hinzufügen',
'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': 'Seitenleiste umschalten',
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'Rechte Seitenleiste umschalten',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Git-Tab der rechten Seitenleiste öffnen',
+10
View File
@@ -426,6 +426,11 @@ export const dict = {
'sessions.sidebar.bulkActions.archivedPlural': '{count} Sitzungen archiviert',
'sessions.sidebar.bulkActions.failedArchiveSingle': 'Fehler beim Archivieren von {count} Sitzung',
'sessions.sidebar.bulkActions.failedArchivePlural': 'Fehler beim Archivieren von {count} Sitzungen',
'sessions.sidebar.bulkActions.restore': 'Wiederherstellen',
'sessions.sidebar.bulkActions.restoredSingle': '{count} Sitzung wiederhergestellt',
'sessions.sidebar.bulkActions.restoredPlural': '{count} Sitzungen wiederhergestellt',
'sessions.sidebar.bulkActions.failedRestoreSingle': 'Fehler beim Wiederherstellen von {count} Sitzung',
'sessions.sidebar.bulkActions.failedRestorePlural': 'Fehler beim Wiederherstellen von {count} Sitzungen',
'sessions.sidebar.folders.none': 'Noch keine Ordner',
'sessions.sidebar.folders.newFolderEllipsis': 'Neuer Ordner...',
'sessions.sidebar.folders.removeFromFolder': 'Aus Ordner entfernen',
@@ -517,6 +522,8 @@ export const dict = {
'sessions.sidebar.session.delete.error': 'Fehler beim Löschen der Sitzung',
'sessions.sidebar.session.archive.success': 'Sitzung archiviert',
'sessions.sidebar.session.archive.error': 'Fehler beim Archivieren der Sitzung',
'sessions.sidebar.session.restore.success': 'Sitzung wiederhergestellt',
'sessions.sidebar.session.restore.error': 'Fehler beim Wiederherstellen der Sitzung',
'sessions.sidebar.group.pr.checksPassed': '{success}/{total} Checks bestanden',
'sessions.sidebar.group.pr.failingCount': '{count} fehlgeschlagen',
'sessions.sidebar.group.pr.pendingCount': '{count} ausstehend',
@@ -1085,6 +1092,7 @@ export const dict = {
'sidebarFilesTree.menu.rename': 'Umbenennen',
'sidebarFilesTree.menu.copyPath': 'Pfad kopieren',
'sidebarFilesTree.menu.save': 'Speichern',
'sidebarFilesTree.menu.download': 'Herunterladen',
'sidebarFilesTree.menu.newFile': 'Neue Datei',
'sidebarFilesTree.menu.newFolder': 'Neuer Ordner',
'sidebarFilesTree.menu.delete': 'Löschen',
@@ -1531,6 +1539,7 @@ export const dict = {
'helpDialog.item.openCommandPalette': 'Befehlspalette öffnen',
'helpDialog.item.showKeyboardShortcuts': 'Tastaturkürzel anzeigen (dieses Dialogfeld)',
'helpDialog.item.toggleSessionSidebar': 'Sitzungs-Seitenleiste umschalten',
'helpDialog.item.addSelectionToChat': 'Auswahl zum Chat hinzufügen',
'helpDialog.item.cycleAgent': 'Agent wechseln (Chat-Eingabe)',
'helpDialog.item.openModelSelector': 'Modell-Auswahldialog öffnen',
'helpDialog.item.navigateModels': 'Modelle navigieren (in Auswahl)',
@@ -2785,6 +2794,7 @@ export const dict = {
'sessions.archivePage.deleteProject': 'Alle archivierten Sitzungen in diesem Projekt löschen',
'sessions.archivePage.deleteProjectAria': 'Alle archivierten Sitzungen in {label} löschen',
'sessions.archivePage.deleteSessionAria': '{title} löschen',
'sessions.archivePage.restoreSessionAria': '{title} wiederherstellen',
'header.sessionActions.openAria': 'Sitzungsaktionen öffnen',
'sessions.sidebar.session.menu.copyId': 'Sitzungs-ID kopieren',
'sessions.sidebar.session.copyId.success': 'Sitzungs-ID kopiert',
@@ -1115,6 +1115,7 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.action.open_settings.label': 'Open settings',
'settings.openchamber.keyboardShortcuts.action.toggle_terminal.label': 'Toggle terminal dock',
'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': 'Toggle terminal expanded',
'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label': 'Add selection to chat',
'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': 'Toggle sidebar',
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'Toggle context panel',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Open Git surface',
+10
View File
@@ -449,6 +449,7 @@ export const dict = {
'sessions.archivePage.deleteProject': 'Delete all archived sessions in this project',
'sessions.archivePage.deleteProjectAria': 'Delete all archived sessions in {label}',
'sessions.archivePage.deleteSessionAria': 'Delete {title}',
'sessions.archivePage.restoreSessionAria': 'Restore {title}',
'sessions.switcher.openAria': 'Open session switcher',
'sessions.switcher.empty': 'No recent sessions',
'sessions.switcher.draftTitle': 'New session',
@@ -470,6 +471,11 @@ export const dict = {
'sessions.sidebar.bulkActions.archivedPlural': 'Archived {count} sessions',
'sessions.sidebar.bulkActions.failedArchiveSingle': 'Failed to archive {count} session',
'sessions.sidebar.bulkActions.failedArchivePlural': 'Failed to archive {count} sessions',
'sessions.sidebar.bulkActions.restore': 'Restore',
'sessions.sidebar.bulkActions.restoredSingle': 'Restored {count} session',
'sessions.sidebar.bulkActions.restoredPlural': 'Restored {count} sessions',
'sessions.sidebar.bulkActions.failedRestoreSingle': 'Failed to restore {count} session',
'sessions.sidebar.bulkActions.failedRestorePlural': 'Failed to restore {count} sessions',
'sessions.sidebar.folders.none': 'No folders yet',
'sessions.sidebar.folders.newFolderEllipsis': 'New folder...',
'sessions.sidebar.folders.removeFromFolder': 'Remove from folder',
@@ -573,6 +579,8 @@ export const dict = {
'sessions.sidebar.session.delete.error': 'Failed to delete session',
'sessions.sidebar.session.archive.success': 'Session archived',
'sessions.sidebar.session.archive.error': 'Failed to archive session',
'sessions.sidebar.session.restore.success': 'Session restored',
'sessions.sidebar.session.restore.error': 'Failed to restore session',
'sessions.sidebar.group.pr.checksPassed': '{success}/{total} checks passed',
'sessions.sidebar.group.pr.failingCount': '{count} failing',
'sessions.sidebar.group.pr.pendingCount': '{count} pending',
@@ -1225,6 +1233,7 @@ export const dict = {
'sidebarFilesTree.menu.rename': 'Rename',
'sidebarFilesTree.menu.copyPath': 'Copy Path',
'sidebarFilesTree.menu.save': 'Save',
'sidebarFilesTree.menu.download': 'Download',
'sidebarFilesTree.menu.newFile': 'New File',
'sidebarFilesTree.menu.newFolder': 'New Folder',
'sidebarFilesTree.menu.delete': 'Delete',
@@ -1678,6 +1687,7 @@ export const dict = {
'helpDialog.item.openCommandPalette': 'Open Command Palette',
'helpDialog.item.showKeyboardShortcuts': 'Show Keyboard Shortcuts (this dialog)',
'helpDialog.item.toggleSessionSidebar': 'Toggle Session Sidebar',
'helpDialog.item.addSelectionToChat': 'Add Selection to Chat',
'helpDialog.item.cycleAgent': 'Cycle Agent (chat input)',
'helpDialog.item.openModelSelector': 'Open Model Selector',
'helpDialog.item.navigateModels': 'Navigate Models (in picker)',
@@ -1083,6 +1083,7 @@ export const settingsDict = {
"settings.openchamber.keyboardShortcuts.action.open_settings.label": "Abrir configuración",
"settings.openchamber.keyboardShortcuts.action.toggle_terminal.label": "Mostrar u ocultar panel de terminal",
"settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label": "Expandir o contraer terminal",
"settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label": "Agregar selección al chat",
"settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label": "Mostrar u ocultar barra lateral",
"settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label": 'Alternar panel de contexto',
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label": 'Abrir superficie de Git',
+10
View File
@@ -450,6 +450,7 @@ export const dict: Record<I18nKey, string> = {
"sessions.archivePage.deleteProject": "Eliminar todas las sesiones archivadas de este proyecto",
"sessions.archivePage.deleteProjectAria": "Eliminar todas las sesiones archivadas de {label}",
"sessions.archivePage.deleteSessionAria": "Eliminar {title}",
"sessions.archivePage.restoreSessionAria": "Restaurar {title}",
"sessions.switcher.openAria": "Abrir selector de sesiones",
"sessions.switcher.empty": "No hay sesiones recientes",
"sessions.switcher.draftTitle": "Nueva sesión",
@@ -471,6 +472,11 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.bulkActions.archivedPlural": "Se archivaron {count} sesiones",
"sessions.sidebar.bulkActions.failedArchiveSingle": "No se pudo archivar {count} sesión",
"sessions.sidebar.bulkActions.failedArchivePlural": "No se pudo archivar {count} sesiones",
"sessions.sidebar.bulkActions.restore": "Restaurar",
"sessions.sidebar.bulkActions.restoredSingle": "Se restauró {count} sesión",
"sessions.sidebar.bulkActions.restoredPlural": "Se restauraron {count} sesiones",
"sessions.sidebar.bulkActions.failedRestoreSingle": "No se pudo restaurar {count} sesión",
"sessions.sidebar.bulkActions.failedRestorePlural": "No se pudo restaurar {count} sesiones",
"sessions.sidebar.folders.none": "No hay carpetas aún",
"sessions.sidebar.folders.newFolderEllipsis": "Nueva carpeta...",
"sessions.sidebar.folders.removeFromFolder": "Quitar de carpeta",
@@ -574,6 +580,8 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.session.delete.error": "No se pudo eliminar la sesión",
"sessions.sidebar.session.archive.success": "Sesión archivada",
"sessions.sidebar.session.archive.error": "No se pudo archivar la sesión",
"sessions.sidebar.session.restore.success": "Sesión restaurada",
"sessions.sidebar.session.restore.error": "No se pudo restaurar la sesión",
"sessions.sidebar.group.pr.checksPassed": "{success}/{total} comprobaciones aprobadas",
"sessions.sidebar.group.pr.failingCount": "{count} con fallos",
"sessions.sidebar.group.pr.pendingCount": "{count} pendientes",
@@ -1191,6 +1199,7 @@ export const dict: Record<I18nKey, string> = {
"sidebarFilesTree.menu.rename": "Cambiar nombre",
"sidebarFilesTree.menu.copyPath": "Copiar ruta",
"sidebarFilesTree.menu.save": "Guardar",
"sidebarFilesTree.menu.download": "Descargar",
"sidebarFilesTree.menu.newFile": "Nuevo archivo",
"sidebarFilesTree.menu.newFolder": "Nueva carpeta",
"sidebarFilesTree.menu.delete": "Eliminar",
@@ -1656,6 +1665,7 @@ export const dict: Record<I18nKey, string> = {
"helpDialog.item.openCommandPalette": "Abrir paleta de comandos",
"helpDialog.item.showKeyboardShortcuts": "Mostrar atajos de teclado (este diálogo)",
"helpDialog.item.toggleSessionSidebar": "Mostrar u ocultar barra lateral de sesión",
"helpDialog.item.addSelectionToChat": "Agregar selección al chat",
"helpDialog.item.cycleAgent": "Cambiar agente (entrada de chat)",
"helpDialog.item.openModelSelector": "Abrir selector de modelos",
"helpDialog.item.navigateModels": "Navegar modelos (en selector)",
@@ -1004,6 +1004,7 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.action.open_settings.label': 'Ouvrir les paramètres',
'settings.openchamber.keyboardShortcuts.action.toggle_terminal.label': 'Basculer la station d\'accueil du terminal',
'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': 'Terminal à bascule étendu',
'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label': 'Ajouter la sélection au chat',
'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': 'Basculer la barre latérale',
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'Afficher/masquer le panneau de contexte',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Ouvrir la surface Git',
+10
View File
@@ -285,6 +285,7 @@ export const dict = {
'sessions.archivePage.deleteProject': 'Supprimer toutes les sessions archivées de ce projet',
'sessions.archivePage.deleteProjectAria': 'Supprimer toutes les sessions archivées de {label}',
'sessions.archivePage.deleteSessionAria': 'Supprimer {title}',
'sessions.archivePage.restoreSessionAria': 'Restaurer {title}',
'sessions.switcher.openAria': 'Sélecteur de session ouvert',
'sessions.switcher.empty': 'Aucune session récente',
'sessions.switcher.draftTitle': 'Nouvelle session',
@@ -306,6 +307,11 @@ export const dict = {
'sessions.sidebar.bulkActions.archivedPlural': 'Sessions {count} archivées',
'sessions.sidebar.bulkActions.failedArchiveSingle': 'Échec de l\'archivage de la session {count}',
'sessions.sidebar.bulkActions.failedArchivePlural': 'Échec de l\'archivage des sessions {count}',
'sessions.sidebar.bulkActions.restore': 'Restaurer',
'sessions.sidebar.bulkActions.restoredSingle': 'Session {count} restaurée',
'sessions.sidebar.bulkActions.restoredPlural': 'Sessions {count} restaurées',
'sessions.sidebar.bulkActions.failedRestoreSingle': 'Échec de la restauration de la session {count}',
'sessions.sidebar.bulkActions.failedRestorePlural': 'Échec de la restauration des sessions {count}',
'sessions.sidebar.folders.none': 'Aucun dossier pour l\'instant',
'sessions.sidebar.folders.newFolderEllipsis': 'Nouveau dossier...',
'sessions.sidebar.folders.removeFromFolder': 'Supprimer du dossier',
@@ -409,6 +415,8 @@ export const dict = {
'sessions.sidebar.session.delete.error': 'Échec de la suppression de la session',
'sessions.sidebar.session.archive.success': 'Session archivée',
'sessions.sidebar.session.archive.error': 'Échec de l\'archivage de la session',
'sessions.sidebar.session.restore.success': 'Session restaurée',
'sessions.sidebar.session.restore.error': 'Échec de la restauration de la session',
'sessions.sidebar.group.pr.checksPassed': 'Contrôles {success}/{total} réussis',
'sessions.sidebar.group.pr.failingCount': 'Échec de {count}',
'sessions.sidebar.group.pr.pendingCount': '{count} en attente',
@@ -1047,6 +1055,7 @@ export const dict = {
'sidebarFilesTree.menu.rename': 'Rebaptiser',
'sidebarFilesTree.menu.copyPath': 'Copier le chemin',
'sidebarFilesTree.menu.save': 'Sauvegarder',
'sidebarFilesTree.menu.download': 'Télécharger',
'sidebarFilesTree.menu.newFile': 'Nouveau fichier',
'sidebarFilesTree.menu.newFolder': 'Nouveau dossier',
'sidebarFilesTree.menu.delete': 'Supprimer',
@@ -1491,6 +1500,7 @@ export const dict = {
'helpDialog.item.openCommandPalette': 'Ouvrir la palette de commandes',
'helpDialog.item.showKeyboardShortcuts': 'Afficher les raccourcis clavier (cette boîte de dialogue)',
'helpDialog.item.toggleSessionSidebar': 'Toggle la barre latérale de la session',
'helpDialog.item.addSelectionToChat': 'Ajouter la sélection au chat',
'helpDialog.item.cycleAgent': 'Agent de cycle (entrée de chat)',
'helpDialog.item.openModelSelector': 'Ouvrir le sélecteur de modèle',
'helpDialog.item.navigateModels': 'Naviguer dans les modèles (dans le sélecteur)',
@@ -1116,6 +1116,7 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.action.open_settings.label': '設定を開く',
'settings.openchamber.keyboardShortcuts.action.toggle_terminal.label': 'ターミナルドックの切替',
'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': 'ターミナル拡大の切替',
'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label': '選択範囲をチャットに追加',
'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': 'サイドバーの切替',
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'コンテキストパネルの表示切替',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Git サーフェスを開く',
+10
View File
@@ -450,6 +450,7 @@ export const dict: Record<I18nKey, string> = {
'sessions.archivePage.deleteProject': 'このプロジェクトのアーカイブ済みセッションをすべて削除',
'sessions.archivePage.deleteProjectAria': '{label} のアーカイブ済みセッションをすべて削除',
'sessions.archivePage.deleteSessionAria': '{title} を削除',
'sessions.archivePage.restoreSessionAria': '{title} を復元',
'sessions.switcher.openAria': 'セッションスイッチャーを開く',
'sessions.switcher.empty': '最近のセッションはありません',
'sessions.switcher.draftTitle': '新しいセッション',
@@ -471,6 +472,11 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.bulkActions.archivedPlural': '{count}セッションをアーカイブしました',
'sessions.sidebar.bulkActions.failedArchiveSingle': '{count}セッションのアーカイブに失敗しました',
'sessions.sidebar.bulkActions.failedArchivePlural': '{count}セッションのアーカイブに失敗しました',
'sessions.sidebar.bulkActions.restore': '復元',
'sessions.sidebar.bulkActions.restoredSingle': '{count}セッションを復元しました',
'sessions.sidebar.bulkActions.restoredPlural': '{count}セッションを復元しました',
'sessions.sidebar.bulkActions.failedRestoreSingle': '{count}セッションの復元に失敗しました',
'sessions.sidebar.bulkActions.failedRestorePlural': '{count}セッションの復元に失敗しました',
'sessions.sidebar.folders.none': 'まだフォルダがありません',
'sessions.sidebar.folders.newFolderEllipsis': '新しいフォルダ...',
'sessions.sidebar.folders.removeFromFolder': 'フォルダから削除',
@@ -574,6 +580,8 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.delete.error': 'セッションの削除に失敗しました',
'sessions.sidebar.session.archive.success': 'セッションをアーカイブしました',
'sessions.sidebar.session.archive.error': 'セッションのアーカイブに失敗しました',
'sessions.sidebar.session.restore.success': 'セッションを復元しました',
'sessions.sidebar.session.restore.error': 'セッションの復元に失敗しました',
'sessions.sidebar.group.pr.checksPassed': '{success}/{total}のチェックに合格',
'sessions.sidebar.group.pr.failingCount': '{count}件失敗',
'sessions.sidebar.group.pr.pendingCount': '{count}件保留中',
@@ -1221,6 +1229,7 @@ export const dict: Record<I18nKey, string> = {
'sidebarFilesTree.menu.rename': '名前の変更',
'sidebarFilesTree.menu.copyPath': 'パスをコピー',
'sidebarFilesTree.menu.save': '保存',
'sidebarFilesTree.menu.download': 'ダウンロード',
'sidebarFilesTree.menu.newFile': '新しいファイル',
'sidebarFilesTree.menu.newFolder': '新しいフォルダ',
'sidebarFilesTree.menu.delete': '削除',
@@ -1674,6 +1683,7 @@ export const dict: Record<I18nKey, string> = {
'helpDialog.item.openCommandPalette': 'コマンドパレットを開く',
'helpDialog.item.showKeyboardShortcuts': 'キーボードショートカットを表示(このダイアログ)',
'helpDialog.item.toggleSessionSidebar': 'セッションサイドバーの切り替え',
'helpDialog.item.addSelectionToChat': '選択範囲をチャットに追加',
'helpDialog.item.cycleAgent': 'エージェント切り替え(チャット入力)',
'helpDialog.item.openModelSelector': 'モデルセレクターを開く',
'helpDialog.item.navigateModels': 'モデルを移動(ピッカー内)',
@@ -1083,6 +1083,7 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.action.open_settings.label': '설정 열기',
'settings.openchamber.keyboardShortcuts.action.toggle_terminal.label': '터미널 dock 토글',
'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': '터미널 확장 토글',
'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label': '선택 내용을 채팅에 추가',
'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': '사이드바 토글',
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': '컨텍스트 패널 표시 전환',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Git 서피스 열기',
+10
View File
@@ -450,6 +450,7 @@ export const dict: Record<I18nKey, string> = {
'sessions.archivePage.deleteProject': '이 프로젝트의 보관된 세션 모두 삭제',
'sessions.archivePage.deleteProjectAria': '{label}의 보관된 세션 모두 삭제',
'sessions.archivePage.deleteSessionAria': '{title} 삭제',
'sessions.archivePage.restoreSessionAria': '{title} 복원',
'sessions.switcher.openAria': '세션 전환기 열기',
'sessions.switcher.empty': '최근 세션 없음',
'sessions.switcher.draftTitle': '새 세션',
@@ -471,6 +472,11 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.bulkActions.archivedPlural': '세션 {count}개 보관됨',
'sessions.sidebar.bulkActions.failedArchiveSingle': '세션 {count}개 보관 실패',
'sessions.sidebar.bulkActions.failedArchivePlural': '세션 {count}개 보관 실패',
'sessions.sidebar.bulkActions.restore': '복원',
'sessions.sidebar.bulkActions.restoredSingle': '세션 {count}개 복원됨',
'sessions.sidebar.bulkActions.restoredPlural': '세션 {count}개 복원됨',
'sessions.sidebar.bulkActions.failedRestoreSingle': '세션 {count}개 복원 실패',
'sessions.sidebar.bulkActions.failedRestorePlural': '세션 {count}개 복원 실패',
'sessions.sidebar.folders.none': '아직 폴더 없음',
'sessions.sidebar.folders.newFolderEllipsis': '새 폴더…',
'sessions.sidebar.folders.removeFromFolder': '폴더에서 제거',
@@ -574,6 +580,8 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.delete.error': '세션 삭제 실패',
'sessions.sidebar.session.archive.success': '세션 보관됨',
'sessions.sidebar.session.archive.error': '세션 보관 실패',
'sessions.sidebar.session.restore.success': '세션 복원됨',
'sessions.sidebar.session.restore.error': '세션 복원 실패',
'sessions.sidebar.group.pr.checksPassed': '검사 통과: {success}/{total}',
'sessions.sidebar.group.pr.failingCount': '실패 {count}개',
'sessions.sidebar.group.pr.pendingCount': '{count} 대기 중',
@@ -1228,6 +1236,7 @@ export const dict: Record<I18nKey, string> = {
'sidebarFilesTree.menu.rename': '이름 변경',
'sidebarFilesTree.menu.copyPath': '경로 복사',
'sidebarFilesTree.menu.save': '저장',
'sidebarFilesTree.menu.download': '다운로드',
'sidebarFilesTree.menu.newFile': '새 파일',
'sidebarFilesTree.menu.newFolder': '새 폴더',
'sidebarFilesTree.menu.delete': '삭제',
@@ -1680,6 +1689,7 @@ export const dict: Record<I18nKey, string> = {
'helpDialog.item.openCommandPalette': '명령 팔레트 열기',
'helpDialog.item.showKeyboardShortcuts': '키보드 단축키 보기(이 대화상자)',
'helpDialog.item.toggleSessionSidebar': '토글 세션 사이드바',
'helpDialog.item.addSelectionToChat': '선택 내용을 채팅에 추가',
'helpDialog.item.cycleAgent': '에이전트 순환(채팅 입력)',
'helpDialog.item.openModelSelector': '모델 선택기 열기',
'helpDialog.item.navigateModels': '모델 이동(선택기)',
@@ -825,6 +825,7 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.action.toggle_context_plan.label': 'Przełącz panel kontekstu planu',
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'Przełącz panel kontekstu',
'settings.openchamber.keyboardShortcuts.action.toggle_services_menu.label': 'Przełącz menu usług',
'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label': 'Dodaj zaznaczenie do czatu',
'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': 'Przełącz pasek boczny',
'settings.openchamber.keyboardShortcuts.action.toggle_terminal.label': 'Przełącz dokowanie terminala',
'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': 'Przełącz rozszerzony terminal',
+10
View File
@@ -266,6 +266,7 @@ export const dict: Record<I18nKey, string> = {
'sessions.archivePage.deleteProject': 'Usuń wszystkie zarchiwizowane sesje tego projektu',
'sessions.archivePage.deleteProjectAria': 'Usuń wszystkie zarchiwizowane sesje w {label}',
'sessions.archivePage.deleteSessionAria': 'Usuń {title}',
'sessions.archivePage.restoreSessionAria': 'Przywróć {title}',
'sessions.switcher.openAria': 'Otwórz przełącznik sesji',
'sessions.switcher.empty': 'Brak ostatnich sesji',
'sessions.switcher.draftTitle': 'Nowa sesja',
@@ -333,6 +334,11 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.bulkActions.archivedPlural': 'Zarchiwizowano {count} sesji',
'sessions.sidebar.bulkActions.failedArchiveSingle': 'Nie udało się zarchiwizować {count} sesji',
'sessions.sidebar.bulkActions.failedArchivePlural': 'Nie udało się zarchiwizować {count} sesji',
'sessions.sidebar.bulkActions.restore': 'Przywróć',
'sessions.sidebar.bulkActions.restoredSingle': 'Przywrócono {count} sesję',
'sessions.sidebar.bulkActions.restoredPlural': 'Przywrócono {count} sesji',
'sessions.sidebar.bulkActions.failedRestoreSingle': 'Nie udało się przywrócić {count} sesji',
'sessions.sidebar.bulkActions.failedRestorePlural': 'Nie udało się przywrócić {count} sesji',
'sessions.scheduledTasks.dialog.title': 'Zaplanowane zadania',
'sessions.scheduledTasks.dialog.description': 'Zadania po stronie serwera, które tworzą nową sesję i wysyłają skonfigurowany prompt.',
'sessions.scheduledTasks.dialog.project.label': 'Projekt',
@@ -574,6 +580,8 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.delete.error': 'Nie udało się usunąć sesji',
'sessions.sidebar.session.archive.success': 'Sesja zarchiwizowana',
'sessions.sidebar.session.archive.error': 'Nie udało się zarchiwizować sesji',
'sessions.sidebar.session.restore.success': 'Sesja przywrócona',
'sessions.sidebar.session.restore.error': 'Nie udało się przywrócić sesji',
'sessions.sidebar.group.pr.checksPassed': '{success}/{total} testów przeszło',
'sessions.sidebar.group.pr.failingCount': '{count} niepowodzeń',
'sessions.sidebar.group.pr.pendingCount': '{count} oczekujących',
@@ -2325,6 +2333,7 @@ export const dict: Record<I18nKey, string> = {
'helpDialog.item.toggleRightSidebar': 'Przełącz panel kontekstu',
'helpDialog.item.toggleServicesMenu': 'Przełącz menu usług',
'helpDialog.item.toggleSessionSidebar': 'Przełącz panel sesji',
'helpDialog.item.addSelectionToChat': 'Dodaj zaznaczenie do czatu',
'helpDialog.item.toggleTerminalDock': 'Przełącz dolny terminal',
'helpDialog.item.toggleTerminalExpanded': 'Przełącz rozszerzenie terminala',
'helpDialog.keyCombiner.or': 'lub',
@@ -2708,6 +2717,7 @@ export const dict: Record<I18nKey, string> = {
'sidebarFilesTree.menu.newFolder': 'Nowy folder',
'sidebarFilesTree.menu.rename': 'Zmień nazwę',
'sidebarFilesTree.menu.save': 'Zapisz',
'sidebarFilesTree.menu.download': 'Pobierz',
'sidebarFilesTree.search.clearAria': 'Wyczyść wyszukiwanie',
'sidebarFilesTree.search.placeholder': 'Szukaj plików...',
'sidebarFilesTree.state.loading': 'Ładowanie...',
@@ -1083,6 +1083,7 @@ export const settingsDict = {
"settings.openchamber.keyboardShortcuts.action.open_settings.label": "Abrir configurações",
"settings.openchamber.keyboardShortcuts.action.toggle_terminal.label": "Mostrar ou ocultar painel de terminal",
"settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label": "Expandir ou recolher terminal",
"settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label": "Adicionar seleção ao chat",
"settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label": "Mostrar ou ocultar barra lateral",
"settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label": 'Alternar painel de contexto',
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label": 'Abrir superfície do Git',
@@ -450,6 +450,7 @@ export const dict: Record<I18nKey, string> = {
"sessions.archivePage.deleteProject": "Excluir todas as sessões arquivadas deste projeto",
"sessions.archivePage.deleteProjectAria": "Excluir todas as sessões arquivadas de {label}",
"sessions.archivePage.deleteSessionAria": "Excluir {title}",
"sessions.archivePage.restoreSessionAria": "Restaurar {title}",
"sessions.switcher.openAria": "Abrir seletor de sessões",
"sessions.switcher.empty": "Nenhuma sessão recente",
"sessions.switcher.draftTitle": "Nova sessão",
@@ -471,6 +472,11 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.bulkActions.archivedPlural": "{count} sessões arquivadas",
"sessions.sidebar.bulkActions.failedArchiveSingle": "Não foi possível arquivar {count} sessão",
"sessions.sidebar.bulkActions.failedArchivePlural": "Não foi possível arquivar {count} sessões",
"sessions.sidebar.bulkActions.restore": "Restaurar",
"sessions.sidebar.bulkActions.restoredSingle": "{count} sessão restaurada",
"sessions.sidebar.bulkActions.restoredPlural": "{count} sessões restauradas",
"sessions.sidebar.bulkActions.failedRestoreSingle": "Não foi possível restaurar {count} sessão",
"sessions.sidebar.bulkActions.failedRestorePlural": "Não foi possível restaurar {count} sessões",
"sessions.sidebar.folders.none": "Não há pastas ainda",
"sessions.sidebar.folders.newFolderEllipsis": "Nova pasta...",
"sessions.sidebar.folders.removeFromFolder": "Remover da pasta",
@@ -574,6 +580,8 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.session.delete.error": "Não foi possível excluir a sessão",
"sessions.sidebar.session.archive.success": "Sessão archivada",
"sessions.sidebar.session.archive.error": "Não foi possível arquivar a sessão",
"sessions.sidebar.session.restore.success": "Sessão restaurada",
"sessions.sidebar.session.restore.error": "Não foi possível restaurar a sessão",
"sessions.sidebar.group.pr.checksPassed": "{success}/{total} checks pasadas",
"sessions.sidebar.group.pr.failingCount": "{count} com fallos",
"sessions.sidebar.group.pr.pendingCount": "{count} pendentes",
@@ -1191,6 +1199,7 @@ export const dict: Record<I18nKey, string> = {
"sidebarFilesTree.menu.rename": "Renomear",
"sidebarFilesTree.menu.copyPath": "Copiar caminho",
"sidebarFilesTree.menu.save": "Salvar",
"sidebarFilesTree.menu.download": "Baixar",
"sidebarFilesTree.menu.newFile": "Novo arquivo",
"sidebarFilesTree.menu.newFolder": "Nova pasta",
"sidebarFilesTree.menu.delete": "Excluir",
@@ -1656,6 +1665,7 @@ export const dict: Record<I18nKey, string> = {
"helpDialog.item.openCommandPalette": "Abrir paleta de comandos",
"helpDialog.item.showKeyboardShortcuts": "Mostrar atalhos de teclado (este diálogo)",
"helpDialog.item.toggleSessionSidebar": "Mostrar ou ocultar barra lateral de sessão",
"helpDialog.item.addSelectionToChat": "Adicionar seleção ao chat",
"helpDialog.item.cycleAgent": "Alternar agente (entrada do chat)",
"helpDialog.item.openModelSelector": "Abrir seletor de modelos",
"helpDialog.item.navigateModels": "Navegar por modelos (no seletor)",
@@ -1083,6 +1083,7 @@ export const settingsDict = {
"settings.openchamber.keyboardShortcuts.action.open_settings.label": "Відкрити налаштування",
"settings.openchamber.keyboardShortcuts.action.toggle_terminal.label": "Перемкнути панель терміналу",
"settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label": "Розгорнути або згорнути термінал",
"settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label": "Додати виділення в чат",
"settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label": "Перемкнути бічну панель",
"settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label": 'Перемкнути контекстну панель',
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label": 'Відкрити поверхню Git',
+10
View File
@@ -450,6 +450,7 @@ export const dict: Record<I18nKey, string> = {
"sessions.archivePage.deleteProject": "Видалити всі архівні сесії цього проєкту",
"sessions.archivePage.deleteProjectAria": "Видалити всі архівні сесії у {label}",
"sessions.archivePage.deleteSessionAria": "Видалити {title}",
"sessions.archivePage.restoreSessionAria": "Відновити {title}",
"sessions.switcher.openAria": "Відкрити перемикач сесій",
"sessions.switcher.empty": "Немає недавніх сесій",
"sessions.switcher.draftTitle": "Нова сесія",
@@ -471,6 +472,11 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.bulkActions.archivedPlural": "Заархівовано сесій: {count}",
"sessions.sidebar.bulkActions.failedArchiveSingle": "Не вдалося архівувати сесія {count}",
"sessions.sidebar.bulkActions.failedArchivePlural": "Не вдалося архівувати сесії {count}",
"sessions.sidebar.bulkActions.restore": "Відновити",
"sessions.sidebar.bulkActions.restoredSingle": "Відновлено сесію: {count}",
"sessions.sidebar.bulkActions.restoredPlural": "Відновлено сесій: {count}",
"sessions.sidebar.bulkActions.failedRestoreSingle": "Не вдалося відновити сесія {count}",
"sessions.sidebar.bulkActions.failedRestorePlural": "Не вдалося відновити сесії {count}",
"sessions.sidebar.folders.none": "Папок ще немає",
"sessions.sidebar.folders.newFolderEllipsis": "Нова папка...",
"sessions.sidebar.folders.removeFromFolder": "Видалити з папки",
@@ -574,6 +580,8 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.session.delete.error": "Не вдалося видалити сесію",
"sessions.sidebar.session.archive.success": "Сесію заархівовано",
"sessions.sidebar.session.archive.error": "Не вдалося заархівувати сесію",
"sessions.sidebar.session.restore.success": "Сесію відновлено",
"sessions.sidebar.session.restore.error": "Не вдалося відновити сесію",
"sessions.sidebar.group.pr.checksPassed": "Перевірки {success}/{total} пройдено",
"sessions.sidebar.group.pr.failingCount": "{count} з помилкою",
"sessions.sidebar.group.pr.pendingCount": "{count} очікує",
@@ -1191,6 +1199,7 @@ export const dict: Record<I18nKey, string> = {
"sidebarFilesTree.menu.rename": "Перейменувати",
"sidebarFilesTree.menu.copyPath": "Копіювати шлях",
"sidebarFilesTree.menu.save": "Зберегти",
"sidebarFilesTree.menu.download": "Завантажити",
"sidebarFilesTree.menu.newFile": "Новий файл",
"sidebarFilesTree.menu.newFolder": "Нова папка",
"sidebarFilesTree.menu.delete": "Видалити",
@@ -1656,6 +1665,7 @@ export const dict: Record<I18nKey, string> = {
"helpDialog.item.openCommandPalette": "Відкрити палітру команд",
"helpDialog.item.showKeyboardShortcuts": "Показати комбінації клавіш (це діалогове вікно)",
"helpDialog.item.toggleSessionSidebar": "Перемкнути бічну панель сесій",
"helpDialog.item.addSelectionToChat": "Додати виділення в чат",
"helpDialog.item.cycleAgent": "Перемкнути агента (введення в чат)",
"helpDialog.item.openModelSelector": "Відкрити засіб вибору моделі",
"helpDialog.item.navigateModels": "Навігація моделями (у засобі вибору)",
@@ -1083,6 +1083,7 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.action.open_settings.label': '打开设置',
'settings.openchamber.keyboardShortcuts.action.toggle_terminal.label': '切换终端停靠区',
'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': '切换终端展开',
'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label': '将选中内容添加到聊天',
'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': '切换侧边栏',
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': '切换上下文面板',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': '打开 Git 界面',
@@ -450,6 +450,7 @@ export const dict: Record<I18nKey, string> = {
'sessions.archivePage.deleteProject': '删除此项目的所有已归档会话',
'sessions.archivePage.deleteProjectAria': '删除 {label} 的所有已归档会话',
'sessions.archivePage.deleteSessionAria': '删除 {title}',
'sessions.archivePage.restoreSessionAria': '还原 {title}',
'sessions.switcher.openAria': '打开会话切换器',
'sessions.switcher.empty': '没有最近会话',
'sessions.switcher.draftTitle': '新会话',
@@ -471,6 +472,11 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.bulkActions.archivedPlural': '已归档 {count} 个会话',
'sessions.sidebar.bulkActions.failedArchiveSingle': '归档 {count} 个会话失败',
'sessions.sidebar.bulkActions.failedArchivePlural': '归档 {count} 个会话失败',
'sessions.sidebar.bulkActions.restore': '还原',
'sessions.sidebar.bulkActions.restoredSingle': '已还原 {count} 个会话',
'sessions.sidebar.bulkActions.restoredPlural': '已还原 {count} 个会话',
'sessions.sidebar.bulkActions.failedRestoreSingle': '还原 {count} 个会话失败',
'sessions.sidebar.bulkActions.failedRestorePlural': '还原 {count} 个会话失败',
'sessions.sidebar.folders.none': '暂无文件夹',
'sessions.sidebar.folders.newFolderEllipsis': '新建文件夹...',
'sessions.sidebar.folders.removeFromFolder': '从文件夹中移除',
@@ -574,6 +580,8 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.delete.error': '删除会话失败',
'sessions.sidebar.session.archive.success': '会话已归档',
'sessions.sidebar.session.archive.error': '归档会话失败',
'sessions.sidebar.session.restore.success': '会话已还原',
'sessions.sidebar.session.restore.error': '还原会话失败',
'sessions.sidebar.group.pr.checksPassed': '{success}/{total} 项检查已通过',
'sessions.sidebar.group.pr.failingCount': '{count} 项失败',
'sessions.sidebar.group.pr.pendingCount': '{count} 项等待中',
@@ -1191,6 +1199,7 @@ export const dict: Record<I18nKey, string> = {
'sidebarFilesTree.menu.rename': '重命名',
'sidebarFilesTree.menu.copyPath': '复制路径',
'sidebarFilesTree.menu.save': '保存',
'sidebarFilesTree.menu.download': '下载',
'sidebarFilesTree.menu.newFile': '新建文件',
'sidebarFilesTree.menu.newFolder': '新建文件夹',
'sidebarFilesTree.menu.delete': '删除',
@@ -1644,6 +1653,7 @@ export const dict: Record<I18nKey, string> = {
'helpDialog.item.openCommandPalette': '打开命令面板',
'helpDialog.item.showKeyboardShortcuts': '显示键盘快捷键(此对话框)',
'helpDialog.item.toggleSessionSidebar': '切换会话侧边栏',
'helpDialog.item.addSelectionToChat': '将选中内容添加到聊天',
'helpDialog.item.cycleAgent': '循环切换智能体(聊天输入)',
'helpDialog.item.openModelSelector': '打开模型选择器',
'helpDialog.item.navigateModels': '导航模型(选择器中)',
@@ -990,6 +990,7 @@
'settings.openchamber.keyboardShortcuts.action.open_settings.label': '開啟設定',
'settings.openchamber.keyboardShortcuts.action.toggle_terminal.label': '切換終端機停靠區',
'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': '切換終端機展開',
'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label': '將選取內容加入聊天',
'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': '切換側邊欄',
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': '切換上下文面板',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': '開啟 Git 介面',
@@ -463,6 +463,7 @@ export const dict: Record<I18nKey, string> = {
'sessions.archivePage.deleteProject': '刪除此專案的所有已封存工作階段',
'sessions.archivePage.deleteProjectAria': '刪除 {label} 的所有已封存工作階段',
'sessions.archivePage.deleteSessionAria': '刪除 {title}',
'sessions.archivePage.restoreSessionAria': '還原 {title}',
'sessions.switcher.openAria': '開啟會話切換器',
'sessions.switcher.empty': '沒有最近會話',
'sessions.switcher.draftTitle': '新會話',
@@ -484,6 +485,11 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.bulkActions.archivedPlural': '已封存 {count} 個會話',
'sessions.sidebar.bulkActions.failedArchiveSingle': '封存 {count} 個會話失敗',
'sessions.sidebar.bulkActions.failedArchivePlural': '封存 {count} 個會話失敗',
'sessions.sidebar.bulkActions.restore': '還原',
'sessions.sidebar.bulkActions.restoredSingle': '已還原 {count} 個會話',
'sessions.sidebar.bulkActions.restoredPlural': '已還原 {count} 個會話',
'sessions.sidebar.bulkActions.failedRestoreSingle': '還原 {count} 個會話失敗',
'sessions.sidebar.bulkActions.failedRestorePlural': '還原 {count} 個會話失敗',
'sessions.sidebar.folders.none': '暫無資料夾',
'sessions.sidebar.folders.newFolderEllipsis': '新增資料夾...',
'sessions.sidebar.folders.removeFromFolder': '從資料夾中移除',
@@ -587,6 +593,8 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.delete.error': '刪除會話失敗',
'sessions.sidebar.session.archive.success': '會話已封存',
'sessions.sidebar.session.archive.error': '封存會話失敗',
'sessions.sidebar.session.restore.success': '會話已還原',
'sessions.sidebar.session.restore.error': '還原會話失敗',
'sessions.sidebar.group.pr.checksPassed': '{success}/{total} 項檢查已通過',
'sessions.sidebar.group.pr.failingCount': '{count} 項失敗',
'sessions.sidebar.group.pr.pendingCount': '{count} 項等待中',
@@ -1203,6 +1211,7 @@ export const dict: Record<I18nKey, string> = {
'sidebarFilesTree.menu.rename': '重新命名',
'sidebarFilesTree.menu.copyPath': '複製路徑',
'sidebarFilesTree.menu.save': '儲存',
'sidebarFilesTree.menu.download': '下載',
'sidebarFilesTree.menu.newFile': '新增檔案',
'sidebarFilesTree.menu.newFolder': '新增資料夾',
'sidebarFilesTree.menu.delete': '刪除',
@@ -1648,6 +1657,7 @@ export const dict: Record<I18nKey, string> = {
'helpDialog.item.openCommandPalette': '開啟命令面板',
'helpDialog.item.showKeyboardShortcuts': '顯示鍵盤快速鍵(此對話方塊)',
'helpDialog.item.toggleSessionSidebar': '切換會話側邊欄',
'helpDialog.item.addSelectionToChat': '將選取內容加入聊天',
'helpDialog.item.cycleAgent': '循環切換 Agent(聊天輸入)',
'helpDialog.item.openModelSelector': '開啟模型選擇器',
'helpDialog.item.navigateModels': '導覽模型(選擇器中)',
+8 -1
View File
@@ -12,6 +12,7 @@ import type {
TextPartInput,
FilePartInput,
} from "@opencode-ai/sdk/v2";
import { isAmbiguousTransportFailure, markAmbiguousTransportFailure } from "@/lib/relay/transport-error";
import type { PermissionRequest } from "@/types/permission";
import type { QuestionRequest } from "@/types/question";
@@ -878,7 +879,13 @@ class OpencodeService {
// failure) — there is no HTTP response to report. Never fabricate a
// status: surface it as a transport error so callers treat it like
// any other network failure instead of a server 500.
throw new Error(`Message send transport failure: ${formatSdkError(result.error)}`);
// Preserve the transport's "dispatched, outcome unknown" tag through
// the wrap: without it the caller cannot tell a lost response from a
// send that never reached the server, and re-sends a running prompt.
const transportError = new Error(`Message send transport failure: ${formatSdkError(result.error)}`);
throw isAmbiguousTransportFailure(result.error)
? markAmbiguousTransportFailure(transportError)
: transportError;
}
response = new Response(JSON.stringify(result.error), { status });
} else {
@@ -22,5 +22,6 @@ export const QUOTA_PROVIDERS: QuotaProviderMeta[] = [
{ id: 'wafer', name: 'Wafer.ai' },
{ id: 'opencode-go', name: 'OpenCode Go' },
{ id: 'crof', name: 'CrofAI' },
{ id: 'deepseek', name: 'DeepSeek' },
{ id: 'neuralwatt', name: 'NeuralWatt' },
];
@@ -0,0 +1,42 @@
/**
* Ambiguous transport failures.
*
* When a request dies after it was already handed to the transport, the client
* knows the response was lost it does NOT know whether the server processed
* the request. Over the relay tunnel this is the common case: a reconnect, a
* host-side stream abort, or a dead channel all fail an in-flight POST that may
* already be running server-side.
*
* Callers must be able to tell that state apart from a definite failure, and
* string-matching the message text is not a contract a renamed abort reason
* silently reclassifies a send. Transports therefore tag these errors, and
* callers read the tag (see `isAmbiguousTransportFailure`).
*
* `prompt_async` is the motivating case: treating an ambiguous failure as a
* definite one rolls back the user message and lets the queue re-send a prompt
* the engine is already answering, producing two independent AI responses.
*/
const AMBIGUOUS_TRANSPORT_FLAG = '__openchamberAmbiguousTransport';
/**
* Mark an error as "dispatched, outcome unknown". Returns the same error so it
* can be thrown inline.
*/
export const markAmbiguousTransportFailure = <T extends Error>(error: T): T => {
Object.defineProperty(error, AMBIGUOUS_TRANSPORT_FLAG, {
value: true,
enumerable: false,
configurable: true,
});
return error;
};
/**
* True when a transport tagged this error as dispatched-but-unconfirmed.
* Deliberately tag-only: text heuristics belong to the caller that owns them.
*/
export const isAmbiguousTransportFailure = (error: unknown): boolean => {
if (!error || typeof error !== 'object') return false;
return (error as Record<string, unknown>)[AMBIGUOUS_TRANSPORT_FLAG] === true;
};
@@ -11,6 +11,7 @@ import {
} from './crypto';
import { createHostHandshake } from './handshake';
import { TunnelFrameType } from './protocol';
import { isAmbiguousTransportFailure } from './transport-error';
import {
createFragmentAssembler,
decodeFrameBatch,
@@ -339,6 +340,24 @@ describe('createRelayTunnelClient', () => {
await expect(reader.read()).rejects.toThrow();
});
// A POST that dies after dispatch may already have been processed by the
// server. Callers must be able to tell that apart from a definite failure —
// a prompt re-sent on this error produces a second AI response (#2425).
test('tags an in-flight request killed by reconnect as an ambiguous failure', async () => {
const { client, killWire } = await setupClient({ silent: true });
track(client);
const pending = client.fetch('/api/session/s1/prompt_async', { method: 'POST', body: '{}' });
let caught: unknown = null;
const settled = pending.catch((error: unknown) => {
caught = error;
});
await wait(20);
killWire();
await settled;
expect(caught).toBeInstanceOf(Error);
expect(isAmbiguousTransportFailure(caught)).toBe(true);
});
test('opens, echoes, and closes a tunneled WebSocket', async () => {
const { client } = await setupClient();
track(client);
+17 -5
View File
@@ -35,6 +35,7 @@ import {
isWsClosePayload,
normalizeTunnelRequest,
} from './tunnel-payloads';
import { markAmbiguousTransportFailure } from './transport-error';
const EMPTY_PAYLOAD = new Uint8Array(0);
const textEncoder = new TextEncoder();
@@ -721,6 +722,13 @@ export const createRelayTunnelClient = (options: RelayTunnelClientOptions): Rela
}
};
// The request head is written to the channel below before any of these
// failures can fire, so losing the stream never proves the server did
// not process the request — only that the response was lost. Callers
// that would otherwise retry (prompt sends) must see that distinction.
const dispatchedFailure = (message: string): Error =>
markAmbiguousTransportFailure(new Error(message));
onAbort = () => {
sendAbort('aborted');
finishError(abortError());
@@ -735,7 +743,7 @@ export const createRelayTunnelClient = (options: RelayTunnelClientOptions): Rela
head = decodeJsonPayload(payload, isHttpResponsePayload);
} catch (error) {
sendAbort('malformed response head');
finishError(toError(error));
finishError(dispatchedFailure(toError(error).message));
return;
}
const nullBody = head.status === 204 || head.status === 205 || head.status === 304;
@@ -773,7 +781,7 @@ export const createRelayTunnelClient = (options: RelayTunnelClientOptions): Rela
if (frameType === TunnelFrameType.StreamEnd) {
if (finished) return;
if (!responseDelivered) {
finishError(new Error('tunnel stream ended before response head'));
finishError(dispatchedFailure('tunnel stream ended before response head'));
return;
}
finished = true;
@@ -792,11 +800,15 @@ export const createRelayTunnelClient = (options: RelayTunnelClientOptions): Rela
} catch {
// Keep the generic reason.
}
finishError(new Error(reason));
finishError(dispatchedFailure(reason));
}
},
fail(error) {
finishError(error);
// Channel death (reconnect, keepalive timeout) with this stream still
// open — same rule as above: dispatched, outcome unknown. A fresh
// error is tagged instead of the shared one so the tag cannot leak to
// waiters whose request never reached the wire.
finishError(dispatchedFailure(error.message));
},
});
@@ -824,7 +836,7 @@ export const createRelayTunnelClient = (options: RelayTunnelClientOptions): Rela
}
} catch (error) {
sendAbort('request body failed');
finishError(toError(error));
finishError(dispatchedFailure(toError(error).message));
}
})();
});
@@ -0,0 +1,80 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { getRuntimeKey } from './runtime-switch';
/**
* `getRuntimeKey` runs on store, event, and render paths, so its cost is
* multiplied by everything the UI does. These tests pin both directions of the
* derived-key cache: repeated calls with unchanged inputs must do no work, and
* any change to the inputs it derives from must still be observed.
*
* This lives in its own file because the cache is only reachable while the
* runtime endpoint has not been explicitly initialised, and module state is
* shared across tests within a file.
*/
type RuntimeWindow = typeof globalThis & {
__OPENCHAMBER_API_BASE_URL__?: string;
__OPENCHAMBER_LOCAL_ORIGIN__?: string;
};
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const NativeURL = globalThis.URL;
let urlConstructions = 0;
const setRuntimeWindow = (apiBaseUrl: string | undefined, localOrigin: string | undefined): void => {
const runtimeWindow = {} as RuntimeWindow;
if (apiBaseUrl !== undefined) runtimeWindow.__OPENCHAMBER_API_BASE_URL__ = apiBaseUrl;
if (localOrigin !== undefined) runtimeWindow.__OPENCHAMBER_LOCAL_ORIGIN__ = localOrigin;
Object.defineProperty(globalThis, 'window', { value: runtimeWindow, configurable: true, writable: true });
};
beforeEach(() => {
urlConstructions = 0;
class CountingURL extends NativeURL {
constructor(url: string | URL, base?: string | URL) {
urlConstructions += 1;
super(url, base);
}
}
globalThis.URL = CountingURL as unknown as typeof URL;
});
afterEach(() => {
globalThis.URL = NativeURL;
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow);
else Reflect.deleteProperty(globalThis, 'window');
});
describe('getRuntimeKey caching', () => {
test('resolves a same-origin endpoint to the local runtime key', () => {
setRuntimeWindow('https://app.example.com/api', 'https://app.example.com');
expect(getRuntimeKey()).toBe('local');
});
test('performs no URL work on repeated calls with unchanged inputs', () => {
setRuntimeWindow('https://remote.example.com', 'https://app.example.com');
const first = getRuntimeKey();
expect(first).toBe('url:https://remote.example.com');
urlConstructions = 0;
for (let index = 0; index < 50; index += 1) expect(getRuntimeKey()).toBe(first);
expect(urlConstructions).toBe(0);
});
test('recomputes when the injected API base URL changes at runtime', () => {
setRuntimeWindow('https://first.example.com', 'https://app.example.com');
expect(getRuntimeKey()).toBe('url:https://first.example.com');
(globalThis as RuntimeWindow & { window: RuntimeWindow }).window.__OPENCHAMBER_API_BASE_URL__ = 'https://second.example.com';
expect(getRuntimeKey()).toBe('url:https://second.example.com');
});
test('recomputes when the injected local origin changes at runtime', () => {
setRuntimeWindow('https://app.example.com', 'https://other.example.com');
expect(getRuntimeKey()).toBe('url:https://app.example.com');
(globalThis as RuntimeWindow & { window: RuntimeWindow }).window.__OPENCHAMBER_LOCAL_ORIGIN__ = 'https://app.example.com';
expect(getRuntimeKey()).toBe('local');
});
});
+43 -2
View File
@@ -76,11 +76,52 @@ const sameOrigin = (left: string, right: string): boolean => {
};
export const getRuntimeApiBaseUrl = (): string => activeApiBaseUrl || readInjectedApiBaseUrl();
// `getRuntimeKey` keys caches, stores, and persisted state across the whole UI,
// so it runs on store reads, event handling, and render paths. Before the
// runtime endpoint is explicitly initialised, every call re-derived the key by
// trimming two injected globals and constructing three `URL` objects, which
// made this one of the most expensive functions during streaming.
//
// The result depends only on `activeApiBaseUrl` and the two injected globals,
// and `switchRuntimeEndpoint` writes the injected API base URL at runtime, so
// the cache is validated against the raw, untrimmed values. That comparison
// allocates nothing and still recomputes the moment any input changes.
let cachedRuntimeKey = '';
let cachedActiveApiBaseUrl: string | null = null;
let cachedRawApiBaseUrl: string | undefined;
let cachedRawLocalOrigin: string | undefined;
const readRawRuntimeGlobal = (key: '__OPENCHAMBER_API_BASE_URL__' | '__OPENCHAMBER_LOCAL_ORIGIN__'): string | undefined => {
if (typeof window === 'undefined') return undefined;
const value = (window as typeof window & {
__OPENCHAMBER_API_BASE_URL__?: string;
__OPENCHAMBER_LOCAL_ORIGIN__?: string;
})[key];
return typeof value === 'string' ? value : undefined;
};
export const getRuntimeKey = (): string => {
if (activeRuntimeKey) return activeRuntimeKey;
const rawApiBaseUrl = readRawRuntimeGlobal('__OPENCHAMBER_API_BASE_URL__');
const rawLocalOrigin = readRawRuntimeGlobal('__OPENCHAMBER_LOCAL_ORIGIN__');
if (
cachedActiveApiBaseUrl === activeApiBaseUrl
&& cachedRawApiBaseUrl === rawApiBaseUrl
&& cachedRawLocalOrigin === rawLocalOrigin
) {
return cachedRuntimeKey;
}
const apiBaseUrl = getRuntimeApiBaseUrl();
if (sameOrigin(apiBaseUrl, readInjectedLocalOrigin())) return 'local';
return normalizeRuntimeUrlKey(apiBaseUrl);
cachedRuntimeKey = sameOrigin(apiBaseUrl, readInjectedLocalOrigin())
? 'local'
: normalizeRuntimeUrlKey(apiBaseUrl);
cachedActiveApiBaseUrl = activeApiBaseUrl;
cachedRawApiBaseUrl = rawApiBaseUrl;
cachedRawLocalOrigin = rawLocalOrigin;
return cachedRuntimeKey;
};
export const initializeRuntimeEndpoint = (options: { apiBaseUrl?: string | null; runtimeKey?: string | null } = {}): void => {
+8 -1
View File
@@ -159,8 +159,15 @@ const SHORTCUT_ACTIONS: ReadonlyArray<ShortcutAction> = [
description: 'Toggle the files panel',
},
{
id: 'toggle_sidebar',
id: 'add_selection_to_chat',
defaultCombo: 'mod+l',
label: 'Add selection to chat',
description: 'Add the selected text to the chat input',
customizable: true,
},
{
id: 'toggle_sidebar',
defaultCombo: 'mod+alt+l',
label: 'Toggle sidebar',
description: 'Toggle the session sidebar',
customizable: true,
@@ -24,12 +24,12 @@
"emphasis": "#fd9b66"
},
"surface": {
"background": "#0c0b0a",
"foreground": "#dbd7ca",
"muted": "#131211",
"background": "#120f0e",
"foreground": "#c9c5ba",
"muted": "#171615",
"mutedForeground": "#8f8b81",
"elevated": "#181715",
"elevatedForeground": "#dbd7ca",
"elevatedForeground": "#c9c5ba",
"overlay": "#00000099",
"subtle": "#171616"
},
@@ -37,11 +37,11 @@
"border": "#242323",
"borderHover": "#504e4c",
"borderFocus": "#da7c47",
"selection": "#da7c472b",
"selectionForeground": "#dbd7ca",
"selection": "#b9a5992b",
"selectionForeground": "#c9c5ba",
"focus": "#da7c47",
"focusRing": "#da7c4755",
"cursor": "#dbd7ca",
"cursor": "#c9c5ba",
"hover": "#ffffff12",
"active": "#ffffff1f"
},
@@ -72,8 +72,8 @@
},
"syntax": {
"base": {
"background": "#131211",
"foreground": "#dbd7ca",
"background": "#120f0e",
"foreground": "#c9c5ba",
"comment": "#728772",
"keyword": "#34983a",
"string": "#d58373",
@@ -127,14 +127,14 @@
"diffModified": "#5d99a9",
"diffModifiedBackground": "#5d99a920",
"lineNumber": "#3c3a37",
"lineNumberActive": "#dbd7ca"
"lineNumberActive": "#c9c5ba"
}
},
"markdown": {
"heading1": "#dbd7ca",
"heading2": "#dbd7ca",
"heading3": "#dbd7ca",
"heading4": "#dbd7ca",
"heading1": "#c9c5ba",
"heading2": "#c9c5ba",
"heading3": "#c9c5ba",
"heading4": "#c9c5ba",
"link": "#5d99a9",
"linkHover": "#6ba7b8",
"inlineCode": "#76ad4f",
@@ -144,19 +144,19 @@
"listMarker": "#4d934e99"
},
"chat": {
"userMessage": "#dbd7ca",
"userMessage": "#c9c5ba",
"userMessageBackground": "#25170e",
"assistantMessage": "#dbd7ca",
"assistantMessageBackground": "#0c0b0a",
"assistantMessage": "#c9c5ba",
"assistantMessageBackground": "#120f0e",
"timestamp": "#8f8b81",
"divider": "#302e2b"
},
"tools": {
"background": "#13121150",
"background": "#120f0e50",
"border": "#302e2b99",
"headerHover": "#ffffff0d",
"icon": "#ada9a0",
"title": "#dbd7ca",
"title": "#c9c5ba",
"description": "#aba9a3",
"edit": {
"added": "#4d934e",