diff --git a/packages/electron/README.md b/packages/electron/README.md index c5c7df08..41490145 100644 --- a/packages/electron/README.md +++ b/packages/electron/README.md @@ -155,6 +155,7 @@ Use an explicit override when testing a different OpenCode CLI build or when a u - SSH host import, connections, logs, and port forwarding. - SSH uses OpenSSH ControlMaster on macOS/Linux. Windows uses independent hidden OpenSSH processes for setup commands and each long-lived forward because Win32 OpenSSH does not support ControlMaster reliably. - Tunnel lifecycle integration through the web server runtime. +- Remote dev-server previews use a direct WebSocket tunnel when the instance has an HTTP address. Relay-only instances keep the encrypted relay transport in the renderer and bridge its raw bytes to the browser panel through a local Electron listener. - Auto-update checks, downloads, and restart/apply flow. - The browser panel's own session (`persist:openchamber-browser`): its storage is cleared only through the scoped clear-data command, and camera, microphone, diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index 2a0de81b..64a08849 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -1,4 +1,4 @@ -import { app, BrowserWindow, dialog, ipcMain, Menu, nativeTheme, net as electronNet, Notification, powerMonitor, powerSaveBlocker, protocol, screen, session, shell, webContents } from 'electron'; +import { app, BrowserWindow, dialog, ipcMain, Menu, MessageChannelMain, nativeTheme, net as electronNet, Notification, powerMonitor, powerSaveBlocker, protocol, screen, session, shell, webContents } from 'electron'; import contextMenu from 'electron-context-menu'; import log from 'electron-log/main.js'; import dgram from 'node:dgram'; @@ -33,6 +33,7 @@ import { } from './linux-autostart.mjs'; import { unsupportedAppSpecificOpenError, validateLocalPath } from './path-open-utils.mjs'; import { shouldAllowBrowserPanelCertificateError } from './browser-panel-security.mjs'; +import { createRelayDevTunnelBridge } from './relay-dev-tunnel.mjs'; import { attachRendererRecovery } from './renderer-recovery.mjs'; import { mintOutsideFileGrant } from '@openchamber/web/server/lib/fs/routes.js'; @@ -3839,6 +3840,7 @@ const runSpecChain = (specs, appName) => { // The tunnel client lives in the web package (it already has a WebSocket // client) and is loaded only if the user actually previews a remote dev server. let devTunnelClientPromise = null; +const relayDevTunnelBridge = createRelayDevTunnelBridge({ createMessageChannel: () => new MessageChannelMain(), logger: log }); const getDevTunnelClient = async () => { if (!devTunnelClientPromise) { devTunnelClientPromise = import('@openchamber/web/server/lib/dev-tunnel/client.js') @@ -3852,6 +3854,7 @@ const getDevTunnelClient = async () => { }; const closeAllDevTunnels = () => { + relayDevTunnelBridge.closeAll(); if (!devTunnelClientPromise) return; const pending = devTunnelClientPromise; devTunnelClientPromise = null; @@ -3959,6 +3962,11 @@ const handleInvoke = async (browserWindow, command, args = {}) => { if (!baseUrl) throw new Error('baseUrl is required'); if (!(port > 0 && port <= 65535)) throw new Error('A valid port is required'); + if (args.relay === true) { + const targetKey = typeof args.targetKey === 'string' ? args.targetKey.trim() : ''; + return relayDevTunnelBridge.open({ targetKey, remotePort: port, webContents: browserWindow?.webContents }); + } + const headers = {}; const requestHeaders = args.requestHeaders && typeof args.requestHeaders === 'object' ? args.requestHeaders : {}; for (const [name, value] of Object.entries(requestHeaders)) { @@ -3981,6 +3989,9 @@ const handleInvoke = async (browserWindow, command, args = {}) => { return { closed: client.close({ baseUrl, port }) }; } + case 'desktop_relay_dev_tunnel_close_all': + return { closed: relayDevTunnelBridge.closeForWebContents(browserWindow?.webContents.id) }; + /** * Forces prefers-color-scheme for one previewed page. * diff --git a/packages/electron/preload.mjs b/packages/electron/preload.mjs index f3f96667..7cecec8c 100644 --- a/packages/electron/preload.mjs +++ b/packages/electron/preload.mjs @@ -158,14 +158,41 @@ ipcRenderer.on('openchamber:emit', (_evt, payload) => { dispatchNativeEvent(event, payload.detail); }); +const relayDevTunnelPorts = new Map(); +let relayDevTunnelHandler = null; +ipcRenderer.on('openchamber:relay-dev-tunnel-connect', (event, payload) => { + if (!isLocalPage || !payload || typeof payload.connectionId !== 'string' || !event.ports?.[0]) return; + const port = event.ports[0]; + relayDevTunnelPorts.set(payload.connectionId, port); + port.onmessage = (messageEvent) => relayDevTunnelHandler?.({ + connectionId: payload.connectionId, + remotePort: payload.remotePort, + message: messageEvent.data, + }); + port.start(); + relayDevTunnelHandler?.({ connectionId: payload.connectionId, remotePort: payload.remotePort, message: { type: 'connect' } }); +}); + // The desktop bridge is exposed on all pages; the main-process gate in // ipcMain.handle('openchamber:invoke') decides per-command what is safe // for non-local callers (window/host-switcher ops yes, file/shell ops // no). See COMMANDS_SAFE_FOR_REMOTE in main.mjs. -contextBridge.exposeInMainWorld('__OPENCHAMBER_DESKTOP__', { +const desktopBridge = { invoke: (cmd, args) => ipcRenderer.invoke('openchamber:invoke', cmd, args || {}), openDialog: (options) => ipcRenderer.invoke('openchamber:dialog:open', options || {}), grantFileAccess: (filePath) => ipcRenderer.invoke('openchamber:file:grant-existing', filePath), openExternal: (url) => ipcRenderer.invoke('openchamber:invoke', 'desktop_open_external_url', { url }), listen: async (event, handler) => addListener(event, handler), -}); +}; + +if (isLocalPage) { + desktopBridge.relayDevTunnelListen = (handler) => { + relayDevTunnelHandler = typeof handler === 'function' ? handler : null; + }; + desktopBridge.relayDevTunnelPost = (connectionId, message) => { + relayDevTunnelPorts.get(connectionId)?.postMessage(message); + if (message?.type === 'close') relayDevTunnelPorts.delete(connectionId); + }; +} + +contextBridge.exposeInMainWorld('__OPENCHAMBER_DESKTOP__', desktopBridge); diff --git a/packages/electron/relay-dev-tunnel.mjs b/packages/electron/relay-dev-tunnel.mjs new file mode 100644 index 00000000..de9bd6c1 --- /dev/null +++ b/packages/electron/relay-dev-tunnel.mjs @@ -0,0 +1,121 @@ +import net from 'node:net'; +import { randomUUID } from 'node:crypto'; + +const CONNECTION_READY_TIMEOUT_MS = 15_000; + +const listen = (server) => new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + server.off('error', reject); + const address = server.address(); + const port = Number(address?.port); + if (!Number.isInteger(port) || port <= 0) { + reject(new Error('Failed to bind a local relay tunnel port')); + return; + } + resolve(port); + }); +}); + +const messageData = (event) => { + if (event?.type === 'ready' || event?.type === 'data' || event?.type === 'close') return event; + return event?.data ?? null; +}; + +export const createRelayDevTunnelBridge = ({ createMessageChannel, logger = console } = {}) => { + const tunnels = new Map(); + + const closeTunnel = (key) => { + const tunnel = tunnels.get(key); + if (!tunnel) return false; + tunnels.delete(key); + for (const connection of tunnel.connections.values()) connection.close(); + try { tunnel.server.close(); } catch { /* already closing */ } + return true; + }; + + return { + async open({ targetKey, remotePort, webContents }) { + const port = Number.parseInt(String(remotePort), 10); + if (!Number.isInteger(port) || port <= 0 || port > 65535) throw new Error('A valid remote port is required'); + if (!targetKey) throw new Error('A relay target key is required'); + if (!webContents || webContents.isDestroyed?.()) throw new Error('The desktop window is unavailable'); + + const key = `${webContents.id}|${targetKey}|${port}`; + const existing = tunnels.get(key); + if (existing) return { localPort: existing.localPort, reused: true }; + + const connections = new Map(); + const server = net.createServer((socket) => { + socket.setNoDelay(true); + socket.pause(); + const connectionId = randomUUID(); + const { port1, port2 } = createMessageChannel(); + let closed = false; + const readyTimer = setTimeout(() => close(), CONNECTION_READY_TIMEOUT_MS); + + const close = () => { + if (closed) return; + closed = true; + clearTimeout(readyTimer); + connections.delete(connectionId); + try { port1.postMessage({ type: 'close' }); } catch { /* already closed */ } + try { socket.destroy(); } catch { /* already closed */ } + try { port1.close(); } catch { /* already closed */ } + }; + connections.set(connectionId, { close }); + + port1.on('message', (event) => { + const message = messageData(event); + if (!message) return; + if (message.type === 'ready') { + clearTimeout(readyTimer); + socket.resume(); + return; + } + if (message.type === 'data' && message.data) { + socket.write(Buffer.from(message.data)); + return; + } + if (message.type === 'close') close(); + }); + port1.on('close', close); + port1.start?.(); + + socket.on('data', (chunk) => { + if (closed) return; + port1.postMessage({ type: 'data', data: Uint8Array.from(chunk) }); + }); + socket.on('error', close); + socket.on('close', close); + + try { + webContents.postMessage('openchamber:relay-dev-tunnel-connect', { connectionId, remotePort: port }, [port2]); + } catch (error) { + logger.warn?.(`[dev-tunnel] failed to hand relay connection to renderer: ${error?.message || error}`); + close(); + } + }); + + const localPort = await listen(server); + server.on('error', (error) => logger.warn?.(`[dev-tunnel] relay listener failed: ${error?.message || error}`)); + tunnels.set(key, { server, connections, localPort }); + webContents.once?.('destroyed', () => closeTunnel(key)); + return { localPort, reused: false }; + }, + + closeAll() { + for (const key of [...tunnels.keys()]) closeTunnel(key); + }, + + closeForWebContents(webContentsId) { + let closed = 0; + const prefix = `${webContentsId}|`; + for (const key of [...tunnels.keys()]) { + if (!key.startsWith(prefix)) continue; + if (closeTunnel(key)) closed += 1; + } + return closed; + }, + }; +}; diff --git a/packages/electron/relay-dev-tunnel.test.mjs b/packages/electron/relay-dev-tunnel.test.mjs new file mode 100644 index 00000000..a370db82 --- /dev/null +++ b/packages/electron/relay-dev-tunnel.test.mjs @@ -0,0 +1,96 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import net from 'node:net'; +import { MessageChannel } from 'node:worker_threads'; +import { createRelayDevTunnelBridge } from './relay-dev-tunnel.mjs'; + +const bridges = []; + +afterEach(() => { + while (bridges.length) bridges.pop().closeAll(); +}); + +describe('relay dev tunnel bridge', () => { + test('pipes a local browser connection through a renderer-owned message port', async () => { + let nextPort; + const webContents = { + id: 7, + isDestroyed: () => false, + once: () => {}, + postMessage: (_channel, payload, ports) => { + nextPort = ports[0]; + nextPort.on('message', (message) => { + if (message.type !== 'data') return; + expect(Buffer.from(message.data).toString()).toContain('GET /docs HTTP/1.1'); + nextPort.postMessage({ type: 'data', data: Buffer.from('HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok') }); + nextPort.postMessage({ type: 'close' }); + }); + nextPort.start(); + expect(payload.remotePort).toBe(4322); + nextPort.postMessage({ type: 'ready' }); + }, + }; + const bridge = createRelayDevTunnelBridge({ createMessageChannel: () => new MessageChannel(), logger: { warn: () => {} } }); + bridges.push(bridge); + const { localPort } = await bridge.open({ targetKey: 'host:exe', remotePort: 4322, webContents }); + + const response = await new Promise((resolve, reject) => { + const socket = net.connect({ host: '127.0.0.1', port: localPort }, () => socket.write('GET /docs HTTP/1.1\r\nHost: localhost\r\n\r\n')); + let data = ''; + socket.on('data', (chunk) => { data += chunk; }); + socket.on('close', () => resolve(data)); + socket.on('error', reject); + }); + expect(response).toContain('\r\n\r\nok'); + }); + + test('reuses one local listener for the same window, runtime, and port', async () => { + const webContents = { id: 9, isDestroyed: () => false, once: () => {}, postMessage: () => {} }; + const bridge = createRelayDevTunnelBridge({ createMessageChannel: () => new MessageChannel() }); + bridges.push(bridge); + const first = await bridge.open({ targetKey: 'host:exe', remotePort: 4322, webContents }); + const second = await bridge.open({ targetKey: 'host:exe', remotePort: 4322, webContents }); + expect(second).toEqual({ localPort: first.localPort, reused: true }); + }); + + test('tells the renderer when the local browser connection closes', async () => { + const rendererClosed = new Promise((resolve) => { + const webContents = { + id: 11, + isDestroyed: () => false, + once: () => {}, + postMessage: (_channel, _payload, ports) => { + const rendererPort = ports[0]; + rendererPort.on('message', (message) => { + if (message.type === 'close') resolve(); + }); + rendererPort.start(); + rendererPort.postMessage({ type: 'ready' }); + }, + }; + const bridge = createRelayDevTunnelBridge({ createMessageChannel: () => new MessageChannel() }); + bridges.push(bridge); + void bridge.open({ targetKey: 'host:exe', remotePort: 4322, webContents }).then(({ localPort }) => { + const socket = net.connect({ host: '127.0.0.1', port: localPort }, () => socket.destroy()); + }); + }); + + await rendererClosed; + }); + + test('closes only listeners owned by the requested desktop window', async () => { + const bridge = createRelayDevTunnelBridge({ createMessageChannel: () => new MessageChannel() }); + bridges.push(bridge); + const windowOne = { id: 21, isDestroyed: () => false, once: () => {}, postMessage: () => {} }; + const windowTwo = { id: 22, isDestroyed: () => false, once: () => {}, postMessage: () => {} }; + const first = await bridge.open({ targetKey: 'host:one', remotePort: 4322, webContents: windowOne }); + const second = await bridge.open({ targetKey: 'host:two', remotePort: 4322, webContents: windowTwo }); + + expect(bridge.closeForWebContents(windowOne.id)).toBe(1); + await expect(new Promise((resolve, reject) => { + const socket = net.connect({ host: '127.0.0.1', port: first.localPort }, resolve); + socket.on('error', reject); + })).rejects.toThrow(); + const remaining = await bridge.open({ targetKey: 'host:two', remotePort: 4322, webContents: windowTwo }); + expect(remaining).toEqual({ localPort: second.localPort, reused: true }); + }); +}); diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index e52bcb1e..21581e28 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -348,7 +348,12 @@ function App({ apis }: AppProps) { void refreshGitHubAuthStatus(apis.github, { force: true }); void refreshLinearAuthStatus(apis.linear, { force: true }); - }, [apis.github, apis.linear, embeddedSessionChat, refreshGitHubAuthStatus, refreshLinearAuthStatus]); + // `apis` is the same object across an instance switch, so without the epoch + // this ran once for the whole app session and both statuses kept describing + // whichever instance happened to be connected at startup. `isConnected` is + // here to re-ask, not to gate: both integrations answer independently of + // OpenCode, but a switch can race the transport and the retry is deduped. + }, [apis.github, apis.linear, embeddedSessionChat, isConnected, refreshGitHubAuthStatus, refreshLinearAuthStatus, runtimeEndpointEpoch]); useAppFontEffects(); diff --git a/packages/ui/src/apps/MobileChangesSurface.tsx b/packages/ui/src/apps/MobileChangesSurface.tsx index 463886b3..50c9c7b3 100644 --- a/packages/ui/src/apps/MobileChangesSurface.tsx +++ b/packages/ui/src/apps/MobileChangesSurface.tsx @@ -5,7 +5,9 @@ import { toast } from '@/components/ui'; import { Button } from '@/components/ui/button'; import { ScrollShadow } from '@/components/ui/ScrollShadow'; import { ChangesPanel, type ChangesGroupConfig } from '@/components/views/git/ChangesPanel'; +import { BranchSelector } from '@/components/views/git/BranchSelector'; import { CommitSection } from '@/components/views/git/CommitSection'; +import { DirtyBranchSwitchDialog } from '@/components/views/git/DirtyBranchSwitchDialog'; import { SyncActions } from '@/components/views/git/SyncActions'; import { PierreDiffViewer } from '@/components/views/PierreDiffViewer'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; @@ -19,6 +21,7 @@ import { getLanguageFromExtension, isImageFile } from '@/lib/toolHelpers'; import { useGitStore, useGitStatus, + useGitBranches, useIsGitRepo, useGitLoadingStatus, } from '@/stores/useGitStore'; @@ -65,6 +68,7 @@ export const MobileChangesSurface: React.FC = ({ onCl const { rootIsGitRepo, gitDirectory, nestedRepos } = useNestedGitDirectory(rootDirectory || null); const currentDirectory = gitDirectory ?? rootDirectory; const status = useGitStatus(currentDirectory || null); + const branches = useGitBranches(currentDirectory || null); const isGitRepo = useIsGitRepo(currentDirectory || null); const isLoadingStatus = useGitLoadingStatus(currentDirectory || null); const setActiveDirectory = useGitStore((state) => state.setActiveDirectory); @@ -104,6 +108,7 @@ export const MobileChangesSurface: React.FC = ({ onCl const [remoteUrl, setRemoteUrl] = React.useState(null); const [diffLoadError, setDiffLoadError] = React.useState(null); const [diffRetryNonce, setDiffRetryNonce] = React.useState(0); + const [pendingDirtySwitchBranch, setPendingDirtySwitchBranch] = React.useState(null); const changeEntries = React.useMemo(() => { const files = status?.files ?? []; @@ -157,6 +162,55 @@ export const MobileChangesSurface: React.FC = ({ onCl } }, [currentDirectory, fetchBranches, fetchStatus, git, t]); + const localBranches = React.useMemo( + () => (branches?.all ?? []).filter((branch) => !branch.startsWith('remotes/')).sort(), + [branches], + ); + + const remoteBranches = React.useMemo( + () => (branches?.all ?? []) + .filter((branch) => branch.startsWith('remotes/')) + .map((branch) => branch.replace(/^remotes\//, '')) + .sort(), + [branches], + ); + + const performCheckout = React.useCallback(async (branch: string) => { + if (!currentDirectory) return; + const normalized = branch.replace(/^remotes\//, ''); + try { + const result = await git.checkoutBranch(currentDirectory, normalized); + toast.success(t('gitView.toast.checkedOut', { name: result.branch || normalized })); + await refreshStatusAndBranches(); + } catch (error) { + toast.error(error instanceof Error ? error.message : t('gitView.toast.checkoutFailed', { name: normalized })); + } + }, [currentDirectory, git, refreshStatusAndBranches, t]); + + const handleCheckoutBranch = React.useCallback((branch: string) => { + const normalized = branch.replace(/^remotes\//, ''); + if ((status?.files?.length ?? 0) > 0) { + setPendingDirtySwitchBranch(normalized); + return; + } + void performCheckout(normalized); + }, [performCheckout, status?.files]); + + const handleCreateBranch = React.useCallback(async (branch: string, remote?: GitRemote) => { + if (!currentDirectory) return; + try { + await git.createBranch(currentDirectory, branch, status?.current ?? 'HEAD'); + await git.checkoutBranch(currentDirectory, branch); + if (remote) { + await git.gitPush(currentDirectory, { remote: remote.name, branch, options: ['--set-upstream'] }); + } + await refreshStatusAndBranches(); + } catch (error) { + toast.error(error instanceof Error ? error.message : t('gitView.toast.createBranchFailed')); + throw error; + } + }, [currentDirectory, git, refreshStatusAndBranches, status?.current, t]); + const refreshRemotes = React.useCallback(async () => { if (!currentDirectory) { setRemotes([]); @@ -542,9 +596,19 @@ export const MobileChangesSurface: React.FC = ({ onCl ) : null}

{t('mobile.nav.changes')}

-

- {status?.current || currentDirectory} -

+ void handleCheckoutBranch(branch)} + onCreate={handleCreateBranch} + remotes={effectiveRemotes} + disabled={isLoadingStatus} + directory={currentDirectory} + switchBlockedNotice={(status?.files?.length ?? 0) > 0 ? t('gitView.branch.switchBlockedNotice') : null} + />
= ({ onCl )} + { if (!open) setPendingDirtySwitchBranch(null); }} + targetBranch={pendingDirtySwitchBranch ?? ''} + changedFileCount={status?.files?.length ?? 0} + onCommitAndSwitch={async (message, pushAfter) => { + const branch = pendingDirtySwitchBranch; + if (!branch || !currentDirectory) return; + const sourceBranch = status?.current ?? null; + await git.createGitCommit(currentDirectory, message, { addAll: true }); + let pushedRemoteName: string | null = null; + if (pushAfter) { + const trackingRemoteName = status?.tracking?.split('/')[0]; + const remote = effectiveRemotes.find((entry) => entry.name === trackingRemoteName) ?? effectiveRemotes[0]; + try { + if (!remote) throw new Error(t('mobile.changes.noRemote')); + await git.gitPush(currentDirectory, status?.tracking + ? { remote: remote.name } + : { remote: remote.name, branch: sourceBranch ?? undefined, options: ['--set-upstream'] }); + pushedRemoteName = remote.name; + } catch { + toast.error(t('gitView.dirtySwitch.pushFailed')); + await refreshStatusAndBranches(); + setPendingDirtySwitchBranch(null); + return; + } + } + toast.success(sourceBranch + ? pushedRemoteName + ? t('gitView.toast.pushedToUpstream', { name: pushedRemoteName }) + : t('gitView.dirtySwitch.committedNotPushed', { branch: sourceBranch }) + : t('gitView.toast.commitCreated')); + await refreshStatusAndBranches(); + setPendingDirtySwitchBranch(null); + await performCheckout(branch); + }} + onGenerateMessage={async () => { + if (!currentDirectory) return ''; + const paths = (status?.files ?? []).map((file) => file.path).sort(); + const { message } = await generateCommitMessage(currentDirectory, paths); + return message.subject?.trim() ?? ''; + }} + onRevertAndSwitch={async () => { + const branch = pendingDirtySwitchBranch; + if (!branch || !currentDirectory) return; + await handleRevertAll((status?.files ?? []).map((file) => file.path)); + const fresh = await git.getGitStatus(currentDirectory); + if (!fresh.isClean && (fresh.files?.length ?? 0) > 0) { + toast.error(t('gitView.dirtySwitch.revertIncomplete')); + return; + } + setPendingDirtySwitchBranch(null); + await performCheckout(branch); + }} + /> ); }; diff --git a/packages/ui/src/apps/runtimeEndpointReset.ts b/packages/ui/src/apps/runtimeEndpointReset.ts index cd1f33ce..afd90a7d 100644 --- a/packages/ui/src/apps/runtimeEndpointReset.ts +++ b/packages/ui/src/apps/runtimeEndpointReset.ts @@ -11,6 +11,13 @@ import { useFileSearchStore } from '@/stores/useFileSearchStore'; import { useGitStore } from '@/stores/useGitStore'; import { useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore'; import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore'; +import { useLinearAuthStore } from '@/stores/useLinearAuthStore'; +import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; +import { useQuotaStore } from '@/stores/useQuotaStore'; +import { useMcpStore } from '@/stores/useMcpStore'; +import { useSkillsStore } from '@/stores/useSkillsStore'; +import { useAgentMemoryStore } from '@/stores/useAgentMemoryStore'; +import { useUIStore } from '@/stores/useUIStore'; import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore'; import { useTerminalStore } from '@/stores/useTerminalStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; @@ -68,6 +75,22 @@ export const resetAppForRuntimeEndpointChange = (detail: RuntimeEndpointChangedD useGitHubPrStatusStore.getState().resetForRuntimeSwitch(); useSessionFoldersStore.getState().resetForRuntimeSwitch(detail.runtimeKey); useFilesViewTabsStore.getState().resetForRuntimeSwitch(detail.runtimeKey); + // Linear and GitHub are authenticated on the instance, not in the browser. + // Left in place, the previous instance's login stayed visible and usable — + // its rail tab, its issue pickers, its work-status rows — against a runtime + // that has no such integration. `App` re-asks once the new instance answers. + useLinearAuthStore.getState().resetForRuntimeSwitch(); + useGitHubAuthStore.getState().resetForRuntimeSwitch(); + // Work-status readouts served from the instance: quotas, MCP servers, skills + // and agent memory. All were cached globally or by directory alone, so they + // reported the previous instance until something happened to refetch. + useQuotaStore.getState().resetForRuntimeSwitch(); + useMcpStore.getState().resetForRuntimeSwitch(); + useSkillsStore.getState().resetForRuntimeSwitch(); + useAgentMemoryStore.getState().reset(); + // The Linear team filter names a team in one workspace. Carried across, it + // filters the new instance's issue list down to nothing. + useUIStore.getState().applyLinearIssueListFiltersForRuntime(); useSessionUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey); resetStreamingState(); queueMicrotask(() => void syncDesktopSettings()); diff --git a/packages/ui/src/assets/provider-logos/exe-dev.svg b/packages/ui/src/assets/provider-logos/exe-dev.svg new file mode 100644 index 00000000..c761f9e5 --- /dev/null +++ b/packages/ui/src/assets/provider-logos/exe-dev.svg @@ -0,0 +1,9 @@ + + exe.dev + + + + + + + diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 371861f0..a62582c3 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -2584,6 +2584,7 @@ const ChatInputComponent: React.FC = ({ selectedDraftDirectory, selectedDraftBranchLabel, selectedDraftBranchIsKnown, + selectedDraftDirectoryHasUncommittedChanges, projectRootBranchOption, worktreeBranchOptions, draftBranchItems, @@ -2851,6 +2852,7 @@ const ChatInputComponent: React.FC = ({ selectedDirectory={selectedDraftDirectory} selectedBranchLabel={selectedDraftBranchLabel} selectedBranchIsKnown={selectedDraftBranchIsKnown} + hasUncommittedChanges={selectedDraftDirectoryHasUncommittedChanges} projectRootBranchOption={projectRootBranchOption} worktreeBranchOptions={worktreeBranchOptions} branchItems={draftBranchItems} @@ -2865,6 +2867,7 @@ const ChatInputComponent: React.FC = ({ = ({ selectedDirectory={selectedDraftDirectory} selectedBranchLabel={selectedDraftBranchLabel} selectedBranchIsKnown={selectedDraftBranchIsKnown} + hasUncommittedChanges={selectedDraftDirectoryHasUncommittedChanges} projectRootBranchOption={projectRootBranchOption} worktreeBranchOptions={worktreeBranchOptions} branchItems={draftBranchItems} diff --git a/packages/ui/src/components/chat/composer/DOCUMENTATION.md b/packages/ui/src/components/chat/composer/DOCUMENTATION.md index 03264fdf..9d842660 100644 --- a/packages/ui/src/components/chat/composer/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/composer/DOCUMENTATION.md @@ -169,7 +169,9 @@ and the send path reading the same grammar. recorded before a queued write could resurrect it. - `state/useDraftTarget.ts` — the draft can target a directory that does not exist yet (a worktree being created). It must survive not appearing in the - branch list, or the selector snaps back to the project root mid-creation. + branch list, or the selector snaps back to the project root mid-creation. It + also owns the advisory dirty state for the selected directory, clearing it as + soon as the target changes so a warning never names a previous branch. - `ui/DraftTargetSelectors.tsx` owns the controlled project/worktree picker state and registers its application shortcuts locally. The selectors only consume their shared prefix while the draft target UI is mounted. diff --git a/packages/ui/src/components/chat/composer/state/useDraftTarget.ts b/packages/ui/src/components/chat/composer/state/useDraftTarget.ts index d4586591..cd774c29 100644 --- a/packages/ui/src/components/chat/composer/state/useDraftTarget.ts +++ b/packages/ui/src/components/chat/composer/state/useDraftTarget.ts @@ -25,6 +25,7 @@ import { buildSessionTargetOptions } from '@/sync/session-worktree-contract'; import { normalizePath } from '../attachments/filePaths'; import { CHAT_DRAFT_PROJECT_ID } from '@/lib/chatDirectories'; import { useI18n } from '@/lib/i18n'; +import { getGitStatus } from '@/lib/gitApi'; /** How long a cached branch list is served before it is refreshed. */ const BRANCHES_SWR_TTL_MS = 30_000; @@ -98,6 +99,7 @@ export function useDraftTarget(enabled: boolean) { const hasDraftBranchList = Boolean(selectedDraftProjectBranches?.all); const fetchBranches = useGitStore((state) => state.fetchBranches); const [isDiscoveringDraftBranches, setIsDiscoveringDraftBranches] = React.useState(false); + const [dirtyDraftDirectory, setDirtyDraftDirectory] = React.useState(null); React.useEffect(() => { if (!enabled || !selectedDraftProjectPath || !runtimeGit || selectedDraftProjectIsGitRepo !== null) { @@ -189,6 +191,35 @@ export function useDraftTarget(enabled: boolean) { [newSessionDraft?.bootstrapPendingDirectory, newSessionDraft?.directoryOverride, selectedDraftProjectPath], ); + React.useEffect(() => { + if ( + !enabled + || !selectedDraftDirectory + || selectedDraftProject?.kind === 'chat' + || newSessionDraft?.pendingWorktreeRequestId + || newSessionDraft?.bootstrapPendingDirectory + ) { + setDirtyDraftDirectory(null); + return; + } + + let cancelled = false; + setDirtyDraftDirectory(null); + getGitStatus(selectedDraftDirectory, { mode: 'light' }) + .then((status) => { + if (!cancelled && (status.files?.length ?? 0) > 0) { + setDirtyDraftDirectory(selectedDraftDirectory); + } + }) + .catch(() => { + if (!cancelled) setDirtyDraftDirectory(null); + }); + + return () => { + cancelled = true; + }; + }, [enabled, newSessionDraft?.bootstrapPendingDirectory, newSessionDraft?.pendingWorktreeRequestId, selectedDraftDirectory, selectedDraftProject?.kind]); + const shouldKeepMissingSelectedDraftDirectory = React.useMemo(() => { const pendingDirectory = normalizePath(newSessionDraft?.bootstrapPendingDirectory ?? null); return Boolean( @@ -306,6 +337,7 @@ export function useDraftTarget(enabled: boolean) { selectedDraftDirectory, selectedDraftBranchLabel, selectedDraftBranchIsKnown, + selectedDraftDirectoryHasUncommittedChanges: dirtyDraftDirectory === selectedDraftDirectory, projectRootBranchOption, worktreeBranchOptions, draftBranchItems, diff --git a/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx b/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx index 5368d208..a025ae4c 100644 --- a/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx +++ b/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx @@ -12,6 +12,7 @@ import React from 'react'; import { Icon } from '@/components/icon/Icon'; import { Input } from '@/components/ui/input'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { shouldDismissDropdown } from '@/components/ui/dropdown-navigation'; import { Select, @@ -44,6 +45,7 @@ export interface DraftTargetProps { selectedDirectory: string | null; selectedBranchLabel: string | null; selectedBranchIsKnown: boolean; + hasUncommittedChanges: boolean; projectRootBranchOption: BranchOption | null; worktreeBranchOptions: readonly BranchOption[]; branchItems: readonly BranchOption[]; @@ -92,14 +94,39 @@ function ProjectLabel({ project, theme }: { project: DraftTargetProject; theme: } /** Desktop: inline project and branch selects. */ +/** How long the dirty-directory tooltip announces itself before becoming hover-only. */ +const DIRTY_TOOLTIP_FLASH_MS = 5000; + +/** + * Opens the tooltip for a few seconds when the dirty state first appears, so + * the warning is seen without hovering, then hands control back to hover. + */ +function useDirtyFlashTooltip(hasUncommittedChanges: boolean) { + const [open, setOpen] = React.useState(false); + + React.useEffect(() => { + if (!hasUncommittedChanges) { + setOpen(false); + return; + } + setOpen(true); + const timer = window.setTimeout(() => setOpen(false), DIRTY_TOOLTIP_FLASH_MS); + return () => window.clearTimeout(timer); + }, [hasUncommittedChanges]); + + return { open, onOpenChange: setOpen }; +} + export function DraftTargetSelectors(props: DraftTargetProps) { const { t } = useI18n(); + const dirtyTooltip = useDirtyFlashTooltip(props.hasUncommittedChanges); const { projects, selectedProject, selectedDirectory, selectedBranchLabel, selectedBranchIsKnown, + hasUncommittedChanges, projectRootBranchOption, worktreeBranchOptions, branchItems, @@ -176,16 +203,32 @@ export function DraftTargetSelectors(props: DraftTargetProps) { onValueChange={handleDirectoryChange} disableGlobalShortcuts > - - - {selectedBranchLabel ?? t('chat.chatInput.branch')} - - + + + + {hasUncommittedChanges ? ( + + ) : null} + + {selectedBranchLabel ?? t('chat.chatInput.branch')} + + + + {hasUncommittedChanges ? ( + + {t('chat.draftDirtyNotice.tooltip')} + + ) : null} + {projectRootBranchOption ? ( @@ -228,11 +271,12 @@ export function DraftTargetSelectors(props: DraftTargetProps) { /** Mobile: buttons that open the bottom sheets below. */ export function MobileDraftTargetTriggers( - props: Pick + props: Pick & { onOpenPicker: (picker: 'project' | 'branch') => void }, ) { const { t } = useI18n(); - const { selectedProject, selectedBranchLabel, showBranchSelector, theme, onOpenPicker } = props; + const { selectedProject, selectedBranchLabel, showBranchSelector, hasUncommittedChanges, theme, onOpenPicker } = props; + const dirtyTooltip = useDirtyFlashTooltip(hasUncommittedChanges); return (
@@ -247,14 +291,30 @@ export function MobileDraftTargetTriggers( {showBranchSelector ? ( - + + + + + {hasUncommittedChanges ? ( + + {t('chat.draftDirtyNotice.tooltip')} + + ) : null} + ) : null}
); diff --git a/packages/ui/src/components/chat/work-status/DOCUMENTATION.md b/packages/ui/src/components/chat/work-status/DOCUMENTATION.md index 00a1abd3..d96a6b6d 100644 --- a/packages/ui/src/components/chat/work-status/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/work-status/DOCUMENTATION.md @@ -347,6 +347,28 @@ the matching header dropdown: discovered relative to the active project. It does not wrap the call in `runBackgroundNetworkTask`: the store already gates its own fetch. +Usage waits for the instance to say it is initialised. Quota providers report +themselves as configured only once the instance can read their credentials, +which on a remote instance is not true when the UI mounts — a fetch fired at +mount gets "nothing configured" for every provider, and since each one then has +a result, nothing asks again until the three-minute refresh. That is why Usage +could stay missing from the panel until Settings -> Usage forced a fresh fetch. +`useQuotaStore.ensureLoadedForRuntime` owns both the readiness rule and the +once-per-instance bookkeeping, so every caller can ask on each connection +change. + +### These readouts belong to the connected instance + +Quotas, MCP status, skills, agent memory and the Linear/GitHub logins are all +served by whichever OpenChamber instance is connected, and each was cached +globally or by directory alone — which two instances can share. A switch left +the previous instance's answers on screen, and its Linear login usable against +a runtime that has no Linear. `apps/runtimeEndpointReset.ts` now drops all of +them, each store guarding its own in-flight requests with a generation so a +response for the previous instance cannot land in the new one. The MCP and +skills effects take `isConnected` as a dependency — not a gate — because +`directory` alone does not change when both instances hold the same path. + The panel now performs these itself, silently and through the background-network gate, so it cannot compete with chat bootstrap traffic for sockets. Usage additionally provides an explicit refresh action in its section diff --git a/packages/ui/src/components/chat/work-status/WorkStatusContextSection.tsx b/packages/ui/src/components/chat/work-status/WorkStatusContextSection.tsx index b549a38a..fe188fc8 100644 --- a/packages/ui/src/components/chat/work-status/WorkStatusContextSection.tsx +++ b/packages/ui/src/components/chat/work-status/WorkStatusContextSection.tsx @@ -15,6 +15,7 @@ import { resolveProjectContextId } from '@/lib/projectContextApi'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useMobileAppActions } from '@/apps/mobileAppContext'; import { useLinearAuthStore } from '@/stores/useLinearAuthStore'; +import { useConfigStore } from '@/stores/useConfigStore'; import { useUIStore } from '@/stores/useUIStore'; import { WorkStatusCollapsibleSection, WorkStatusRow, WorkStatusValue } from './WorkStatusPrimitives'; import { useReportWorkStatusPresence } from './presenceContext'; @@ -61,9 +62,15 @@ export const WorkStatusContextSection: React.FC = ({ sessionId, directory // here: `loadSkills` already gates its own fetch, and wrapping it again // would hold a second slot idle for the length of the first. const loadSkills = useSkillsStore((state) => state.loadSkills); + // `isConnected` is a dependency, not a gate: skills are discovered on the + // connected instance and their caches are dropped when instances switch, so + // the count has to be asked for again once the new instance is up. Two + // instances can hold the same project path, which leaves `directory` + // unchanged across a switch. + const isConnected = useConfigStore((state) => state.isConnected); React.useEffect(() => { void loadSkills(); - }, [directory, loadSkills]); + }, [directory, isConnected, loadSkills]); /** * What this session carries. Read from the server diff --git a/packages/ui/src/components/chat/work-status/WorkStatusMcpSection.tsx b/packages/ui/src/components/chat/work-status/WorkStatusMcpSection.tsx index d44a846e..2ac0efb0 100644 --- a/packages/ui/src/components/chat/work-status/WorkStatusMcpSection.tsx +++ b/packages/ui/src/components/chat/work-status/WorkStatusMcpSection.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { useI18n } from '@/lib/i18n'; import { Switch } from '@/components/ui/switch'; import { useMcpStore } from '@/stores/useMcpStore'; +import { useConfigStore } from '@/stores/useConfigStore'; import { McpIcon } from '@/components/icons/McpIcon'; import { runBackgroundNetworkTask } from '@/lib/background-network'; import { toast } from 'sonner'; @@ -28,6 +29,7 @@ export const WorkStatusMcpSection: React.FC = ({ directory }) => { const ensureMcpFresh = useMcpStore((state) => state.ensureFresh); const connect = useMcpStore((state) => state.connect); const disconnect = useMcpStore((state) => state.disconnect); + const isConnected = useConfigStore((state) => state.isConnected); const [busyServer, setBusyServer] = React.useState(null); // The panel must not depend on the header dropdown having been mounted or @@ -35,9 +37,12 @@ export const WorkStatusMcpSection: React.FC = ({ directory }) => { // compete with chat bootstrap traffic for sockets. The section remounts on // every session switch, so it only asks for a status that is missing or // older than a minute; connect/disconnect/auth refresh on their own. + // `isConnected` is a dependency, not a gate: MCP status is cached by + // directory alone and dropped on an instance switch, and two instances can + // hold the same project path — so the switch itself has to trigger the ask. React.useEffect(() => { void runBackgroundNetworkTask(() => ensureMcpFresh({ directory, silent: true, maxAgeMs: MCP_STATUS_MAX_AGE_MS })); - }, [directory, ensureMcpFresh]); + }, [directory, ensureMcpFresh, isConnected]); const mcpServers = React.useMemo( () => Object.entries(mcpStatus ?? {}).sort(([left], [right]) => left.localeCompare(right)), diff --git a/packages/ui/src/components/chat/work-status/WorkStatusUsageSection.tsx b/packages/ui/src/components/chat/work-status/WorkStatusUsageSection.tsx index 3484512e..41deccbb 100644 --- a/packages/ui/src/components/chat/work-status/WorkStatusUsageSection.tsx +++ b/packages/ui/src/components/chat/work-status/WorkStatusUsageSection.tsx @@ -43,9 +43,10 @@ export const WorkStatusUsageSection: React.FC = () => { const groups = useUsageProviderGroups(); const displayMode = useQuotaStore((state) => state.displayMode); const isLoading = useQuotaStore((state) => state.isLoading); - const quotaResults = useQuotaStore((state) => state.results); const dropdownProviderIds = useQuotaStore((state) => state.dropdownProviderIds); const fetchQuotas = useQuotaStore((state) => state.fetchQuotas); + const ensureQuotasLoadedForRuntime = useQuotaStore((state) => state.ensureLoadedForRuntime); + const isInitialized = useConfigStore((state) => state.isInitialized); const timeFormatPreference = useUIStore((state) => state.timeFormatPreference); const currentProviderId = useConfigStore((state) => state.currentProviderId); @@ -54,17 +55,13 @@ export const WorkStatusUsageSection: React.FC = () => { // `useQuotaAutoRefresh` only schedules an interval — it never performs the // first fetch. That was owned by the header dropdown's open handler, so the - // panel stayed empty until the user opened it. Kick off the initial load for - // any enabled provider that has not reported yet, background-gated so it - // cannot compete with chat bootstrap traffic. + // panel stayed empty until the user opened it. `ensureLoadedForRuntime` owns + // the once-per-instance load and its readiness rule; asking again is a no-op, + // so this is safe to run on every connection change. React.useEffect(() => { - if (isLoading || dropdownProviderIds.length === 0) return; - const missingProvider = dropdownProviderIds.some( - (providerId) => !quotaResults.some((result) => result.providerId === providerId), - ); - if (!missingProvider) return; - void runBackgroundNetworkTask(() => fetchQuotas(dropdownProviderIds)); - }, [dropdownProviderIds, fetchQuotas, isLoading, quotaResults]); + if (!isInitialized) return; + void runBackgroundNetworkTask(() => ensureQuotasLoadedForRuntime()); + }, [ensureQuotasLoadedForRuntime, isInitialized]); React.useEffect(() => { if (groups.length === 0) return; diff --git a/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx b/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx index 8cd3022c..92a34faf 100644 --- a/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx +++ b/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx @@ -14,6 +14,7 @@ import { toast } from '@/components/ui'; import { isElectronShell, isDesktopShell } from '@/lib/desktop'; import { Icon } from "@/components/icon/Icon"; import { useUIStore } from '@/stores/useUIStore'; +import { useConfigStore } from '@/stores/useConfigStore'; import { useI18n } from '@/lib/i18n'; import { desktopHostProbe, @@ -37,6 +38,14 @@ import { resolveCurrentDesktopHost, runtimeKeyForDesktopHost, } from '@/lib/desktopCurrentHost'; +import { + getDesktopHostStatusSnapshot, + probeDesktopHosts, + setDesktopHostStatus, + pruneDesktopHostStatuses, + subscribeDesktopHostStatuses, + type DesktopHostStatus, +} from '@/lib/desktopHostStatus'; import { scheduleDesktopHostCandidateRefresh } from '@/lib/desktopRelayRestore'; import { adoptRelayTunnel } from '@/lib/relay/runtime-tunnel'; import { createRelayTunnelClient } from '@/lib/relay/tunnel-client'; @@ -52,17 +61,7 @@ import { const SSH_CONNECT_TIMEOUT_MS = 90_000; const SSH_CONNECT_CANCELLED_ERROR = 'SSH connection cancelled'; -type HostStatus = { - status: HostProbeResult['status']; - latencyMs: number; - /** Which transport the successful probe used (multi-transport hosts). */ - via?: 'relay'; -}; - -// Last known statuses survive the dropdown unmounting (it remounts on every -// open). Rows show the previous result immediately — refreshed quietly by the -// open-probe — instead of shouting "Unknown" at the user for a few seconds. -const lastKnownHostStatuses: Record = {}; +type HostStatus = DesktopHostStatus; type HostDisplayStatus = HostProbeResult['status'] | 'checking' | null; @@ -247,15 +246,17 @@ export function DesktopHostSwitcherDialog({ const { t } = useI18n(); const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); const setSettingsPage = useUIStore((state) => state.setSettingsPage); + const isRuntimeConnected = useConfigStore((state) => state.isConnected); const [configHosts, setConfigHosts] = React.useState([]); const [defaultHostId, setDefaultHostId] = React.useState(null); - const [statusById, setStatusById] = React.useState>(() => ({ ...lastKnownHostStatuses })); - React.useEffect(() => { - Object.assign(lastKnownHostStatuses, statusById); - }, [statusById]); + // Statuses live outside this component: startup warms them, and the dropdown + // remounts on every open — holding them here is what made each open start + // from nothing and show "Checking" on rows the app already knew about. + const statusSnapshot = React.useSyncExternalStore(subscribeDesktopHostStatuses, getDesktopHostStatusSnapshot, getDesktopHostStatusSnapshot); + const statusById = statusSnapshot.byHostId; + const isProbing = statusSnapshot.isProbing; const [isLoading, setIsLoading] = React.useState(false); - const [isProbing, setIsProbing] = React.useState(false); const [isSaving, setIsSaving] = React.useState(false); const [switchingHostId, setSwitchingHostId] = React.useState(null); const [sshHostIds, setSshHostIds] = React.useState>({}); @@ -347,6 +348,10 @@ export function DesktopHostSwitcherDialog({ nextSshHostIds[instance.id] = true; } setConfigHosts(cfg.hosts || []); + // Config is the authoritative host list: drop statuses for instances the + // user removed. Doing this from a probe run instead would clear entries + // every time a run started before the config had finished loading. + pruneDesktopHostStatuses((cfg.hosts || []).map((host) => host.id)); setDefaultHostId(cfg.defaultHostId ?? null); setSshHostIds(nextSshHostIds); setSshStatusesById(sshStatusMap); @@ -362,43 +367,7 @@ export function DesktopHostSwitcherDialog({ }, [t]); const probeAll = React.useCallback(async (hosts: DesktopHost[]) => { - if (!isDesktopShell()) return; - setIsProbing(true); - try { - const localClientToken = await getLocalClientToken(); - const results = await Promise.all( - hosts.map(async (h) => { - const clientToken = h.id === LOCAL_HOST_ID ? localClientToken : (h.clientToken || ''); - const probeRelayLeg = async (): Promise => { - const res = await probeRelayDesktopHost(h.relay!, { clientToken, requestHeaders: h.requestHeaders || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 })); - return { status: res.status, latencyMs: res.latencyMs, ...(res.status === 'ok' ? { via: 'relay' as const } : {}) }; - }; - // Relay-only host: no HTTP address — probe through the E2EE tunnel. - if (h.relay && !h.apiUrl) { - return [h.id, await probeRelayLeg()] as const; - } - const url = normalizeHostUrl(isElectronShell() ? getDesktopHostApiUrl(h) : h.url); - if (!url) { - return [h.id, { status: 'unreachable' as const, latencyMs: 0 } satisfies HostStatus] as const; - } - const res = await desktopHostProbe(url, { clientToken: clientToken || null, requestHeaders: h.requestHeaders || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 })); - // Multi-transport host away from its network: the direct leg fails - // but the relay may still reach it. - if (isBlockedHostStatus(res.status) && h.relay) { - const relayStatus = await probeRelayLeg(); - if (relayStatus.status === 'ok') return [h.id, relayStatus] as const; - } - return [h.id, { status: res.status, latencyMs: res.latencyMs } satisfies HostStatus] as const; - }) - ); - const next: Record = {}; - for (const [id, val] of results) { - next[id] = val; - } - setStatusById(next); - } finally { - setIsProbing(false); - } + await probeDesktopHosts(hosts); }, []); React.useEffect(() => { @@ -514,7 +483,7 @@ export function DesktopHostSwitcherDialog({ relayProbeTunnel = 'tunnel' in probe ? probe.tunnel : undefined; } } - setStatusById((prev) => ({ ...prev, [host.id]: finalStatus })); + setDesktopHostStatus(host.id, finalStatus); if (!transport) { toast.error(t('desktopHostSwitcher.toast.instanceUnreachable', { host: redactSensitiveUrl(host.label) })); @@ -620,10 +589,7 @@ export function DesktopHostSwitcherDialog({ if (host.id !== LOCAL_HOST_ID && isDesktopShell()) { setSwitchingHostId(host.id); const probe = await desktopHostProbe(origin, { clientToken: host.clientToken || null, requestHeaders: host.requestHeaders || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 })); - setStatusById((prev) => ({ - ...prev, - [host.id]: { status: probe.status, latencyMs: probe.latencyMs }, - })); + setDesktopHostStatus(host.id, { status: probe.status, latencyMs: probe.latencyMs }); if (isBlockedHostStatus(probe.status)) { toast.error(t('desktopHostSwitcher.toast.instanceUnreachable', { host: redactSensitiveUrl(host.label) })); @@ -863,12 +829,17 @@ export function DesktopHostSwitcherDialog({ const status = statusById[host.id] || null; const sshStatus = sshStatusesById[host.id] || null; // While a probe runs, keep showing the last known result (quiet - // refresh); only fall back to "Checking" when there has never - // been one. "Unknown" is never shown — an unprobed host is by - // definition being checked. + // refresh — the header's refresh icon is the spinner); only fall + // back to "Checking" when there has never been one. "Unknown" is + // never shown — an unprobed host is by definition being checked. + // + // The instance the app is connected to never says "Checking": + // the live connection already answers the question a probe would + // ask, and reporting otherwise reads as the app not knowing where + // it is. A real probe result still wins — it carries the ping. const statusKind: HostDisplayStatus = isSsh ? sshPhaseToHostStatus(sshStatus?.phase) - : (status?.status ?? 'checking'); + : (status?.status ?? (isActive && isRuntimeConnected ? 'ok' : 'checking')); const isEditing = editingId === host.id; const effectiveUrl = isLocal ? localOrigin : (normalizeHostUrl(host.url) || host.url); const displayLabel = host.id === LOCAL_HOST_ID diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 41bc1dfa..e89c2ec1 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -124,7 +124,6 @@ const HeaderIconActionButton = React.memo(function HeaderIconActionButton({ type DesktopServicesMenuProps = { isDesktopApp: boolean; currentInstanceLabel: string; - compactCurrentInstanceLabel: string; currentInstanceIsLocal: boolean; isDesktopServicesOpen: boolean; setIsDesktopServicesOpen: React.Dispatch>; @@ -139,7 +138,6 @@ type DesktopServicesMenuProps = { const DesktopServicesMenu = React.memo(function DesktopServicesMenu({ isDesktopApp, currentInstanceLabel, - compactCurrentInstanceLabel, currentInstanceIsLocal, isDesktopServicesOpen, setIsDesktopServicesOpen, @@ -171,12 +169,12 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({ : t('header.services.open')} className={cn( DESKTOP_HEADER_ICON_BUTTON_CLASS, - isDesktopApp ? 'w-auto max-w-[14rem] justify-start gap-1.5 px-2.5' : 'h-8 w-8' + isDesktopApp ? 'w-auto max-w-[20rem] justify-start gap-1.5 px-2.5' : 'h-8 w-8' )} > {isDesktopApp ? ( - {compactCurrentInstanceLabel} + {currentInstanceLabel} ) : null} @@ -251,27 +249,6 @@ const isSameContextUsage = ( && (a.lastMessageId ?? '') === (b.lastMessageId ?? ''); }; -const formatCompactHeaderLabel = (value: string): string => { - const trimmed = value.trim(); - if (!trimmed) { - return ''; - } - - const words = trimmed.split(/\s+/).filter(Boolean); - if (words.length >= 2) { - const first = words[0]; - const second = words[1].slice(0, 3); - const shortTwoWord = `${first} ${second}`.trim(); - if (words.length > 2 || shortTwoWord.length < trimmed.length) { - return `${shortTwoWord}...`; - } - return shortTwoWord; - } - - return trimmed.length > 12 ? `${trimmed.slice(0, 9).trimEnd()}...` : trimmed; -}; - - const normalize = (value: string): string => { if (!value) return ''; const replaced = value.replace(/\\/g, '/'); @@ -447,7 +424,6 @@ export const Header: React.FC = () => { const [remoteUpdateInfo, setRemoteUpdateInfo] = React.useState(null); const [remoteUpdateChecking, setRemoteUpdateChecking] = React.useState(false); const [remoteUpdateError, setRemoteUpdateError] = React.useState(null); - const compactCurrentInstanceLabel = React.useMemo(() => formatCompactHeaderLabel(currentInstanceLabel), [currentInstanceLabel]); const isVSCode = React.useMemo(() => isVSCodeRuntime(), []); // While the work-status panel is on screen it already reports the project, // the branch and the context fill — three paces away in the same window. @@ -1293,7 +1269,6 @@ export const Header: React.FC = () => { = ({ providerId, providerName }) => { const { t } = useI18n(); const [status, setStatus] = React.useState(null); - const [values, setValues] = React.useState>({}); + const [values, setValues] = React.useState({}); const [busy, setBusy] = React.useState(false); const route = `/api/quota/credentials/${providerId}`; React.useEffect(() => { void runtimeFetch(route).then(async (response) => { if (!response.ok) throw new Error(); - const next = await response.json() as Status; + const next: Status = await response.json(); setStatus(next); setValues({}); }).catch(() => setStatus({ configured: false })); }, [route]); - const request = async (path: string, method: string, body?: object) => { + const request = async (path: string, method: string, body?: CredentialPayload) => { setBusy(true); try { const response = await runtimeFetch(path, { method, headers: body ? { 'Content-Type': 'application/json' } : undefined, body: body ? JSON.stringify(body) : undefined }); @@ -31,11 +33,16 @@ export const QuotaCredentials: React.FC<{ providerId: ProviderId; providerName: } catch (error) { toast.error(error instanceof Error && error.message ? error.message : t('settings.providers.page.openCodeGo.saveFailed')); } finally { setBusy(false); } }; - const field = (name: string, label: string, placeholder: string) => ; + const field = (name: keyof CredentialPayload, label: string, placeholder: string) => ; return

{providerName}

+ {providerId === 'exe-dev' &&
+

{t('settings.providers.page.quotaCredentials.exeDevTokenInstructions')}

+ {EXE_DEV_TOKEN_COMMAND} +
} {providerId === 'ollama-cloud' && field('cookie', t('settings.providers.page.openCodeGo.authCookie'), 'session=...')} + {providerId === 'exe-dev' && field('usageToken', t('settings.providers.page.quotaCredentials.usageToken'), t('settings.providers.page.quotaCredentials.tokenPlaceholder'))} {providerId === 'cursor' && field('accessToken', t('settings.providers.page.quotaCredentials.accessToken'), t('settings.providers.page.quotaCredentials.tokenPlaceholder'))} {providerId === 'cursor' && field('refreshToken', t('settings.providers.page.quotaCredentials.refreshToken'), t('settings.providers.page.quotaCredentials.tokenPlaceholder'))}
diff --git a/packages/ui/src/components/sections/usage/UsagePage.tsx b/packages/ui/src/components/sections/usage/UsagePage.tsx index 7e8e3ccc..679d73b1 100644 --- a/packages/ui/src/components/sections/usage/UsagePage.tsx +++ b/packages/ui/src/components/sections/usage/UsagePage.tsx @@ -80,7 +80,7 @@ export const UsagePage: React.FC = () => { ? selectedResult.error : null; const showInDropdown = selectedProviderId ? dropdownProviderIds.includes(selectedProviderId) : false; - const hasCredentialsForm = selectedProviderId === 'ollama-cloud' || selectedProviderId === 'cursor'; + const hasCredentialsForm = selectedProviderId === 'exe-dev' || selectedProviderId === 'ollama-cloud' || selectedProviderId === 'cursor'; const handleDropdownToggle = React.useCallback((enabled: boolean) => { if (!selectedProviderId) { return; @@ -204,7 +204,7 @@ export const UsagePage: React.FC = () => {
)} - {(selectedProviderId === 'ollama-cloud' || selectedProviderId === 'cursor') && ( + {(selectedProviderId === 'exe-dev' || selectedProviderId === 'ollama-cloud' || selectedProviderId === 'cursor') && ( )} diff --git a/packages/ui/src/components/session/sidebar/projects/sortableItems.tsx b/packages/ui/src/components/session/sidebar/projects/sortableItems.tsx index 303c8470..1db05b32 100644 --- a/packages/ui/src/components/session/sidebar/projects/sortableItems.tsx +++ b/packages/ui/src/components/session/sidebar/projects/sortableItems.tsx @@ -285,7 +285,14 @@ export const SortableProjectItem: React.FC = ({ + ); + }; + + return ( + <> + + + setIsOpen(false)} + > +
+ setSearch(event.target.value)} + placeholder={t('gitView.branch.searchPlaceholder')} + className="h-9 w-full rounded-lg border border-border bg-transparent px-3 typography-meta outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-primary" + /> + {switchBlockedNotice ? ( +
+
+ ) : null} + {recentLocalBranches.length > 0 ? ( +
+

{t('gitView.branch.recentBranches')}

+ {recentLocalBranches.map((branch) => renderBranch(branch))} +
+ ) : null} +
+

{t('gitView.branch.localBranches')}

+ {filteredLocal.map((branch) => renderBranch(branch))} +
+
+

{t('gitView.branch.remoteBranches')}

+ {filteredRemote.map((branch) => renderBranch(branch, true))} +
+
+
+ + ); + } + return ( @@ -192,6 +314,12 @@ export const BranchSelector: React.FC = ({ onValueChange={setSearch} onKeyDown={stopDropdownTypeahead} /> + {switchBlockedNotice ? ( +
+
+ ) : null} = ({ + {recentBranches.filter((branch) => localBranches.includes(branch)).length > 0 ? ( + <> + + {recentBranches.filter((branch) => localBranches.includes(branch)).map((branch) => ( + handleCheckout(branch)}> + + {branch} + {(() => { + const ahead = unpushedCounts[branch] ?? (branch === currentBranch ? currentBranchAhead : 0); + const aheadLabel = ahead === 1 + ? t('gitView.branch.unpushedSingle') + : t('gitView.branch.unpushedPlural', { count: ahead }); + return ahead > 0 ? ( + + + ) : null; + })()} + + {currentBranch === branch ? {t('gitView.branch.currentBadge')} : null} + + ))} + + + + ) : null} + {filteredLocal.map((branch) => ( void; + targetBranch: string; + changedFileCount: number; + /** + * Commit every uncommitted change with this message — pushing the commit + * first when the user opted in — then perform the checkout. + */ + onCommitAndSwitch: (message: string, pushAfter: boolean) => Promise; + /** Produce an AI commit message for the current changes, same as the commit panel. */ + onGenerateMessage: () => Promise; + /** Revert every uncommitted change, then perform the checkout. */ + onRevertAndSwitch: () => Promise; +} + +/** + * Switching branches with uncommitted changes is blocked so a checkout can + * never silently carry, conflict with, or drop the user's work. The user + * resolves the working tree with one explicit choice: commit and switch + * (message written, generated on demand, or generated automatically when the + * field is left empty — same pipeline as the commit panel), or revert and + * switch. Cancel leaves everything untouched for a fully manual flow. + */ +export const DirtyBranchSwitchDialog: React.FC = ({ + open, + onOpenChange, + targetBranch, + changedFileCount, + onCommitAndSwitch, + onGenerateMessage, + onRevertAndSwitch, +}) => { + const { t } = useI18n(); + const [commitMessage, setCommitMessage] = React.useState(''); + const [pendingAction, setPendingAction] = React.useState<'generate' | 'commit' | 'revert' | null>(null); + const [pushAfter, setPushAfter] = React.useState(false); + const isProcessing = pendingAction !== null; + + React.useEffect(() => { + if (!open) { + setCommitMessage(''); + setPushAfter(false); + } + }, [open]); + + const handleGenerate = async () => { + setPendingAction('generate'); + try { + const generated = await onGenerateMessage(); + if (generated) setCommitMessage(generated); + } catch (err) { + toast.error(err instanceof Error ? err.message : t('gitView.dirtySwitch.actionFailed')); + } finally { + setPendingAction(null); + } + }; + + // An empty field is not an obstacle: the message is generated on the spot, + // through the same pipeline as the commit panel, and the commit proceeds. + const handleCommitAndSwitch = async () => { + setPendingAction('commit'); + try { + let message = commitMessage.trim(); + if (!message) { + message = (await onGenerateMessage()).trim(); + if (!message) { + toast.error(t('gitView.toast.enterCommitMessage')); + return; + } + setCommitMessage(message); + } + await onCommitAndSwitch(message, pushAfter); + } catch (err) { + toast.error(err instanceof Error ? err.message : t('gitView.dirtySwitch.actionFailed')); + } finally { + setPendingAction(null); + } + }; + + const handleRevertAndSwitch = async () => { + setPendingAction('revert'); + try { + await onRevertAndSwitch(); + } catch (err) { + toast.error(err instanceof Error ? err.message : t('gitView.dirtySwitch.actionFailed')); + } finally { + setPendingAction(null); + } + }; + + return ( + { if (!isProcessing) onOpenChange(next); }}> + +
+ +
+ + {t('gitView.dirtySwitch.title')} +
+ + {changedFileCount === 1 + ? t('gitView.dirtySwitch.descriptionSingle', { branch: targetBranch }) + : t('gitView.dirtySwitch.descriptionPlural', { branch: targetBranch, count: changedFileCount })} + +
+ +
+ setCommitMessage(event.target.value)} + placeholder={t('gitView.commit.messagePlaceholder')} + disabled={isProcessing} + onKeyDown={(event) => { + if (event.key === 'Enter' && !isProcessing) { + event.preventDefault(); + void handleCommitAndSwitch(); + } + }} + className="min-w-0 flex-1 bg-transparent typography-meta text-foreground outline-none placeholder:text-muted-foreground" + /> + +
+ +
+ + !isProcessing && setPushAfter(!pushAfter)} + > + {t('gitView.dirtySwitch.pushAfterCommit')} + +
+ +
+ + +
+
+
+
+ ); +}; diff --git a/packages/ui/src/components/views/git/GitHeader.tsx b/packages/ui/src/components/views/git/GitHeader.tsx index 8324f7a3..bbcaa6ef 100644 --- a/packages/ui/src/components/views/git/GitHeader.tsx +++ b/packages/ui/src/components/views/git/GitHeader.tsx @@ -22,10 +22,12 @@ import type { GitHubChecksSummary, } from '@/lib/api/types'; import { useI18n } from '@/lib/i18n'; +import { useDeviceInfo } from '@/lib/device'; type SyncAction = 'fetch' | 'pull' | 'push' | 'sync' | null; interface GitHeaderProps { + directory: string; status: GitStatus | null; localBranches: string[]; remoteBranches: string[]; @@ -240,6 +242,7 @@ const UpstreamStatusPill: React.FC = ({ }; export const GitHeader: React.FC = ({ + directory, status, localBranches, remoteBranches, @@ -272,6 +275,7 @@ export const GitHeader: React.FC = ({ repositoryRoot, }) => { const { t } = useI18n(); + const { isMobile } = useDeviceInfo(); if (!status) { return null; } @@ -425,20 +429,23 @@ export const GitHeader: React.FC = ({
- {isWorktreeMode ? ( + {isWorktreeMode && !isMobile ? ( ) : ( 0 ? t('gitView.branch.switchBlockedNotice') : null} /> )} {repositoryOptionsForPicker.length > 0 && onSelectRepository ? ( diff --git a/packages/ui/src/components/views/git/recentBranches.test.ts b/packages/ui/src/components/views/git/recentBranches.test.ts new file mode 100644 index 00000000..7d7278af --- /dev/null +++ b/packages/ui/src/components/views/git/recentBranches.test.ts @@ -0,0 +1,44 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { getRecentBranches, rememberRecentBranch } from './recentBranches'; + +class TestStorage implements Storage { + #values = new Map(); + + get length(): number { return this.#values.size; } + clear(): void { this.#values.clear(); } + getItem(key: string): string | null { return this.#values.get(key) ?? null; } + key(index: number): string | null { return [...this.#values.keys()][index] ?? null; } + removeItem(key: string): void { this.#values.delete(key); } + setItem(key: string, value: string): void { this.#values.set(key, value); } +} + +const originalLocalStorage = globalThis.localStorage; +let storage: TestStorage; + +beforeEach(() => { + storage = new TestStorage(); + Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: storage }); +}); + +afterEach(() => { + Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: originalLocalStorage }); +}); + +describe('recent Git branches', () => { + test('persists a branch list for a later UI mount', () => { + rememberRecentBranch('/repo', 'feature/one'); + rememberRecentBranch('/repo', 'feature/two'); + + expect(getRecentBranches('/repo')).toEqual(['feature/two', 'feature/one']); + }); + + test('keeps only the five most recently used branches', () => { + for (let index = 1; index <= 6; index += 1) { + rememberRecentBranch('/repo', `feature/${index}`); + } + + expect(getRecentBranches('/repo')).toEqual([ + 'feature/6', 'feature/5', 'feature/4', 'feature/3', 'feature/2', + ]); + }); +}); diff --git a/packages/ui/src/components/views/git/recentBranches.ts b/packages/ui/src/components/views/git/recentBranches.ts new file mode 100644 index 00000000..ccf0f33c --- /dev/null +++ b/packages/ui/src/components/views/git/recentBranches.ts @@ -0,0 +1,38 @@ +import { normalizePath } from '@/lib/pathNormalization'; +import { getRuntimeKey } from '@/lib/runtime-switch'; +import { z } from 'zod'; + +const KEY = 'openchamber:recent-git-branches:v1'; +const LIMIT = 5; + +const entriesSchema = z.record(z.string(), z.array(z.string())); +type Entries = z.infer; + +const keyFor = (directory: string): string | null => { + const normalized = normalizePath(directory); + return normalized ? `${getRuntimeKey()}:${normalized}` : null; +}; + +const read = (): Entries => { + try { + const raw = localStorage.getItem(KEY); + const parsed = entriesSchema.safeParse(raw ? JSON.parse(raw) : null); + return parsed.success + ? Object.fromEntries(Object.entries(parsed.data).map(([key, branches]) => [key, branches.slice(0, LIMIT)])) + : {}; + } catch { return {}; } +}; + +export const getRecentBranches = (directory: string): string[] => { + const key = keyFor(directory); + return key ? read()[key] ?? [] : []; +}; + +export const rememberRecentBranch = (directory: string, branch: string): string[] => { + const key = keyFor(directory); + if (!key || !branch) return []; + const entries = read(); + const next = [branch, ...(entries[key] ?? []).filter((item) => item !== branch)].slice(0, LIMIT); + try { localStorage.setItem(KEY, JSON.stringify({ ...entries, [key]: next })); } catch { /* convenience only */ } + return next; +}; diff --git a/packages/ui/src/contexts/theme-storage.test.ts b/packages/ui/src/contexts/theme-storage.test.ts index 2bb98528..1128b44c 100644 --- a/packages/ui/src/contexts/theme-storage.test.ts +++ b/packages/ui/src/contexts/theme-storage.test.ts @@ -295,12 +295,12 @@ describe('settings sync resolution', () => { const serverTheme = { useSystemTheme: false as const, themeVariant: 'dark' as const, lightThemeId: 'server-light', darkThemeId: 'server-dark' }; test('a non-bootstrap sync (settings save echo) never changes preferences', () => { - expect(resolveThemePreferencesFromSettingsSync({ bootstrap: false, settings: serverTheme }, current)).toBeNull(); + expect(resolveThemePreferencesFromSettingsSync({ adoptTheme: false, settings: serverTheme }, current)).toBeNull(); expect(resolveThemePreferencesFromSettingsSync(null, current)).toBeNull(); }); test('a bootstrap sync adopts the server theme', () => { - expect(resolveThemePreferencesFromSettingsSync({ bootstrap: true, settings: serverTheme }, current)).toEqual({ + expect(resolveThemePreferencesFromSettingsSync({ adoptTheme: true, settings: serverTheme }, current)).toEqual({ themeMode: 'dark', lightThemeId: 'server-light', darkThemeId: 'server-dark', @@ -308,19 +308,19 @@ describe('settings sync resolution', () => { }); test('theme fields omitted by the server keep the current preferences (not-set is not reset-to-defaults)', () => { - expect(resolveThemePreferencesFromSettingsSync({ bootstrap: true, settings: {} }, current)).toBeNull(); + expect(resolveThemePreferencesFromSettingsSync({ adoptTheme: true, settings: {} }, current)).toBeNull(); expect( - resolveThemePreferencesFromSettingsSync({ bootstrap: true, settings: { useSystemTheme: true } }, current), + resolveThemePreferencesFromSettingsSync({ adoptTheme: true, settings: { useSystemTheme: true } }, current), ).toBeNull(); expect( - resolveThemePreferencesFromSettingsSync({ bootstrap: true, settings: { lightThemeId: 'server-light' } }, current), + resolveThemePreferencesFromSettingsSync({ adoptTheme: true, settings: { lightThemeId: 'server-light' } }, current), ).toEqual({ themeMode: 'system', lightThemeId: 'server-light', darkThemeId: 'dark-theme' }); }); test('a bootstrap sync carrying the current preferences resolves to no change', () => { expect( resolveThemePreferencesFromSettingsSync( - { bootstrap: true, settings: { useSystemTheme: true, lightThemeId: 'light-theme', darkThemeId: 'dark-theme' } }, + { adoptTheme: true, settings: { useSystemTheme: true, lightThemeId: 'light-theme', darkThemeId: 'dark-theme' } }, current, ), ).toBeNull(); diff --git a/packages/ui/src/contexts/theme-storage.ts b/packages/ui/src/contexts/theme-storage.ts index d14cf6f0..a7c9c1ce 100644 --- a/packages/ui/src/contexts/theme-storage.ts +++ b/packages/ui/src/contexts/theme-storage.ts @@ -201,10 +201,10 @@ type SettingsSyncThemePayload = Pick< * theme lookup downstream and falls back cosmetically. */ export const resolveThemePreferencesFromSettingsSync = ( - detail: { bootstrap: boolean; settings: SettingsSyncThemePayload } | null, + detail: { adoptTheme: boolean; settings: SettingsSyncThemePayload } | null, current: StoredThemePreferences, ): StoredThemePreferences | null => { - if (!detail?.bootstrap) { + if (!detail?.adoptTheme) { return null; } diff --git a/packages/ui/src/hooks/useProviderLogo.test.ts b/packages/ui/src/hooks/useProviderLogo.test.ts new file mode 100644 index 00000000..940b2bd2 --- /dev/null +++ b/packages/ui/src/hooks/useProviderLogo.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; + +const source = readFileSync(new URL('./useProviderLogo.ts', import.meta.url), 'utf8'); + +describe('provider logo aliases', () => { + test('maps rotating exe.dev proxy provider IDs to the local exe.dev logo', () => { + expect(source).toContain("compact.startsWith('exe-') ? 'exe-dev' : undefined"); + expect(source).toContain('const candidates = [prefixAlias,'); + }); +}); diff --git a/packages/ui/src/hooks/useProviderLogo.ts b/packages/ui/src/hooks/useProviderLogo.ts index 011d1110..80ded66d 100644 --- a/packages/ui/src/hooks/useProviderLogo.ts +++ b/packages/ui/src/hooks/useProviderLogo.ts @@ -45,7 +45,8 @@ const buildLogoCandidates = (providerId: string | null | undefined) => { const compact = normalized.replace(/[^a-z0-9_\-./:]/g, ''); const primary = compact.split(/[/:]/)[0] || compact; - const candidates = [LOGO_ALIAS.get(compact), LOGO_ALIAS.get(primary), compact, primary] + const prefixAlias = compact.startsWith('exe-') ? 'exe-dev' : undefined; + const candidates = [prefixAlias, LOGO_ALIAS.get(compact), LOGO_ALIAS.get(primary), compact, primary] .filter((value): value is string => Boolean(value && value.length > 0)); return [...new Set(candidates)]; diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index d1b70aa4..910896cb 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -147,6 +147,11 @@ export interface GitStatus { attentionReason?: 'merge' | 'rebase' | 'cherry-pick' | 'revert' | 'bisect' | null; } +export interface GitUnpushedBranchCounts { + /** Local commits not present in each branch's configured upstream. */ + counts: Record; +} + export interface GitDiffResponse { diff: string; } @@ -505,6 +510,7 @@ export interface GitAPI { revertGitHunk?(directory: string, filePath: string, patch: string): Promise; isLinkedWorktree(directory: string): Promise; getGitBranches(directory: string): Promise; + getGitUnpushedBranchCounts(directory: string, branches: string[]): Promise; deleteGitBranch(directory: string, payload: GitDeleteBranchPayload): Promise<{ success: boolean }>; deleteRemoteBranch(directory: string, payload: GitDeleteRemoteBranchPayload): Promise<{ success: boolean }>; removeRemote(directory: string, payload: GitRemoveRemotePayload): Promise<{ success: boolean }>; diff --git a/packages/ui/src/lib/browser/devTunnel.test.ts b/packages/ui/src/lib/browser/devTunnel.test.ts index ff8093a2..02bb1615 100644 --- a/packages/ui/src/lib/browser/devTunnel.test.ts +++ b/packages/ui/src/lib/browser/devTunnel.test.ts @@ -2,19 +2,52 @@ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'; let apiBaseUrl = 'https://remote.example.test'; -let tunnelResult: unknown = { localPort: 52418, reused: false }; +type TunnelResult = { localPort: number; reused: boolean } | Error; +type DesktopTunnelArgs = { baseUrl?: string; port?: number; relay?: boolean; targetKey?: string }; +type RelayEvent = { connectionId: string; remotePort: number; message: { type: string; data?: ArrayBuffer } }; +type RelaySocketFixture = { + binaryType: string; + onopen: (() => void) | null; + onmessage: ((event: { data: ArrayBuffer | string }) => void) | null; + onerror: (() => void) | null; + onclose: (() => void) | null; + send: ReturnType; + close: ReturnType; + readyState: number; +}; + +let tunnelResult: TunnelResult = { localPort: 52418, reused: false }; +let desktopArgs: DesktopTunnelArgs | undefined; +let relayActive = false; +let openedRelayUrl = ''; +let refreshedBaseUrl = ''; +let refreshUrlAuth = async (baseUrl: string) => { refreshedBaseUrl = baseUrl; return 'url-token'; }; +let relayHandler: ((event: RelayEvent) => void) | null = null; +const relayPosts: Array<{ connectionId: string; message: { type: string; data?: ArrayBuffer } }> = []; +const relaySocket: RelaySocketFixture = { binaryType: 'arraybuffer', onopen: null, onmessage: null, onerror: null, onclose: null, send: mock(() => {}), close: mock(() => {}), readyState: 0 }; mock.module('@/lib/desktopNative', () => ({ - invokeDesktopCommand: mock(async () => { + invokeDesktopCommand: mock(async (_command: string, args?: DesktopTunnelArgs) => { + desktopArgs = args; if (tunnelResult instanceof Error) throw tunnelResult; return tunnelResult; }), + listenForDesktopRelayDevTunnels: (handler: typeof relayHandler) => { relayHandler = handler; return true; }, + postDesktopRelayDevTunnelMessage: (connectionId: string, message: { type: string; data?: ArrayBuffer }) => relayPosts.push({ connectionId, message }), })); +mock.module('@/lib/relay/runtime-tunnel', () => ({ + isRelayModeActive: () => relayActive, + getActiveRelayTunnel: () => relayActive ? {} : null, +})); +mock.module('@/lib/relay/runtime-socket', () => ({ openRuntimeWebSocket: (url: string) => { openedRelayUrl = url; return relaySocket; } })); mock.module('@/lib/runtime-auth', () => ({ getRuntimeBearerTokenSync: () => 'token', getRuntimeExtraHeadersSync: () => ({}), + refreshRuntimeUrlAuthToken: (baseUrl: string) => refreshUrlAuth(baseUrl), })); +mock.module('@/lib/runtime-url', () => ({ getRuntimeUrlResolver: () => ({ websocket: (path: string) => `openchamber-ui://app${path}&oc_url_token=test` }) })); mock.module('@/lib/runtime-switch', () => ({ getRuntimeApiBaseUrl: () => apiBaseUrl, + getRuntimeKey: () => relayActive ? 'host:exe' : `url:${apiBaseUrl}`, subscribeRuntimeEndpointChanged: () => () => {}, })); @@ -25,23 +58,30 @@ const { toDisplayUrl, } = await import('./devTunnel'); -const globalScope = globalThis as unknown as { window?: unknown }; - const asDesktop = (value: boolean) => { - globalScope.window = value - ? { __OPENCHAMBER_ELECTRON__: true, location: { href: 'http://127.0.0.1:3901/' } } - : { location: { href: 'http://127.0.0.1:3901/' } }; + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: value + ? { __OPENCHAMBER_ELECTRON__: true, location: { href: 'http://127.0.0.1:3901/' } } + : { location: { href: 'http://127.0.0.1:3901/' } }, + }); }; describe('loopback navigations against a remote instance', () => { beforeEach(() => { apiBaseUrl = 'https://remote.example.test'; tunnelResult = { localPort: 52418, reused: false }; + desktopArgs = undefined; + relayActive = false; + relayPosts.length = 0; + openedRelayUrl = ''; + refreshedBaseUrl = ''; + refreshUrlAuth = async (baseUrl: string) => { refreshedBaseUrl = baseUrl; return 'url-token'; }; asDesktop(true); }); afterEach(() => { - delete globalScope.window; + Reflect.deleteProperty(globalThis, 'window'); }); test('a page reached through a tunnel keeps its other ports on the host', () => { @@ -82,6 +122,39 @@ describe('loopback navigations against a remote instance', () => { expect(failed).toBe(true); }); + test('a relay-only runtime asks Electron for a local relay bridge', async () => { + relayActive = true; + apiBaseUrl = 'openchamber-ui://app'; + const resolved = await resolveBrowsableUrl('http://localhost:4322/docs/'); + expect(resolved).toBe('http://127.0.0.1:52418/docs/'); + expect(desktopArgs?.relay).toBe(true); + expect(desktopArgs?.targetKey).toBe('host:exe'); + expect(desktopArgs?.port).toBe(4322); + + relayHandler?.({ connectionId: 'connection-1', remotePort: 4322, message: { type: 'connect' } }); + await Promise.resolve(); + await Promise.resolve(); + relaySocket.onopen?.(); + expect(refreshedBaseUrl).toBe('openchamber-ui://app'); + expect(openedRelayUrl).toContain('/api/dev-tunnel?port=4322&oc_url_token=test'); + expect(relayPosts.some((entry) => entry.connectionId === 'connection-1' && entry.message.type === 'ready')).toBe(true); + }); + + test('a local disconnect during auth does not leave an orphan relay socket', async () => { + relayActive = true; + apiBaseUrl = 'openchamber-ui://app'; + let finishAuth = () => {}; + refreshUrlAuth = () => new Promise((resolve) => { finishAuth = () => resolve('url-token'); }); + + relayHandler?.({ connectionId: 'connection-cancelled', remotePort: 4322, message: { type: 'connect' } }); + relayHandler?.({ connectionId: 'connection-cancelled', remotePort: 4322, message: { type: 'close' } }); + finishAuth(); + await Promise.resolve(); + await Promise.resolve(); + + expect(openedRelayUrl).toBe(''); + }); + test('a local instance resolves its own loopback correctly', () => { apiBaseUrl = 'http://127.0.0.1:3901'; expect(shouldTunnelLoopbackUrl('http://localhost:4322/docs/')).toBe(false); diff --git a/packages/ui/src/lib/browser/devTunnel.ts b/packages/ui/src/lib/browser/devTunnel.ts index 439f99e2..1f9322b2 100644 --- a/packages/ui/src/lib/browser/devTunnel.ts +++ b/packages/ui/src/lib/browser/devTunnel.ts @@ -10,17 +10,66 @@ * Everywhere else — local runtime, web, mobile — the URL is already correct and * is returned untouched. */ -import { invokeDesktopCommand } from '@/lib/desktopNative'; -import { getRuntimeBearerTokenSync, getRuntimeExtraHeadersSync } from '@/lib/runtime-auth'; -import { getRuntimeApiBaseUrl, subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch'; +import { invokeDesktopCommand, listenForDesktopRelayDevTunnels, postDesktopRelayDevTunnelMessage } from '@/lib/desktopNative'; +import { getRuntimeBearerTokenSync, getRuntimeExtraHeadersSync, refreshRuntimeUrlAuthToken } from '@/lib/runtime-auth'; +import { getActiveRelayTunnel, isRelayModeActive } from '@/lib/relay/runtime-tunnel'; +import { openRuntimeWebSocket } from '@/lib/relay/runtime-socket'; +import type { RelayTunnelWebSocket } from '@/lib/relay/tunnel-client'; +import { getRuntimeApiBaseUrl, getRuntimeKey, subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch'; +import { getRuntimeUrlResolver } from '@/lib/runtime-url'; import { isLoopbackUrl } from './url'; -type TunnelResult = { localPort: number; reused: boolean; url: string }; +type TunnelResult = { localPort: number }; /** Keyed by `${baseUrl}|${port}`; the shell owns the real lifetime. */ const localPortByTarget = new Map(); /** Reverse map, so a tunnel port never leaks into the address bar or storage. */ const originByLocalPort = new Map(); +const relaySockets = new Map(); +const pendingRelayConnections = new Set(); + +const openRelayConnection = async (connectionId: string, remotePort: number): Promise => { + if (!getActiveRelayTunnel()) { + pendingRelayConnections.delete(connectionId); + postDesktopRelayDevTunnelMessage(connectionId, { type: 'close' }); + return; + } + await refreshRuntimeUrlAuthToken(getRuntimeApiBaseUrl()); + if (!pendingRelayConnections.has(connectionId) || !getActiveRelayTunnel()) return; + const url = getRuntimeUrlResolver().websocket(`/api/dev-tunnel?port=${remotePort}`); + const socket = openRuntimeWebSocket(url); + relaySockets.set(connectionId, socket); + socket.binaryType = 'arraybuffer'; + socket.onopen = () => postDesktopRelayDevTunnelMessage(connectionId, { type: 'ready' }); + socket.onmessage = (event) => postDesktopRelayDevTunnelMessage(connectionId, { type: 'data', data: event.data instanceof ArrayBuffer ? event.data : new TextEncoder().encode(event.data) }); + socket.onerror = () => postDesktopRelayDevTunnelMessage(connectionId, { type: 'close' }); + socket.onclose = () => { + pendingRelayConnections.delete(connectionId); + relaySockets.delete(connectionId); + postDesktopRelayDevTunnelMessage(connectionId, { type: 'close' }); + }; +}; + +listenForDesktopRelayDevTunnels(({ connectionId, remotePort, message }) => { + switch (message.type) { + case 'data': { + const socket = relaySockets.get(connectionId); + if (socket && message.data) socket.send(message.data); + return; + } + case 'close': + pendingRelayConnections.delete(connectionId); + relaySockets.get(connectionId)?.close(); + relaySockets.delete(connectionId); + return; + case 'connect': + pendingRelayConnections.add(connectionId); + void openRelayConnection(connectionId, remotePort).catch(() => { + pendingRelayConnections.delete(connectionId); + postDesktopRelayDevTunnelMessage(connectionId, { type: 'close' }); + }); + } +}); const isDesktopRuntime = (): boolean => ( typeof window !== 'undefined' && Boolean(window.__OPENCHAMBER_ELECTRON__) @@ -69,6 +118,14 @@ const rewriteToLocalPort = (url: string, localPort: number): string => { } }; +const rememberOriginalOrigin = (url: string, localPort: number): void => { + try { + originByLocalPort.set(localPort, new URL(url).origin); + } catch { + // Unparseable input never reaches here; nothing to record. + } +}; + /** Thrown when a remote dev server exists but could not be reached from here. */ export class DevTunnelUnavailableError extends Error { constructor(message: string) { @@ -100,11 +157,7 @@ export const resolveBrowsableUrl = async (url: string): Promise => { const key = `${baseUrl}|${port}`; const cached = localPortByTarget.get(key); if (cached) { - try { - originByLocalPort.set(cached, new URL(url).origin); - } catch { - // Unparseable input never reaches here; nothing to record. - } + rememberOriginalOrigin(url, cached); return rewriteToLocalPort(url, cached); } @@ -112,6 +165,8 @@ export const resolveBrowsableUrl = async (url: string): Promise => { const result = await invokeDesktopCommand('desktop_dev_tunnel_open', { baseUrl, port, + relay: isRelayModeActive(), + targetKey: getRuntimeKey(), clientToken: getRuntimeBearerTokenSync(), requestHeaders: getRuntimeExtraHeadersSync(), }); @@ -119,11 +174,7 @@ export const resolveBrowsableUrl = async (url: string): Promise => { throw new DevTunnelUnavailableError(url); } localPortByTarget.set(key, result.localPort); - try { - originByLocalPort.set(result.localPort, new URL(url).origin); - } catch { - // Unparseable input never reaches here; nothing to record. - } + rememberOriginalOrigin(url, result.localPort); return rewriteToLocalPort(url, result.localPort); } catch (error) { if (error instanceof DevTunnelUnavailableError) throw error; @@ -180,6 +231,10 @@ export const toDisplayUrl = (url: string): string => { const resetDevTunnelCache = (): void => { localPortByTarget.clear(); originByLocalPort.clear(); + pendingRelayConnections.clear(); + for (const socket of relaySockets.values()) socket.close(); + relaySockets.clear(); + void invokeDesktopCommand('desktop_relay_dev_tunnel_close_all').catch(() => {}); }; if (typeof window !== 'undefined') { diff --git a/packages/ui/src/lib/desktopHostStatus.test.ts b/packages/ui/src/lib/desktopHostStatus.test.ts new file mode 100644 index 00000000..f609b27f --- /dev/null +++ b/packages/ui/src/lib/desktopHostStatus.test.ts @@ -0,0 +1,138 @@ +import { beforeEach, describe, expect, mock, test } from 'bun:test'; +import type { DesktopHost, HostProbeResult } from './desktopHosts'; + +let probeResults: Record = {}; +const probeCalls: string[] = []; +let probeGate: Promise | null = null; + +const desktopModule = await import('./desktopHosts'); +mock.module('./desktopHosts', () => ({ + ...desktopModule, + desktopLocalClientTokenGet: async () => 'local-token', + desktopHostProbe: async (url: string) => { + probeCalls.push(url); + if (probeGate) await probeGate; + return probeResults[url] ?? { status: 'unreachable', latencyMs: 0 }; + }, +})); + +const desktopShell = await import('@/lib/desktop'); +mock.module('@/lib/desktop', () => ({ + ...desktopShell, + isDesktopShell: () => true, + isElectronShell: () => false, +})); + +const { + getDesktopHostStatusSnapshot, + probeDesktopHosts, + pruneDesktopHostStatuses, + setDesktopHostStatus, + subscribeDesktopHostStatuses, +} = await import('./desktopHostStatus'); + +const host = (id: string, url: string): DesktopHost => ({ id, label: id, url }); + +describe('desktop host statuses', () => { + beforeEach(() => { + probeResults = {}; + probeCalls.length = 0; + probeGate = null; + pruneDesktopHostStatuses([]); + setDesktopHostStatus('local', { status: 'ok', latencyMs: 1 }); + pruneDesktopHostStatuses([]); + }); + + test('a probe replaces the previous value instead of blanking it first', async () => { + setDesktopHostStatus('remote', { status: 'ok', latencyMs: 12 }); + probeResults['https://remote.example'] = { status: 'ok', latencyMs: 40 }; + + const seen: Array = []; + const unsubscribe = subscribeDesktopHostStatuses(() => { + seen.push(getDesktopHostStatusSnapshot().byHostId.remote?.status); + }); + await probeDesktopHosts([host('remote', 'https://remote.example')]); + unsubscribe(); + + // Every published snapshot during the run still carried a status; the row + // never falls back to "Checking" while a quiet refresh is running. + expect(seen.every((status) => status !== undefined)).toBe(true); + expect(getDesktopHostStatusSnapshot().byHostId.remote?.latencyMs).toBe(40); + }); + + test('a fast host is published while a slow one is still in flight', async () => { + probeResults['https://fast.example'] = { status: 'ok', latencyMs: 5 }; + probeResults['https://slow.example'] = { status: 'ok', latencyMs: 900 }; + let releaseSlow!: () => void; + const slowGate = new Promise((resolve) => { releaseSlow = resolve; }); + probeGate = slowGate; + + const run = probeDesktopHosts([host('fast', 'https://fast.example'), host('slow', 'https://slow.example')]); + await Promise.resolve(); + expect(getDesktopHostStatusSnapshot().isProbing).toBe(true); + + releaseSlow(); + await run; + + expect(getDesktopHostStatusSnapshot().byHostId.fast?.status).toBe('ok'); + expect(getDesktopHostStatusSnapshot().byHostId.slow?.status).toBe('ok'); + expect(getDesktopHostStatusSnapshot().isProbing).toBe(false); + }); + + test('pruning keeps local and every configured instance, and forgets the rest', () => { + setDesktopHostStatus('kept', { status: 'ok', latencyMs: 3 }); + setDesktopHostStatus('removed', { status: 'ok', latencyMs: 4 }); + + pruneDesktopHostStatuses(['kept']); + + const { byHostId } = getDesktopHostStatusSnapshot(); + expect(byHostId.kept?.status).toBe('ok'); + expect(byHostId.local?.status).toBe('ok'); + expect(byHostId.removed).toBe(undefined); + }); + + test('a snapshot is a new object per change so subscribers re-render', () => { + const before = getDesktopHostStatusSnapshot(); + setDesktopHostStatus('remote', { status: 'auth', latencyMs: 0 }); + + expect(getDesktopHostStatusSnapshot()).not.toBe(before); + expect(before.byHostId.remote).toBe(undefined); + }); + + test('a slow older run cannot overwrite a newer result', async () => { + // Startup warm-up, opening the switcher and the refresh button all probe; + // whichever finishes last must not be whichever started first. + probeResults['https://remote.example'] = { status: 'unreachable', latencyMs: 0 }; + let releaseSlow!: () => void; + probeGate = new Promise((resolve) => { releaseSlow = resolve; }); + + const slowRun = probeDesktopHosts([host('remote', 'https://remote.example')]); + await Promise.resolve(); + + probeGate = null; + probeResults['https://remote.example'] = { status: 'ok', latencyMs: 30 }; + await probeDesktopHosts([host('remote', 'https://remote.example')]); + expect(getDesktopHostStatusSnapshot().byHostId.remote?.status).toBe('ok'); + + releaseSlow(); + await slowRun; + + expect(getDesktopHostStatusSnapshot().byHostId.remote?.status).toBe('ok'); + expect(getDesktopHostStatusSnapshot().byHostId.remote?.latencyMs).toBe(30); + }); + + test('a status recorded by the switch flow outranks a probe already running', async () => { + probeResults['https://remote.example'] = { status: 'unreachable', latencyMs: 0 }; + let releaseSlow!: () => void; + probeGate = new Promise((resolve) => { releaseSlow = resolve; }); + + const slowRun = probeDesktopHosts([host('remote', 'https://remote.example')]); + await Promise.resolve(); + setDesktopHostStatus('remote', { status: 'ok', latencyMs: 7, via: 'relay' }); + + releaseSlow(); + await slowRun; + + expect(getDesktopHostStatusSnapshot().byHostId.remote?.status).toBe('ok'); + }); +}); diff --git a/packages/ui/src/lib/desktopHostStatus.ts b/packages/ui/src/lib/desktopHostStatus.ts new file mode 100644 index 00000000..2fb3c9f7 --- /dev/null +++ b/packages/ui/src/lib/desktopHostStatus.ts @@ -0,0 +1,186 @@ +import { isDesktopShell, isElectronShell } from '@/lib/desktop'; +import { + desktopHostProbe, + desktopHostsGet, + desktopLocalClientTokenGet, + getDesktopHostApiUrl, + normalizeHostUrl, + probeRelayDesktopHost, + type DesktopHost, + type HostProbeResult, +} from '@/lib/desktopHosts'; +import { LOCAL_HOST_ID, buildLocalDesktopHost } from '@/lib/desktopCurrentHost'; + +export type DesktopHostStatus = { + status: HostProbeResult['status']; + latencyMs: number; + /** Which transport the successful probe used (multi-transport hosts). */ + via?: 'relay'; +}; + +/** Reachability by instance id. */ +type DesktopHostStatusMap = Record; + +type DesktopHostStatusSnapshot = { + byHostId: Readonly; + /** True while any probe run is in flight, for the refresh spinner. */ + isProbing: boolean; +}; + +/** + * Reachability of every configured instance, owned outside the switcher UI. + * + * The switcher used to hold this in component state, which made the dropdown + * the only thing that could ever learn an instance's status: every open started + * from nothing and showed "Checking" on rows the app had already answered for — + * including the instance the app was connected to and actively talking to. + * + * Keeping it here lets startup warm the statuses before the user opens + * anything, and lets a re-probe replace values in place instead of blanking + * them first. + */ +const statuses = new Map(); +// Startup warm-up, opening the switcher and the refresh button can all be in +// flight at once, and a probe's duration varies by an order of magnitude +// between a loopback host and a relay host working through tunnel retries. +// Without ordering, a slow older run lands last and replaces a fresh "ok" with +// its own stale "unreachable". Each host remembers which run owns its status. +let probeRunSequence = 0; +const owningRunByHostId = new Map(); +let activeProbeRuns = 0; +let snapshot: DesktopHostStatusSnapshot = { byHostId: {}, isProbing: false }; +const listeners = new Set<() => void>(); + +const publishSnapshot = (): void => { + // `useSyncExternalStore` compares snapshots by identity, so each mutation + // publishes a fresh one rather than handing out the live map. + snapshot = { byHostId: Object.fromEntries(statuses), isProbing: activeProbeRuns > 0 }; + for (const listener of listeners) { + try { + listener(); + } catch { + // A subscriber throwing must not stop the others. + } + } +}; + +export const subscribeDesktopHostStatuses = (listener: () => void): (() => void) => { + listeners.add(listener); + return () => { listeners.delete(listener); }; +}; + +export const getDesktopHostStatusSnapshot = (): DesktopHostStatusSnapshot => snapshot; + +const setStatus = (hostId: string, status: DesktopHostStatus): void => { + statuses.set(hostId, status); + publishSnapshot(); +}; + +/** + * Record a status learned outside a probe run — the switch flow probes too, and + * its result is the freshest thing anyone has, so it takes ownership away from + * any probe run still running for that host. + */ +export const setDesktopHostStatus = (hostId: string, status: DesktopHostStatus): void => { + owningRunByHostId.set(hostId, ++probeRunSequence); + setStatus(hostId, status); +}; + +/** + * Forget instances that are no longer configured. Called with the authoritative + * host list, never with a partially loaded one — dropping entries on a list + * that has not finished loading is what made every dropdown open start blank. + */ +export const pruneDesktopHostStatuses = (configuredHostIds: readonly string[]): void => { + const keep = new Set([LOCAL_HOST_ID, ...configuredHostIds]); + let changed = false; + for (const hostId of Array.from(statuses.keys())) { + if (keep.has(hostId)) continue; + statuses.delete(hostId); + owningRunByHostId.delete(hostId); + changed = true; + } + if (changed) publishSnapshot(); +}; + +const isBlockedProbeStatus = (status: HostProbeResult['status']): boolean => + status === 'unreachable' || status === 'wrong-service' || status === 'incompatible'; + +const getLocalClientToken = async (): Promise => { + if (!isElectronShell()) return ''; + return desktopLocalClientTokenGet().catch(() => ''); +}; + +const probeHost = async (host: DesktopHost, localClientToken: string): Promise => { + const clientToken = host.id === LOCAL_HOST_ID ? localClientToken : (host.clientToken || ''); + const probeRelayLeg = async (): Promise => { + const res = await probeRelayDesktopHost(host.relay!, { clientToken, requestHeaders: host.requestHeaders || null }) + .catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 })); + const status: DesktopHostStatus = { status: res.status, latencyMs: res.latencyMs }; + // `via` is what renders the "· Relay" suffix, so it marks a reachable host + // only — a failed relay leg says nothing about which transport would work. + if (res.status === 'ok') status.via = 'relay'; + return status; + }; + + // Relay-only host: no HTTP address — probe through the E2EE tunnel. + if (host.relay && !host.apiUrl) return probeRelayLeg(); + + const url = normalizeHostUrl(isElectronShell() ? getDesktopHostApiUrl(host) : host.url); + if (!url) return { status: 'unreachable', latencyMs: 0 }; + + const res = await desktopHostProbe(url, { clientToken: clientToken || null, requestHeaders: host.requestHeaders || null }) + .catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 })); + // Multi-transport host away from its network: the direct leg fails but the + // relay may still reach it. + if (isBlockedProbeStatus(res.status) && host.relay) { + const relayStatus = await probeRelayLeg(); + if (relayStatus.status === 'ok') return relayStatus; + } + return { status: res.status, latencyMs: res.latencyMs }; +}; + +/** + * Probe every given instance, publishing each result the moment it lands. + * Waiting for the slowest probe would hold answered rows on "Checking" beside + * one host still working through its relay tunnel retries. + */ +export const probeDesktopHosts = async (hosts: readonly DesktopHost[]): Promise => { + if (!isDesktopShell()) return; + const run = ++probeRunSequence; + for (const host of hosts) owningRunByHostId.set(host.id, run); + activeProbeRuns += 1; + publishSnapshot(); + try { + const localClientToken = await getLocalClientToken(); + await Promise.all(hosts.map(async (host) => { + const status = await probeHost(host, localClientToken); + // A newer run (or a switch) claimed this host while we were probing. + if (owningRunByHostId.get(host.id) !== run) return; + setStatus(host.id, status); + })); + } finally { + activeProbeRuns -= 1; + publishSnapshot(); + } +}; + +let warmUpStarted = false; + +/** + * Learn every instance's status once at startup, so the switcher opens on real + * values instead of probing for the first time under the user's cursor. + * + * Deliberately after the app's own bootstrap: this is background work, and the + * direct legs go through the Electron main process while relay legs open their + * own WebSocket, so neither shares the renderer's connection pool with session + * traffic — but the machine's network is still busiest right at launch. + */ +export const warmDesktopHostStatuses = async (): Promise => { + if (warmUpStarted || !isDesktopShell()) return; + warmUpStarted = true; + const config = await desktopHostsGet().catch(() => null); + if (!config) return; + pruneDesktopHostStatuses(config.hosts.map((host) => host.id)); + await probeDesktopHosts([buildLocalDesktopHost(config.localOrigin), ...config.hosts]); +}; diff --git a/packages/ui/src/lib/desktopHosts.test.ts b/packages/ui/src/lib/desktopHosts.test.ts index 114c8f87..5ac8d7c7 100644 --- a/packages/ui/src/lib/desktopHosts.test.ts +++ b/packages/ui/src/lib/desktopHosts.test.ts @@ -1,5 +1,24 @@ -import { describe, expect, test } from 'bun:test'; -import { desktopHostProbe, desktopHostsGet, desktopHostsSet, importDesktopHostPairing, redactSensitiveUrl, resolveDesktopHostUrl } from './desktopHosts'; +import { describe, expect, mock, test } from 'bun:test'; +import type { RelayTunnelStatus } from '@/lib/relay/tunnel-client'; +import type { DesktopHostRelay } from './desktopHosts'; + +type TunnelStub = { + fetch: (path: string, init?: RequestInit) => Promise; + getStatus: () => RelayTunnelStatus; + close: () => void; +}; + +let nextTunnel: (() => TunnelStub) | null = null; +const tunnelModule = await import('@/lib/relay/tunnel-client'); +mock.module('@/lib/relay/tunnel-client', () => ({ + ...tunnelModule, + createRelayTunnelClient: () => { + if (!nextTunnel) throw new Error('no tunnel stub registered'); + return nextTunnel(); + }, +})); + +const { desktopHostProbe, desktopHostsGet, desktopHostsSet, importDesktopHostPairing, probeRelayDesktopHost, redactSensitiveUrl, resolveDesktopHostUrl } = await import('./desktopHosts'); const withDesktopBridge = async (handler: (cmd: string, args: Record) => unknown | Promise, run: () => Promise): Promise => { const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); @@ -121,3 +140,86 @@ describe('desktop host runtime headers', () => { }); }); }); + +describe('probeRelayDesktopHost', () => { + const relay: DesktopHostRelay = { + relayUrl: 'wss://relay.example', + serverId: 'server-a', + hostEncPubJwk: { kty: 'EC', crv: 'P-256', x: 'x', y: 'y' }, + }; + + const withTimerWindow = async (run: () => Promise): Promise => { + const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { setTimeout: setTimeout.bind(globalThis), clearTimeout: clearTimeout.bind(globalThis) }, + }); + try { + return await run(); + } finally { + if (previousWindow) { + Object.defineProperty(globalThis, 'window', previousWindow); + } else { + Reflect.deleteProperty(globalThis, 'window'); + } + } + }; + + const stubTunnel = ( + responses: Array, + state: RelayTunnelStatus['state'] = 'reconnecting', + ) => { + const calls: string[] = []; + let closed = false; + nextTunnel = () => ({ + fetch: async (path) => { + calls.push(path); + const next = responses.shift(); + if (!next) throw new Error('relay tunnel reset'); + if (next instanceof Error) throw next; + return next; + }, + getStatus: () => ({ state }), + close: () => { closed = true; }, + }); + return { calls, isClosed: () => closed }; + }; + + test('a cold first attempt is retried instead of reported unreachable', async () => { + // The tunnel rejects waiters on its first failed connect and then + // reconnects; the probe must span that, not read it as an unreachable host. + const tunnel = stubTunnel([ + new Error('relay tunnel reset: connection failed'), + new Response('{}', { status: 200 }), + new Response('{}', { status: 200 }), + ]); + + const result = await withTimerWindow(() => probeRelayDesktopHost(relay, { clientToken: 'token' })); + + expect(result.status).toBe('ok'); + expect(tunnel.calls).toEqual(['/health', '/health', '/auth/session']); + expect(tunnel.isClosed()).toBe(true); + }); + + test('a terminal tunnel state ends the probe without retrying', async () => { + // Auth failed / duplicate client / limit reached will not resolve by waiting. + const tunnel = stubTunnel([new Error('relay connection replaced by another client')], 'error'); + + const result = await withTimerWindow(() => probeRelayDesktopHost(relay, { clientToken: 'token' })); + + expect(result.status).toBe('unreachable'); + expect(tunnel.calls).toEqual(['/health']); + }); + + test('a rejected client token is reported as auth, not unreachable', async () => { + const tunnel = stubTunnel([ + new Response('{}', { status: 200 }), + new Response('{}', { status: 401 }), + ]); + + const result = await withTimerWindow(() => probeRelayDesktopHost(relay, { clientToken: 'stale' })); + + expect(result.status).toBe('auth'); + expect(tunnel.calls).toEqual(['/health', '/auth/session']); + }); +}); diff --git a/packages/ui/src/lib/desktopHosts.ts b/packages/ui/src/lib/desktopHosts.ts index ddcc4787..ef15adf5 100644 --- a/packages/ui/src/lib/desktopHosts.ts +++ b/packages/ui/src/lib/desktopHosts.ts @@ -408,14 +408,18 @@ export const desktopInstallIdGet = async (): Promise => { }; const RELAY_PROBE_TIMEOUT_MS = 8_000; +// Whole-probe budget, spanning the tunnel's own reconnect attempts. +const RELAY_PROBE_DEADLINE_MS = 15_000; +const RELAY_PROBE_RETRY_DELAY_MS = 400; const fetchRelayProbe = async ( tunnel: ReturnType, path: string, + timeoutMs: number, init?: RequestInit, ): Promise => { const controller = new AbortController(); - const timer = window.setTimeout(() => controller.abort(), RELAY_PROBE_TIMEOUT_MS); + const timer = window.setTimeout(() => controller.abort(), timeoutMs); try { return await tunnel.fetch(path, { ...init, signal: controller.signal }); } finally { @@ -423,13 +427,52 @@ const fetchRelayProbe = async ( } }; +/** + * Reach the host, letting the tunnel's own reconnect do the work. + * + * The tunnel rejects everything waiting on its channel the moment ONE connect + * attempt fails, even though it has already scheduled the next one with + * backoff. That is right for app traffic — `runtime-fetch` retries for itself — + * but it made a one-shot probe report a durable red "Unreachable" for a host + * that answers when the user presses refresh a second later. A cold start is + * exactly when that first attempt loses: DNS and TLS to the relay are cold, the + * remote host may still be re-establishing its control connection, and the + * probe competes with the app's own bootstrap traffic. + * + * A terminal tunnel state (auth failed, duplicate client, limit reached) will + * not resolve by waiting, so it ends the probe immediately. + */ +const fetchRelayProbeUntilDeadline = async ( + tunnel: ReturnType, + path: string, + deadline: number, + init?: RequestInit, +): Promise => { + for (;;) { + // Every attempt is capped by what is LEFT of the budget, not by the full + // per-request timeout: an attempt started just under the deadline would + // otherwise run the whole 8s past it, and the switch flow waits on this. + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) throw new Error('relay probe deadline exceeded'); + try { + return await fetchRelayProbe(tunnel, path, Math.min(RELAY_PROBE_TIMEOUT_MS, remainingMs), init); + } catch (error) { + if (tunnel.getStatus().state === 'error') throw error; + if (Date.now() >= deadline) throw error; + await new Promise((resolve) => window.setTimeout(resolve, RELAY_PROBE_RETRY_DELAY_MS)); + } + } +}; + /** * Reachability and client-auth check for a relay host: open a throwaway E2EE * tunnel, verify `/health`, then verify `/auth/session` with the saved bearer. - * Relay hosts have no HTTP address for `desktopHostProbe`. Hard timeout: a - * ghost relay registration (relay lost the host, host doesn't know) leaves the - * tunnel in `connecting` forever — the probe must report unreachable instead - * of hanging every status/switch flow with it. + * Relay hosts have no HTTP address for `desktopHostProbe`. Bounded by + * `RELAY_PROBE_DEADLINE_MS`: a ghost relay registration (relay lost the host, + * host doesn't know) leaves the tunnel reconnecting forever — the probe must + * report unreachable rather than hang every status/switch flow with it — while + * still spanning enough reconnect attempts that a cold first attempt is not + * mistaken for an unreachable instance. */ export const probeRelayDesktopHost = async ( relay: DesktopHostRelay, @@ -444,9 +487,10 @@ export const probeRelayDesktopHost = async ( hostEncPubJwk: relay.hostEncPubJwk, }); const startedAt = Date.now(); + const deadline = startedAt + RELAY_PROBE_DEADLINE_MS; let keep = false; try { - const response = await fetchRelayProbe(tunnel, '/health'); + const response = await fetchRelayProbeUntilDeadline(tunnel, '/health', deadline); if (!response.ok) return { status: 'unreachable', latencyMs: 0 }; const headers = new Headers({ Accept: 'application/json' }); for (const [name, value] of Object.entries(options?.requestHeaders || {})) { @@ -454,7 +498,7 @@ export const probeRelayDesktopHost = async ( } const clientToken = options?.clientToken?.trim(); if (clientToken) headers.set('Authorization', `Bearer ${clientToken}`); - const sessionResponse = await fetchRelayProbe(tunnel, '/auth/session', { headers }); + const sessionResponse = await fetchRelayProbeUntilDeadline(tunnel, '/auth/session', deadline, { headers }); if (sessionResponse.status === 401 || sessionResponse.status === 403) { return { status: 'auth', latencyMs: Math.max(0, Date.now() - startedAt) }; } diff --git a/packages/ui/src/lib/desktopNative.ts b/packages/ui/src/lib/desktopNative.ts index 9f1a7d29..945f64b0 100644 --- a/packages/ui/src/lib/desktopNative.ts +++ b/packages/ui/src/lib/desktopNative.ts @@ -1,6 +1,34 @@ import { hasDesktopInvoke, invokeDesktop, isDesktopShell } from '@/lib/desktop'; type InvokeArgs = Record; +type RelayDevTunnelData = ArrayBuffer | Uint8Array; +type RelayDevTunnelMessage = { type: 'connect' | 'ready' | 'data' | 'close'; data?: RelayDevTunnelData }; +type RelayDevTunnelEvent = { connectionId: string; remotePort: number; message: RelayDevTunnelMessage }; +type RelayDevTunnelBridge = { + relayDevTunnelListen?: (handler: (event: RelayDevTunnelEvent) => void) => void; + relayDevTunnelPost?: (connectionId: string, message: RelayDevTunnelMessage) => void; +}; + +declare global { + interface Window { + __OPENCHAMBER_DESKTOP__?: RelayDevTunnelBridge; + } +} + +const getRelayDevTunnelBridge = (): RelayDevTunnelBridge | null => { + return globalThis.window?.__OPENCHAMBER_DESKTOP__ ?? null; +}; + +export const listenForDesktopRelayDevTunnels = (handler: (event: RelayDevTunnelEvent) => void): boolean => { + const bridge = getRelayDevTunnelBridge(); + if (!bridge?.relayDevTunnelListen) return false; + bridge.relayDevTunnelListen(handler); + return true; +}; + +export const postDesktopRelayDevTunnelMessage = (connectionId: string, message: RelayDevTunnelMessage): void => { + getRelayDevTunnelBridge()?.relayDevTunnelPost?.(connectionId, message); +}; export const invokeDesktopCommand = async ( command: string, diff --git a/packages/ui/src/lib/fontLoader.ts b/packages/ui/src/lib/fontLoader.ts index 9df09c25..5bb95511 100644 --- a/packages/ui/src/lib/fontLoader.ts +++ b/packages/ui/src/lib/fontLoader.ts @@ -4,6 +4,10 @@ const loadedFaces = new Set(); const pendingFaces = new Map>(); const buildFontUrl = (source: FontFaceSource, weight: number) => { + if ('urls' in source) { + return source.urls[weight]; + } + const packageName = encodeURIComponent(source.packageName).replace('%40', '@').replace('%2F', '/'); return `https://cdn.jsdelivr.net/npm/${packageName}/files/${source.filePrefix}-latin-${weight}-normal.woff2`; }; diff --git a/packages/ui/src/lib/fontOptions.ts b/packages/ui/src/lib/fontOptions.ts index 6387698a..372b51a3 100644 --- a/packages/ui/src/lib/fontOptions.ts +++ b/packages/ui/src/lib/fontOptions.ts @@ -1,14 +1,23 @@ -export type UiFontOption = 'inter' | 'geist-sans' | 'atkinson-hyperlegible' | 'source-sans-3' | 'roboto' | 'noto-sans' | 'dm-sans' | 'manrope' | 'system'; +export type UiFontOption = 'inter' | 'fixel' | 'geist-sans' | 'atkinson-hyperlegible' | 'source-sans-3' | 'roboto' | 'noto-sans' | 'dm-sans' | 'manrope' | 'system'; export type MonoFontOption = 'jetbrains-mono' | 'fira-code' | 'geist-mono' | 'commit-mono' | 'source-code-pro' | 'cascadia-code' | 'roboto-mono' | 'iosevka' | 'system-mono'; -export interface FontFaceSource { +interface FontFaceSourceBase { family: string; - packageName: string; - filePrefix: string; weights: number[]; } +interface FontsourceFaceSource extends FontFaceSourceBase { + packageName: string; + filePrefix: string; +} + +interface DirectFontFaceSource extends FontFaceSourceBase { + urls: Record; +} + +export type FontFaceSource = FontsourceFaceSource | DirectFontFaceSource; + export interface FontOptionDefinition { id: T; label: string; @@ -26,6 +35,21 @@ export const UI_FONT_OPTIONS: FontOptionDefinition[] = [ stack: '"Inter", "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', source: { family: 'Inter', packageName: '@fontsource/inter', filePrefix: 'inter', weights: [400, 500, 600] } }, + { + id: 'fixel', + label: 'Fixel Text', + description: 'Humanist geometric sans-serif with full Ukrainian support.', + stack: '"Fixel Text", "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', + source: { + family: 'Fixel Text', + weights: [400, 500, 600], + urls: { + 400: 'https://cdn.jsdelivr.net/gh/MacPaw/Fixel@f6ee910e98add47e830db87f1a754130506c11a2/fonts/webfonts/FixelText-Regular.woff2', + 500: 'https://cdn.jsdelivr.net/gh/MacPaw/Fixel@f6ee910e98add47e830db87f1a754130506c11a2/fonts/webfonts/FixelText-Medium.woff2', + 600: 'https://cdn.jsdelivr.net/gh/MacPaw/Fixel@f6ee910e98add47e830db87f1a754130506c11a2/fonts/webfonts/FixelText-SemiBold.woff2' + } + } + }, { id: 'geist-sans', label: 'Geist Sans', diff --git a/packages/ui/src/lib/gitApi.ts b/packages/ui/src/lib/gitApi.ts index 5c692d8b..1d0e325a 100644 --- a/packages/ui/src/lib/gitApi.ts +++ b/packages/ui/src/lib/gitApi.ts @@ -214,6 +214,12 @@ export async function getGitBranches(directory: string): Promise { + const runtime = getRuntimeGit(); + if (runtime) return runtime.getGitUnpushedBranchCounts(directory, branches); + return gitHttp.getGitUnpushedBranchCounts(directory, branches); +} + export async function deleteGitBranch(directory: string, payload: import('./api/types').GitDeleteBranchPayload): Promise<{ success: boolean }> { const runtime = getRuntimeGit(); if (runtime) return runtimeStatusMutation(directory, runtime.deleteGitBranch(directory, payload)); diff --git a/packages/ui/src/lib/gitApiHttp.ts b/packages/ui/src/lib/gitApiHttp.ts index d9908dfc..197eb7cf 100644 --- a/packages/ui/src/lib/gitApiHttp.ts +++ b/packages/ui/src/lib/gitApiHttp.ts @@ -7,6 +7,7 @@ import type { GitFileDiffResponse, GetGitFileDiffOptions, GitBranch, + GitUnpushedBranchCounts, GitDeleteBranchPayload, GitDeleteRemoteBranchPayload, GitRemoveRemotePayload, @@ -493,6 +494,16 @@ export async function getGitBranches(directory: string): Promise { return response.json(); } +export async function getGitUnpushedBranchCounts(directory: string, branches: string[]): Promise { + const response = await runtimeFetch(buildUrl(`${API_BASE}/branch-push-status`, directory), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ branches }), + }); + if (!response.ok) throw new Error(`Failed to get branch push status: ${response.statusText}`); + return response.json(); +} + export async function deleteGitBranch(directory: string, payload: GitDeleteBranchPayload): Promise<{ success: boolean }> { if (!payload?.branch) { throw new Error('branch is required to delete a branch'); diff --git a/packages/ui/src/lib/i18n/messages/de.settings.ts b/packages/ui/src/lib/i18n/messages/de.settings.ts index 86dc0985..8e7ad3c5 100644 --- a/packages/ui/src/lib/i18n/messages/de.settings.ts +++ b/packages/ui/src/lib/i18n/messages/de.settings.ts @@ -2136,6 +2136,8 @@ export const settingsDict = { 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Warteschlange', 'settings.providers.page.quotaCredentials.accessToken': 'Zugriffstoken', + 'settings.providers.page.quotaCredentials.usageToken': 'Nutzungs-API-Token', + 'settings.providers.page.quotaCredentials.exeDevTokenInstructions': 'Führen Sie diesen Befehl im Terminal aus und fügen Sie dann das Token unten ein. Es kann nur die LLM-Guthabennutzung lesen und läuft nach 30 Tagen ab.', 'settings.providers.page.quotaCredentials.refreshToken': 'Aktualisierungstoken', 'settings.providers.page.quotaCredentials.tokenPlaceholder': 'Token einfügen', 'settings.view.nav.group.general': 'OpenChamber', diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index 761aba6a..a9a512d9 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -699,6 +699,20 @@ export const dict = { 'gitView.commit.stageFilesHint': 'Stagen Sie Dateien, um Commit zu aktivieren.', 'gitView.commit.title': 'Commit', 'gitView.common.cancel': 'Abbrechen', + 'gitView.branch.switchBlockedNotice': 'Nicht committete Änderungen — vor dem Wechsel folgt ein Commit-oder-Verwerfen-Schritt.', + 'gitView.branch.unpushedSingle': '1 Commit nicht gepusht', + 'gitView.branch.unpushedPlural': '{count} Commits nicht gepusht', + 'gitView.branch.recentBranches': 'Kürzliche Branches', + 'gitView.dirtySwitch.title': 'Nicht committete Änderungen', + 'gitView.dirtySwitch.descriptionSingle': 'Der Wechsel zu {branch} ist angehalten, damit die geänderte Datei nicht verloren geht. Zuerst committen oder verwerfen.', + 'gitView.dirtySwitch.descriptionPlural': 'Der Wechsel zu {branch} ist angehalten, damit die {count} geänderten Dateien nicht verloren gehen. Zuerst committen oder verwerfen.', + 'gitView.dirtySwitch.commitAndSwitch': 'Committen und wechseln', + 'gitView.dirtySwitch.committedNotPushed': 'Auf {branch} committet. Der Commit ist nur lokal — er wurde nicht gepusht.', + 'gitView.dirtySwitch.pushAfterCommit': 'Nach dem Commit pushen', + 'gitView.dirtySwitch.pushFailed': 'Committet, aber der Push ist fehlgeschlagen — der Branch wurde nicht gewechselt.', + 'gitView.dirtySwitch.actionFailed': 'Die Aktion ist fehlgeschlagen; der Branch wurde nicht gewechselt.', + 'gitView.dirtySwitch.revertAndSwitch': 'Verwerfen und wechseln', + 'gitView.dirtySwitch.revertIncomplete': 'Einige Änderungen konnten nicht verworfen werden, der Branch wurde nicht gewechselt.', 'gitView.common.close': 'Schließen', 'gitView.common.done': 'Fertig', 'gitView.common.processing': 'Verarbeitung läuft...', @@ -1429,6 +1443,8 @@ export const dict = { 'chat.autoReview.reviewSessionLabel': 'Überprüfungssitzung', 'chat.autoReview.actions.open': 'Öffnen', 'chat.autoReview.actions.stop': 'Stoppen', + 'chat.draftDirtyNotice.tooltip': 'Dieser Branch hat nicht committete Dateien.\nDie neue Session sieht sie. Ein Commit oder ein Worktree hält sie getrennt.', + 'chat.draftDirtyNotice.indicatorAria': 'Nicht committete Änderungen in diesem Verzeichnis', 'diffView.hunk.label': 'Stücke', 'diffView.hunk.stage': 'Zu Staging hinzufügen', 'diffView.hunk.unstage': 'Aus Staging entfernen', diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index e0c6468f..4b44ad31 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -13,6 +13,8 @@ export const settingsDict = { 'settings.providers.page.openCodeGo.delete': 'Delete', 'settings.providers.page.quotaCredentials.saved': '{provider} credentials saved.', 'settings.providers.page.quotaCredentials.accessToken': 'Access token', + 'settings.providers.page.quotaCredentials.usageToken': 'Usage API token', + 'settings.providers.page.quotaCredentials.exeDevTokenInstructions': 'Run this command in your terminal, then paste the token below. It can only read LLM credit usage and expires after 30 days.', 'settings.providers.page.quotaCredentials.refreshToken': 'Refresh token', 'settings.providers.page.quotaCredentials.tokenPlaceholder': 'Paste token', 'settings.providers.page.openCodeGo.saveFailed': 'Could not validate OpenCode Go credentials.', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 9a3aae3a..c6a82a67 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -795,6 +795,20 @@ export const dict = { 'gitView.commit.stageFilesHint': 'Stage files to enable commit.', 'gitView.commit.title': 'Commit', 'gitView.common.cancel': 'Cancel', + 'gitView.branch.switchBlockedNotice': 'Uncommitted changes — switching opens a commit-or-revert step first.', + 'gitView.branch.unpushedSingle': '1 commit not pushed', + 'gitView.branch.unpushedPlural': '{count} commits not pushed', + 'gitView.branch.recentBranches': 'Recent branches', + 'gitView.dirtySwitch.title': 'Uncommitted changes', + 'gitView.dirtySwitch.descriptionSingle': 'Switching to {branch} is paused so your changed file is not lost. Commit it, or revert it first.', + 'gitView.dirtySwitch.descriptionPlural': 'Switching to {branch} is paused so your {count} changed files are not lost. Commit them, or revert them first.', + 'gitView.dirtySwitch.commitAndSwitch': 'Commit and switch', + 'gitView.dirtySwitch.committedNotPushed': 'Committed to {branch}. The commit is local only — it has not been pushed.', + 'gitView.dirtySwitch.pushAfterCommit': 'Push after commit', + 'gitView.dirtySwitch.pushFailed': 'Committed, but the push failed — the branch was not switched.', + 'gitView.dirtySwitch.actionFailed': 'The action failed; the branch was not switched.', + 'gitView.dirtySwitch.revertAndSwitch': 'Revert and switch', + 'gitView.dirtySwitch.revertIncomplete': 'Some changes could not be reverted, so the branch was not switched.', 'gitView.common.close': 'Close', 'gitView.common.done': 'Done', 'gitView.common.processing': 'Processing...', @@ -1626,6 +1640,8 @@ export const dict = { 'chat.autoReview.reviewSessionLabel': 'Review session', 'chat.autoReview.actions.open': 'Open', 'chat.autoReview.actions.stop': 'Stop', + 'chat.draftDirtyNotice.tooltip': 'This branch has uncommitted files.\nThe new session will see them. A commit or a worktree keeps them separate.', + 'chat.draftDirtyNotice.indicatorAria': 'Uncommitted changes in this directory', 'diffView.hunk.label': 'Hunks', 'diffView.hunk.stage': 'Stage', 'diffView.hunk.unstage': 'Unstage', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index b72f9e26..2b656990 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -13,6 +13,8 @@ export const settingsDict = { 'settings.providers.page.openCodeGo.delete': 'Eliminar', 'settings.providers.page.quotaCredentials.saved': 'Credenciales de {provider} guardadas.', 'settings.providers.page.quotaCredentials.accessToken': 'Token de acceso', + 'settings.providers.page.quotaCredentials.usageToken': 'Token de API de uso', + 'settings.providers.page.quotaCredentials.exeDevTokenInstructions': 'Ejecuta este comando en tu terminal y pega el token abajo. Solo puede leer el uso de créditos de LLM y caduca después de 30 días.', 'settings.providers.page.quotaCredentials.refreshToken': 'Token de actualización', 'settings.providers.page.quotaCredentials.tokenPlaceholder': 'Pega el token', 'settings.providers.page.openCodeGo.saveFailed': 'No se pudieron validar las credenciales de OpenCode Go.', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 967ad2da..2804b35a 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -796,6 +796,20 @@ export const dict: Record = { "gitView.commit.stageFilesHint": "Prepara archivos para habilitar el commit.", "gitView.commit.title": "Commit", "gitView.common.cancel": "Cancelar", + 'gitView.branch.switchBlockedNotice': 'Cambios sin confirmar: antes de cambiar de rama se ofrece confirmar o revertir.', + 'gitView.branch.unpushedSingle': '1 commit sin push', + 'gitView.branch.unpushedPlural': '{count} commits sin push', + 'gitView.branch.recentBranches': 'Ramas recientes', + 'gitView.dirtySwitch.title': 'Cambios sin confirmar', + 'gitView.dirtySwitch.descriptionSingle': 'El cambio a {branch} está en pausa para no perder tu archivo modificado. Confírmalo o reviértelo primero.', + 'gitView.dirtySwitch.descriptionPlural': 'El cambio a {branch} está en pausa para no perder tus {count} archivos modificados. Confírmalos o reviértelos primero.', + 'gitView.dirtySwitch.commitAndSwitch': 'Confirmar y cambiar', + 'gitView.dirtySwitch.committedNotPushed': 'Confirmado en {branch}. El commit es solo local: no se ha hecho push.', + 'gitView.dirtySwitch.pushAfterCommit': 'Hacer push después del commit', + 'gitView.dirtySwitch.pushFailed': 'Se confirmó, pero el push falló: no se cambió de rama.', + 'gitView.dirtySwitch.actionFailed': 'La acción falló; no se cambió de rama.', + 'gitView.dirtySwitch.revertAndSwitch': 'Revertir y cambiar', + 'gitView.dirtySwitch.revertIncomplete': 'Algunos cambios no se pudieron revertir, así que no se cambió de rama.', "gitView.common.close": "Cerrar", "gitView.common.done": "Hecho", "gitView.common.processing": "Procesando...", @@ -1604,6 +1618,8 @@ export const dict: Record = { 'chat.autoReview.reviewSessionLabel': 'Sesión de revisión', 'chat.autoReview.actions.open': 'Abrir', 'chat.autoReview.actions.stop': 'Detener', + 'chat.draftDirtyNotice.tooltip': 'Esta rama tiene archivos sin confirmar.\nLa nueva sesión los verá. Un commit o un worktree los mantiene separados.', + 'chat.draftDirtyNotice.indicatorAria': 'Cambios sin confirmar en este directorio', "diffView.hunk.label": "Fragmentos", "diffView.hunk.stage": "Preparar", "diffView.hunk.unstage": "Quitar", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index ae7d5d4e..0fe60bea 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -13,6 +13,8 @@ export const settingsDict = { 'settings.providers.page.openCodeGo.delete': 'Supprimer', 'settings.providers.page.quotaCredentials.saved': 'Identifiants de {provider} enregistrés.', 'settings.providers.page.quotaCredentials.accessToken': 'Jeton d’accès', + 'settings.providers.page.quotaCredentials.usageToken': 'Jeton API d’utilisation', + 'settings.providers.page.quotaCredentials.exeDevTokenInstructions': 'Exécutez cette commande dans votre terminal, puis collez le jeton ci-dessous. Il peut uniquement lire l’utilisation des crédits LLM et expire après 30 jours.', 'settings.providers.page.quotaCredentials.refreshToken': 'Jeton d’actualisation', 'settings.providers.page.quotaCredentials.tokenPlaceholder': 'Collez le jeton', 'settings.providers.page.openCodeGo.saveFailed': 'Impossible de valider les identifiants OpenCode Go.', diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index e5b82ed5..cb58b62d 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -618,6 +618,20 @@ export const dict = { 'gitView.commit.stageFilesHint': 'Ajoutez des fichiers à l’index pour activer le commit.', 'gitView.commit.title': 'Commettre', 'gitView.common.cancel': 'Annuler', + 'gitView.branch.switchBlockedNotice': 'Modifications non commitées — le changement passe d’abord par un commit ou une annulation.', + 'gitView.branch.unpushedSingle': '1 commit non poussé', + 'gitView.branch.unpushedPlural': '{count} commits non poussés', + 'gitView.branch.recentBranches': 'Branches récentes', + 'gitView.dirtySwitch.title': 'Modifications non commitées', + 'gitView.dirtySwitch.descriptionSingle': 'Le passage à {branch} est suspendu pour ne pas perdre votre fichier modifié. Commitez-le ou annulez-le d’abord.', + 'gitView.dirtySwitch.descriptionPlural': 'Le passage à {branch} est suspendu pour ne pas perdre vos {count} fichiers modifiés. Commitez-les ou annulez-les d’abord.', + 'gitView.dirtySwitch.commitAndSwitch': 'Commiter et changer', + 'gitView.dirtySwitch.committedNotPushed': 'Commité sur {branch}. Le commit est local uniquement — il n’a pas été poussé.', + 'gitView.dirtySwitch.pushAfterCommit': 'Pousser après le commit', + 'gitView.dirtySwitch.pushFailed': 'Commité, mais le push a échoué — la branche n’a pas été changée.', + 'gitView.dirtySwitch.actionFailed': 'L’action a échoué ; la branche n’a pas été changée.', + 'gitView.dirtySwitch.revertAndSwitch': 'Annuler et changer', + 'gitView.dirtySwitch.revertIncomplete': 'Certaines modifications n’ont pas pu être annulées, la branche n’a donc pas été changée.', 'gitView.common.close': 'Fermer', 'gitView.common.done': 'Fait', 'gitView.common.processing': 'Traitement...', @@ -1390,6 +1404,8 @@ export const dict = { 'chat.autoReview.reviewSessionLabel': 'Session de revue', 'chat.autoReview.actions.open': 'Ouvrir', 'chat.autoReview.actions.stop': 'Arrêter', + 'chat.draftDirtyNotice.tooltip': 'Cette branche a des fichiers non commités.\nLa nouvelle session les verra. Un commit ou un worktree les garde séparés.', + 'chat.draftDirtyNotice.indicatorAria': 'Modifications non commitées dans ce répertoire', 'diffView.hunk.label': 'Sections', 'diffView.hunk.stage': 'Préparer', 'diffView.hunk.unstage': 'Retirer', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index a68f0fc7..729551a6 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -13,6 +13,8 @@ export const settingsDict = { 'settings.providers.page.openCodeGo.delete': '削除', 'settings.providers.page.quotaCredentials.saved': '{provider} の認証情報を保存しました。', 'settings.providers.page.quotaCredentials.accessToken': 'アクセストークン', + 'settings.providers.page.quotaCredentials.usageToken': '使用量 API トークン', + 'settings.providers.page.quotaCredentials.exeDevTokenInstructions': 'このコマンドをターミナルで実行し、下にトークンを貼り付けてください。LLM クレジット使用量の読み取りのみが可能で、30 日後に期限切れになります。', 'settings.providers.page.quotaCredentials.refreshToken': '更新トークン', 'settings.providers.page.quotaCredentials.tokenPlaceholder': 'トークンを貼り付け', 'settings.providers.page.openCodeGo.saveFailed': 'OpenCode Go の認証情報を検証できませんでした。', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 1e41c450..56b6b27b 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -793,6 +793,20 @@ export const dict: Record = { 'gitView.commit.stageFilesHint': 'ファイルをステージするとコミットが有効になります。', 'gitView.commit.title': 'コミット', 'gitView.common.cancel': 'キャンセル', + 'gitView.branch.switchBlockedNotice': '未コミットの変更があります — 切り替え前にコミットまたは破棄の手順が入ります。', + 'gitView.branch.unpushedSingle': '未プッシュのコミットが1件', + 'gitView.branch.unpushedPlural': '未プッシュのコミットが{count}件', + 'gitView.branch.recentBranches': '最近のブランチ', + 'gitView.dirtySwitch.title': '未コミットの変更', + 'gitView.dirtySwitch.descriptionSingle': '変更したファイルを失わないよう、{branch}への切り替えを一時停止しました。先にコミットするか破棄してください。', + 'gitView.dirtySwitch.descriptionPlural': '変更した{count}件のファイルを失わないよう、{branch}への切り替えを一時停止しました。先にコミットするか破棄してください。', + 'gitView.dirtySwitch.commitAndSwitch': 'コミットして切り替え', + 'gitView.dirtySwitch.committedNotPushed': '{branch}にコミットしました。このコミットはローカルのみで、プッシュされていません。', + 'gitView.dirtySwitch.pushAfterCommit': 'コミット後にプッシュ', + 'gitView.dirtySwitch.pushFailed': 'コミットしましたが、プッシュに失敗したためブランチは切り替えませんでした。', + 'gitView.dirtySwitch.actionFailed': '操作に失敗したため、ブランチは切り替えませんでした。', + 'gitView.dirtySwitch.revertAndSwitch': '破棄して切り替え', + 'gitView.dirtySwitch.revertIncomplete': '一部の変更を破棄できなかったため、ブランチは切り替えませんでした。', 'gitView.common.close': '閉じる', 'gitView.common.done': '完了', 'gitView.common.processing': '処理中...', @@ -1631,6 +1645,8 @@ export const dict: Record = { 'chat.autoReview.reviewSessionLabel': 'レビューセッション', 'chat.autoReview.actions.open': '開く', 'chat.autoReview.actions.stop': '停止', + 'chat.draftDirtyNotice.tooltip': 'このブランチには未コミットのファイルがあります。\n新しいセッションからも見えます。コミットまたはワークツリーで分けられます。', + 'chat.draftDirtyNotice.indicatorAria': 'このディレクトリに未コミットの変更があります', 'rightSidebar.contextNotesTodo.plan.defaultTitle': '計画', 'rightSidebar.contextNotesTodo.empty.selectProject': 'プロジェクトを選択してメモとTODOを追加します。', 'rightSidebar.contextNotesTodo.notes.placeholder': 'コンテキスト、リマインダー、リンクを記録', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index 4fdb93af..48d6aa8b 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -13,6 +13,8 @@ export const settingsDict = { 'settings.providers.page.openCodeGo.delete': '삭제', 'settings.providers.page.quotaCredentials.saved': '{provider} 인증 정보를 저장했습니다.', 'settings.providers.page.quotaCredentials.accessToken': '액세스 토큰', + 'settings.providers.page.quotaCredentials.usageToken': '사용량 API 토큰', + 'settings.providers.page.quotaCredentials.exeDevTokenInstructions': '터미널에서 이 명령을 실행한 다음 아래에 토큰을 붙여 넣으세요. LLM 크레딧 사용량만 읽을 수 있으며 30일 후 만료됩니다.', 'settings.providers.page.quotaCredentials.refreshToken': '새로 고침 토큰', 'settings.providers.page.quotaCredentials.tokenPlaceholder': '토큰 붙여넣기', 'settings.providers.page.openCodeGo.saveFailed': 'OpenCode Go 인증 정보를 검증할 수 없습니다.', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index bb1c1c0a..96781aac 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -796,6 +796,20 @@ export const dict: Record = { 'gitView.commit.stageFilesHint': '커밋하려면 파일을 스테이징하세요.', 'gitView.commit.title': '커밋', 'gitView.common.cancel': '취소', + 'gitView.branch.switchBlockedNotice': '커밋되지 않은 변경 사항이 있습니다 — 전환 전에 커밋 또는 되돌리기 단계가 먼저 열립니다.', + 'gitView.branch.unpushedSingle': '푸시되지 않은 커밋 1개', + 'gitView.branch.unpushedPlural': '푸시되지 않은 커밋 {count}개', + 'gitView.branch.recentBranches': '최근 브랜치', + 'gitView.dirtySwitch.title': '커밋되지 않은 변경 사항', + 'gitView.dirtySwitch.descriptionSingle': '변경된 파일을 잃지 않도록 {branch}(으)로의 전환을 잠시 멈췄습니다. 먼저 커밋하거나 되돌리세요.', + 'gitView.dirtySwitch.descriptionPlural': '변경된 파일 {count}개를 잃지 않도록 {branch}(으)로의 전환을 잠시 멈췄습니다. 먼저 커밋하거나 되돌리세요.', + 'gitView.dirtySwitch.commitAndSwitch': '커밋하고 전환', + 'gitView.dirtySwitch.committedNotPushed': '{branch}에 커밋했습니다. 이 커밋은 로컬 전용이며 푸시되지 않았습니다.', + 'gitView.dirtySwitch.pushAfterCommit': '커밋 후 푸시', + 'gitView.dirtySwitch.pushFailed': '커밋했지만 푸시에 실패하여 브랜치를 전환하지 않았습니다.', + 'gitView.dirtySwitch.actionFailed': '작업이 실패하여 브랜치를 전환하지 않았습니다.', + 'gitView.dirtySwitch.revertAndSwitch': '되돌리고 전환', + 'gitView.dirtySwitch.revertIncomplete': '일부 변경 사항을 되돌리지 못해 브랜치를 전환하지 않았습니다.', 'gitView.common.close': '닫기', 'gitView.common.done': '완료', 'gitView.common.processing': '처리 중…', @@ -1628,6 +1642,8 @@ export const dict: Record = { 'chat.autoReview.reviewSessionLabel': '리뷰 세션', 'chat.autoReview.actions.open': '열기', 'chat.autoReview.actions.stop': '중지', + 'chat.draftDirtyNotice.tooltip': '이 브랜치에는 커밋되지 않은 파일이 있습니다.\n새 세션에서도 보입니다. 커밋 또는 워크트리로 분리할 수 있습니다.', + 'chat.draftDirtyNotice.indicatorAria': '이 디렉터리에 커밋되지 않은 변경 사항이 있습니다', 'diffView.hunk.label': '허크', 'diffView.hunk.stage': '스테이지', 'diffView.hunk.unstage': '스테이지 해제', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index f8272cc1..9d89d843 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -13,6 +13,8 @@ export const settingsDict = { 'settings.providers.page.openCodeGo.delete': 'Usuń', 'settings.providers.page.quotaCredentials.saved': 'Dane uwierzytelniające {provider} zostały zapisane.', 'settings.providers.page.quotaCredentials.accessToken': 'Token dostępu', + 'settings.providers.page.quotaCredentials.usageToken': 'Token API użycia', + 'settings.providers.page.quotaCredentials.exeDevTokenInstructions': 'Uruchom to polecenie w terminalu, a następnie wklej token poniżej. Może on tylko odczytywać użycie środków LLM i wygasa po 30 dniach.', 'settings.providers.page.quotaCredentials.refreshToken': 'Token odświeżania', 'settings.providers.page.quotaCredentials.tokenPlaceholder': 'Wklej token', 'settings.providers.page.openCodeGo.saveFailed': 'Nie udało się sprawdzić danych OpenCode Go.', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 6209b2f0..77b3864c 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1844,6 +1844,8 @@ export const dict: Record = { 'chat.autoReview.reviewSessionLabel': 'Sesja review', 'chat.autoReview.actions.open': 'Otwórz', 'chat.autoReview.actions.stop': 'Zatrzymaj', + 'chat.draftDirtyNotice.tooltip': 'Ta gałąź ma niezacommitowane pliki.\nNowa sesja będzie je widzieć. Commit albo worktree trzyma je osobno.', + 'chat.draftDirtyNotice.indicatorAria': 'Niezacommitowane zmiany w tym katalogu', 'diffView.hunk.label': 'Fragmenty', 'diffView.hunk.stage': 'Przygotuj', 'diffView.hunk.unstage': 'Cofnij', @@ -2106,6 +2108,20 @@ export const dict: Record = { 'gitView.commit.stageFilesHint': 'Dodaj pliki do indeksu, aby włączyć commit.', 'gitView.commit.title': 'Commit', 'gitView.common.cancel': 'Anuluj', + 'gitView.branch.switchBlockedNotice': 'Niezacommitowane zmiany — przed przełączeniem pojawi się krok commit lub cofnięcie.', + 'gitView.branch.unpushedSingle': '1 niewypchnięty commit', + 'gitView.branch.unpushedPlural': 'Niewypchnięte commity: {count}', + 'gitView.branch.recentBranches': 'Ostatnie gałęzie', + 'gitView.dirtySwitch.title': 'Niezacommitowane zmiany', + 'gitView.dirtySwitch.descriptionSingle': 'Przełączenie na {branch} wstrzymano, aby nie stracić zmienionego pliku. Najpierw go zacommituj lub cofnij.', + 'gitView.dirtySwitch.descriptionPlural': 'Przełączenie na {branch} wstrzymano, aby nie stracić {count} zmienionych plików. Najpierw je zacommituj lub cofnij.', + 'gitView.dirtySwitch.commitAndSwitch': 'Zacommituj i przełącz', + 'gitView.dirtySwitch.committedNotPushed': 'Zacommitowano na {branch}. Commit jest tylko lokalny — nie został wypchnięty.', + 'gitView.dirtySwitch.pushAfterCommit': 'Wypchnij po commicie', + 'gitView.dirtySwitch.pushFailed': 'Zacommitowano, ale push się nie powiódł — gałąź nie została przełączona.', + 'gitView.dirtySwitch.actionFailed': 'Akcja nie powiodła się; gałąź nie została przełączona.', + 'gitView.dirtySwitch.revertAndSwitch': 'Cofnij i przełącz', + 'gitView.dirtySwitch.revertIncomplete': 'Nie udało się cofnąć części zmian, więc gałąź nie została przełączona.', 'gitView.common.close': 'Zamknij', 'gitView.common.done': 'Gotowe', 'gitView.common.processing': 'Przetwarzanie...', 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 3f2827c5..9261f8ca 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -13,6 +13,8 @@ export const settingsDict = { 'settings.providers.page.openCodeGo.delete': 'Excluir', 'settings.providers.page.quotaCredentials.saved': 'Credenciais de {provider} salvas.', 'settings.providers.page.quotaCredentials.accessToken': 'Token de acesso', + 'settings.providers.page.quotaCredentials.usageToken': 'Token da API de uso', + 'settings.providers.page.quotaCredentials.exeDevTokenInstructions': 'Execute este comando no terminal e cole o token abaixo. Ele só pode ler o uso de créditos de LLM e expira após 30 dias.', 'settings.providers.page.quotaCredentials.refreshToken': 'Token de atualização', 'settings.providers.page.quotaCredentials.tokenPlaceholder': 'Cole o token', 'settings.providers.page.openCodeGo.saveFailed': 'Não foi possível validar as credenciais do OpenCode Go.', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 4c0425f2..18d7bd12 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -796,6 +796,20 @@ export const dict: Record = { "gitView.commit.stageFilesHint": "Adicione arquivos ao stage para habilitar o commit.", "gitView.commit.title": "Commit", "gitView.common.cancel": "Cancelar", + 'gitView.branch.switchBlockedNotice': 'Alterações sem commit — antes de trocar, será oferecido commit ou reversão.', + 'gitView.branch.unpushedSingle': '1 commit sem push', + 'gitView.branch.unpushedPlural': '{count} commits sem push', + 'gitView.branch.recentBranches': 'Branches recentes', + 'gitView.dirtySwitch.title': 'Alterações sem commit', + 'gitView.dirtySwitch.descriptionSingle': 'A troca para {branch} foi pausada para não perder seu arquivo alterado. Faça commit ou reverta primeiro.', + 'gitView.dirtySwitch.descriptionPlural': 'A troca para {branch} foi pausada para não perder seus {count} arquivos alterados. Faça commit ou reverta primeiro.', + 'gitView.dirtySwitch.commitAndSwitch': 'Fazer commit e trocar', + 'gitView.dirtySwitch.committedNotPushed': 'Commit feito em {branch}. O commit é apenas local — não foi enviado com push.', + 'gitView.dirtySwitch.pushAfterCommit': 'Fazer push após o commit', + 'gitView.dirtySwitch.pushFailed': 'Commit feito, mas o push falhou — a branch não foi trocada.', + 'gitView.dirtySwitch.actionFailed': 'A ação falhou; a branch não foi trocada.', + 'gitView.dirtySwitch.revertAndSwitch': 'Reverter e trocar', + 'gitView.dirtySwitch.revertIncomplete': 'Algumas alterações não puderam ser revertidas, então a branch não foi trocada.', "gitView.common.close": "Fechar", "gitView.common.done": "Concluído", "gitView.common.processing": "Procesando...", @@ -1604,6 +1618,8 @@ export const dict: Record = { 'chat.autoReview.reviewSessionLabel': 'Sessão de revisão', 'chat.autoReview.actions.open': 'Abrir', 'chat.autoReview.actions.stop': 'Parar', + 'chat.draftDirtyNotice.tooltip': 'Esta branch tem arquivos sem commit.\nA nova sessão os verá. Um commit ou um worktree os mantém separados.', + 'chat.draftDirtyNotice.indicatorAria': 'Alterações sem commit neste diretório', "diffView.hunk.label": "Trechos", "diffView.hunk.stage": "Preparar", "diffView.hunk.unstage": "Remover", diff --git a/packages/ui/src/lib/i18n/messages/tr.settings.ts b/packages/ui/src/lib/i18n/messages/tr.settings.ts index ead5621c..02651073 100644 --- a/packages/ui/src/lib/i18n/messages/tr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/tr.settings.ts @@ -13,6 +13,8 @@ export const settingsDict = { 'settings.providers.page.openCodeGo.delete': 'Sil', 'settings.providers.page.quotaCredentials.saved': '{provider} kimlik bilgileri kaydedildi.', 'settings.providers.page.quotaCredentials.accessToken': 'Erişim token\'ı', + 'settings.providers.page.quotaCredentials.usageToken': 'Kullanım API token\'ı', + 'settings.providers.page.quotaCredentials.exeDevTokenInstructions': 'Bu komutu terminalde çalıştırın, ardından token\'ı aşağıya yapıştırın. Yalnızca LLM kredi kullanımını okuyabilir ve 30 gün sonra sona erer.', 'settings.providers.page.quotaCredentials.refreshToken': 'Yenileme token\'ı', 'settings.providers.page.quotaCredentials.tokenPlaceholder': 'Token\'ı yapıştır', 'settings.providers.page.openCodeGo.saveFailed': 'OpenCode Go kimlik bilgileri doğrulanamadı.', diff --git a/packages/ui/src/lib/i18n/messages/tr.ts b/packages/ui/src/lib/i18n/messages/tr.ts index fed29c74..12448fe6 100644 --- a/packages/ui/src/lib/i18n/messages/tr.ts +++ b/packages/ui/src/lib/i18n/messages/tr.ts @@ -777,6 +777,20 @@ export const dict = { 'gitView.commit.stageFilesHint': 'Commit\'i etkinleştirmek için dosyaları stage edin.', 'gitView.commit.title': 'Commit', 'gitView.common.cancel': 'İptal', + 'gitView.branch.switchBlockedNotice': 'Commit edilmemiş değişiklikler var — geçişten önce commit veya geri alma adımı açılır.', + 'gitView.branch.unpushedSingle': '1 commit push edilmedi', + 'gitView.branch.unpushedPlural': '{count} commit push edilmedi', + 'gitView.branch.recentBranches': 'Son kullanılan dallar', + 'gitView.dirtySwitch.title': 'Commit edilmemiş değişiklikler', + 'gitView.dirtySwitch.descriptionSingle': 'Değiştirilen dosyanız kaybolmasın diye {branch} dalına geçiş duraklatıldı. Önce commit edin veya geri alın.', + 'gitView.dirtySwitch.descriptionPlural': 'Değiştirilen {count} dosyanız kaybolmasın diye {branch} dalına geçiş duraklatıldı. Önce commit edin veya geri alın.', + 'gitView.dirtySwitch.commitAndSwitch': 'Commit et ve geç', + 'gitView.dirtySwitch.committedNotPushed': '{branch} dalına commit edildi. Commit yalnızca yerel — push edilmedi.', + 'gitView.dirtySwitch.pushAfterCommit': 'Commit sonrası push et', + 'gitView.dirtySwitch.pushFailed': 'Commit edildi ancak push başarısız oldu — dal değiştirilmedi.', + 'gitView.dirtySwitch.actionFailed': 'İşlem başarısız oldu; dal değiştirilmedi.', + 'gitView.dirtySwitch.revertAndSwitch': 'Geri al ve geç', + 'gitView.dirtySwitch.revertIncomplete': 'Bazı değişiklikler geri alınamadığı için dal değiştirilmedi.', 'gitView.common.close': 'Kapat', 'gitView.common.done': 'Tamam', 'gitView.common.processing': 'İşleniyor...', @@ -796,10 +810,8 @@ export const dict = { 'gitView.conflict.resolveNewSession': 'Yeni session\'da çöz', 'gitView.empty.cleanDescription': 'Tüm değişiklikler commit edildi', 'gitView.empty.cleanTitle': 'Working tree temiz', - 'gitView.empty.discoveringRepositories': 'Git repository\'leri aranıyor...', - 'gitView.empty.discoverFailed': 'Git repository\'leri taranamadı', - 'gitView.empty.retryDiscovery': 'Tekrar dene', - 'gitView.empty.selectRepositoryPlaceholder': 'Bir repository seçin...', + 'gitView.empty.discoveringRepositories': 'Git depoları aranıyor...', + 'gitView.empty.discoverFailed': 'Git depoları taranamadı', 'gitView.empty.pullBehindPlural': '{count} commit pull et', 'gitView.empty.pullBehindSingle': '{count} commit pull et', 'gitView.header.identityTooltip': 'Git kimliği', @@ -963,6 +975,8 @@ export const dict = { 'gitView.conflict.noDetailsAvailable': 'Çakışma detayları mevcut değil', 'gitView.empty.notGitRepository': 'Bu dizin bir Git repository\'si değil', 'gitView.empty.notGitRepositoryDescription': 'Bu dizinde Git\'i başlatın veya bir repository açın.', + 'gitView.empty.retryDiscovery': 'Yeniden dene', + 'gitView.empty.selectRepositoryPlaceholder': 'Bir repository seç...', 'gitView.empty.selectSessionOrDirectory': 'Git durumunu görüntülemek için bir session veya dizin seçin', 'gitView.empty.worktreeFeaturesUnavailable': 'Bu çalışma alanı modunda worktree özellikleri kullanılamıyor.', 'gitView.empty.worktreeSetupDescription': 'Worktree kurulumu tamamlanıyor ve repository durumu hazırlanıyor.', @@ -1588,6 +1602,8 @@ export const dict = { 'chat.autoReview.reviewSessionLabel': 'İnceleme session\'ı', 'chat.autoReview.actions.open': 'Aç', 'chat.autoReview.actions.stop': 'Durdur', + 'chat.draftDirtyNotice.tooltip': 'Bu dalda commit edilmemiş dosyalar var.\nYeni oturum onları görecek. Bir commit veya worktree onları ayrı tutar.', + 'chat.draftDirtyNotice.indicatorAria': 'Bu dizinde commit edilmemiş değişiklikler var', 'diffView.hunk.label': 'Hunk\'lar', 'diffView.hunk.stage': 'Stage', 'diffView.hunk.unstage': 'Unstage', diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index f3d88411..4116ef42 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -13,6 +13,8 @@ export const settingsDict = { 'settings.providers.page.openCodeGo.delete': 'Видалити', 'settings.providers.page.quotaCredentials.saved': 'Облікові дані {provider} збережено.', 'settings.providers.page.quotaCredentials.accessToken': 'Токен доступу', + 'settings.providers.page.quotaCredentials.usageToken': 'Токен API використання', + 'settings.providers.page.quotaCredentials.exeDevTokenInstructions': 'Виконайте цю команду в терміналі, а потім вставте токен нижче. Він може лише читати використання LLM-кредитів і діє 30 днів.', 'settings.providers.page.quotaCredentials.refreshToken': 'Токен оновлення', 'settings.providers.page.quotaCredentials.tokenPlaceholder': 'Вставте токен', 'settings.providers.page.openCodeGo.saveFailed': 'Не вдалося перевірити дані OpenCode Go.', diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 48232f2b..ce612215 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -796,6 +796,20 @@ export const dict: Record = { "gitView.commit.stageFilesHint": "Додайте файли до індексу, щоб увімкнути коміт.", "gitView.commit.title": "Коміт", "gitView.common.cancel": "Скасувати", + 'gitView.branch.switchBlockedNotice': 'Є незакомічені зміни — перед перемиканням спершу буде крок «закомітити або скасувати».', + 'gitView.branch.unpushedSingle': '1 незапушений коміт', + 'gitView.branch.unpushedPlural': 'Незапушені коміти: {count}', + 'gitView.branch.recentBranches': 'Нещодавні гілки', + 'gitView.dirtySwitch.title': 'Незакомічені зміни', + 'gitView.dirtySwitch.descriptionSingle': 'Перемикання на {branch} призупинено, щоб не втратити змінений файл. Спершу закоміть його або скасуй зміни.', + 'gitView.dirtySwitch.descriptionPlural': 'Перемикання на {branch} призупинено, щоб не втратити {count} змінених файлів. Спершу закоміть їх або скасуй зміни.', + 'gitView.dirtySwitch.commitAndSwitch': 'Закомітити й перемкнути', + 'gitView.dirtySwitch.committedNotPushed': 'Закомічено в {branch}. Коміт лише локальний — його не запушено.', + 'gitView.dirtySwitch.pushAfterCommit': 'Запушити після коміту', + 'gitView.dirtySwitch.pushFailed': 'Закомічено, але push не вдався — гілку не перемкнено.', + 'gitView.dirtySwitch.actionFailed': 'Дія не вдалася; гілку не перемкнено.', + 'gitView.dirtySwitch.revertAndSwitch': 'Скасувати зміни й перемкнути', + 'gitView.dirtySwitch.revertIncomplete': 'Частину змін не вдалося скасувати, тому гілку не перемкнено.', "gitView.common.close": "Закрити", "gitView.common.done": "Готово", "gitView.common.processing": "Обробка...", @@ -1604,6 +1618,8 @@ export const dict: Record = { 'chat.autoReview.reviewSessionLabel': 'Сесія ревʼю', 'chat.autoReview.actions.open': 'Відкрити', 'chat.autoReview.actions.stop': 'Зупинити', + 'chat.draftDirtyNotice.tooltip': 'У цій гілці є незакомічені файли.\nНова сесія бачитиме їх. Коміт або worktree тримають їх окремо.', + 'chat.draftDirtyNotice.indicatorAria': 'Незакомічені зміни в цьому каталозі', "diffView.hunk.label": "Шматки", "diffView.hunk.stage": "Додати", "diffView.hunk.unstage": "Прибрати", 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 c0eef58a..01024ad1 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -13,6 +13,8 @@ export const settingsDict = { 'settings.providers.page.openCodeGo.delete': '删除', 'settings.providers.page.quotaCredentials.saved': '已保存 {provider} 凭据。', 'settings.providers.page.quotaCredentials.accessToken': '访问令牌', + 'settings.providers.page.quotaCredentials.usageToken': '用量 API 令牌', + 'settings.providers.page.quotaCredentials.exeDevTokenInstructions': '在终端中运行此命令,然后在下方粘贴令牌。该令牌只能读取 LLM 积分用量,并将在 30 天后过期。', 'settings.providers.page.quotaCredentials.refreshToken': '刷新令牌', 'settings.providers.page.quotaCredentials.tokenPlaceholder': '粘贴令牌', 'settings.providers.page.openCodeGo.saveFailed': '无法验证 OpenCode Go 凭据。', diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 8806c72f..ecb8f316 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -796,6 +796,20 @@ export const dict: Record = { 'gitView.commit.stageFilesHint': '暂存文件以启用提交。', 'gitView.commit.title': '提交', 'gitView.common.cancel': '取消', + 'gitView.branch.switchBlockedNotice': '有未提交的更改 — 切换前会先进入提交或还原步骤。', + 'gitView.branch.unpushedSingle': '1 个未推送的提交', + 'gitView.branch.unpushedPlural': '{count} 个未推送的提交', + 'gitView.branch.recentBranches': '最近分支', + 'gitView.dirtySwitch.title': '未提交的更改', + 'gitView.dirtySwitch.descriptionSingle': '为避免丢失已更改的文件,切换到 {branch} 已暂停。请先提交或还原。', + 'gitView.dirtySwitch.descriptionPlural': '为避免丢失 {count} 个已更改的文件,切换到 {branch} 已暂停。请先提交或还原。', + 'gitView.dirtySwitch.commitAndSwitch': '提交并切换', + 'gitView.dirtySwitch.committedNotPushed': '已提交到 {branch}。该提交仅在本地,尚未推送。', + 'gitView.dirtySwitch.pushAfterCommit': '提交后推送', + 'gitView.dirtySwitch.pushFailed': '已提交,但推送失败 — 未切换分支。', + 'gitView.dirtySwitch.actionFailed': '操作失败,未切换分支。', + 'gitView.dirtySwitch.revertAndSwitch': '还原并切换', + 'gitView.dirtySwitch.revertIncomplete': '部分更改无法还原,因此未切换分支。', 'gitView.common.close': '关闭', 'gitView.common.done': '完成', 'gitView.common.processing': '处理中...', @@ -1592,6 +1606,8 @@ export const dict: Record = { 'chat.autoReview.reviewSessionLabel': '审查会话', 'chat.autoReview.actions.open': '打开', 'chat.autoReview.actions.stop': '停止', + 'chat.draftDirtyNotice.tooltip': '此分支有未提交的文件。\n新会话会看到它们。提交或工作树可将它们分开。', + 'chat.draftDirtyNotice.indicatorAria': '此目录有未提交的更改', 'diffView.hunk.label': '代码块', 'diffView.hunk.stage': '暂存', 'diffView.hunk.unstage': '取消暂存', 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 c0639769..7e5d3f25 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -13,6 +13,8 @@ export const settingsDict = { 'settings.providers.page.openCodeGo.delete': '刪除', 'settings.providers.page.quotaCredentials.saved': '已儲存 {provider} 憑證。', 'settings.providers.page.quotaCredentials.accessToken': '存取權杖', + 'settings.providers.page.quotaCredentials.usageToken': '用量 API 權杖', + 'settings.providers.page.quotaCredentials.exeDevTokenInstructions': '在終端機中執行此命令,然後在下方貼上權杖。該權杖只能讀取 LLM 點數用量,並將在 30 天後到期。', 'settings.providers.page.quotaCredentials.refreshToken': '重新整理權杖', 'settings.providers.page.quotaCredentials.tokenPlaceholder': '貼上權杖', 'settings.providers.page.openCodeGo.saveFailed': '無法驗證 OpenCode Go 憑證。', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index e56ee9d0..1dc94f20 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -809,6 +809,20 @@ export const dict: Record = { 'gitView.commit.stageFilesHint': '暫存文件以啟用提交。', 'gitView.commit.title': '提交', 'gitView.common.cancel': '取消', + 'gitView.branch.switchBlockedNotice': '有未提交的變更 — 切換前會先進入提交或還原步驟。', + 'gitView.branch.unpushedSingle': '1 個未推送的提交', + 'gitView.branch.unpushedPlural': '{count} 個未推送的提交', + 'gitView.branch.recentBranches': '最近分支', + 'gitView.dirtySwitch.title': '未提交的變更', + 'gitView.dirtySwitch.descriptionSingle': '為避免遺失已變更的檔案,切換到 {branch} 已暫停。請先提交或還原。', + 'gitView.dirtySwitch.descriptionPlural': '為避免遺失 {count} 個已變更的檔案,切換到 {branch} 已暫停。請先提交或還原。', + 'gitView.dirtySwitch.commitAndSwitch': '提交並切換', + 'gitView.dirtySwitch.committedNotPushed': '已提交到 {branch}。該提交僅在本地,尚未推送。', + 'gitView.dirtySwitch.pushAfterCommit': '提交後推送', + 'gitView.dirtySwitch.pushFailed': '已提交,但推送失敗 — 未切換分支。', + 'gitView.dirtySwitch.actionFailed': '操作失敗,未切換分支。', + 'gitView.dirtySwitch.revertAndSwitch': '還原並切換', + 'gitView.dirtySwitch.revertIncomplete': '部分變更無法還原,因此未切換分支。', 'gitView.common.close': '關閉', 'gitView.common.done': '完成', 'gitView.common.processing': '處理中...', @@ -1602,6 +1616,8 @@ export const dict: Record = { 'chat.autoReview.reviewSessionLabel': '審查工作階段', 'chat.autoReview.actions.open': '開啟', 'chat.autoReview.actions.stop': '停止', + 'chat.draftDirtyNotice.tooltip': '此分支有未提交的檔案。\n新的工作階段會看到它們。提交或工作樹可將它們分開。', + 'chat.draftDirtyNotice.indicatorAria': '此目錄有未提交的變更', 'diffView.hunk.label': '程式碼區塊', 'diffView.hunk.stage': '暫存', 'diffView.hunk.unstage': '取消暫存', diff --git a/packages/ui/src/lib/persistence.test.ts b/packages/ui/src/lib/persistence.test.ts index ecb8e5c1..0fa1adb0 100644 --- a/packages/ui/src/lib/persistence.test.ts +++ b/packages/ui/src/lib/persistence.test.ts @@ -875,6 +875,7 @@ describe('updateDesktopSettings', () => { expect(synced.length).toBeGreaterThan(0); const bootstrapSync = synced.find((detail) => detail.bootstrap); expect(bootstrapSync).toBeTruthy(); + expect(bootstrapSync?.adoptTheme).toBe(true); expect(bootstrapSync?.settings.useSystemTheme).toBe(undefined); expect(bootstrapSync?.settings.lightThemeId).toBe(undefined); expect(bootstrapSync?.settings.darkThemeId).toBe(undefined); @@ -905,8 +906,38 @@ describe('updateDesktopSettings', () => { expect(synced.length).toBeGreaterThan(0); expect(synced.every((detail) => detail.bootstrap === false)).toBe(true); + expect(synced.every((detail) => detail.adoptTheme === false)).toBe(true); expect(synced.every((detail) => detail.settings.themeVariant === 'dark')).toBe(true); }); + + test('allows a bootstrap sync to preserve the current window theme', async () => { + getWindow(); + invalidateSettingsCache(); + registerSettingsApi( + async (changes) => ({ ...changes } as SettingsPayload), + async () => ({ + settings: { activeProjectId: 'project-a', themeVariant: 'dark' }, + source: 'web', + }), + ); + + const synced: SettingsSyncedDetail[] = []; + const listener = (event: Event): void => { + const detail = (event as CustomEvent).detail; + if (detail) synced.push(detail); + }; + window.addEventListener('openchamber:settings-synced', listener); + try { + await syncDesktopSettings({ adoptTheme: false }); + } finally { + window.removeEventListener('openchamber:settings-synced', listener); + } + + const broadcastSync = synced.find((detail) => detail.bootstrap && !detail.adoptTheme); + expect(broadcastSync).toBeTruthy(); + expect(broadcastSync?.settings.activeProjectId).toBe('project-a'); + expect(broadcastSync?.settings.themeVariant).toBe('dark'); + }); }); describe('unload lifecycle flush (#2197)', () => { diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index e719eb35..c5750464 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -205,14 +205,18 @@ export interface SettingsSyncedDetail { not filtered; listeners gate their adoption on this flag and keep their live state for the fields they own. */ bootstrap: boolean; + /** Whether this sync may replace this window's theme preferences. VS Code + settings broadcasts remain bootstrap-grade for shared workspace pointers, + but must not copy one webview's theme into another webview. */ + adoptTheme: boolean; } -const dispatchSettingsSynced = (settings: DesktopSettings, bootstrap: boolean): void => { +const dispatchSettingsSynced = (settings: DesktopSettings, bootstrap: boolean, adoptTheme = bootstrap): void => { if (typeof window === 'undefined') { return; } window.dispatchEvent(new CustomEvent('openchamber:settings-synced', { - detail: { settings, bootstrap }, + detail: { settings, bootstrap, adoptTheme }, })); }; @@ -1900,8 +1904,9 @@ export const invalidateSettingsCache = (): void => { _settingsCache = null; }; -export const syncDesktopSettings = async (options?: { bootstrap?: boolean }): Promise => { +export const syncDesktopSettings = async (options?: { bootstrap?: boolean; adoptTheme?: boolean }): Promise => { const bootstrap = options?.bootstrap !== false; + const adoptTheme = options?.adoptTheme ?? bootstrap; if (typeof window === 'undefined') { return; } @@ -2030,7 +2035,7 @@ export const syncDesktopSettings = async (options?: { bootstrap?: boolean }): Pr if (!isSettingsRuntimeContextCurrent(context)) return; } - dispatchSettingsSynced(authoritativeSettings, bootstrap); + dispatchSettingsSynced(authoritativeSettings, bootstrap, adoptTheme); }; try { diff --git a/packages/ui/src/lib/quota/providers/index.ts b/packages/ui/src/lib/quota/providers/index.ts index 96a4906c..749f5516 100644 --- a/packages/ui/src/lib/quota/providers/index.ts +++ b/packages/ui/src/lib/quota/providers/index.ts @@ -23,6 +23,7 @@ export const QUOTA_PROVIDERS: QuotaProviderMeta[] = [ { id: 'opencode-go', name: 'OpenCode Go' }, { id: 'crof', name: 'CrofAI' }, { id: 'deepseek', name: 'DeepSeek' }, + { id: 'exe-dev', name: 'exe.dev' }, { id: 'neuralwatt', name: 'NeuralWatt' }, { id: 'xai', name: 'xAI' }, ]; diff --git a/packages/ui/src/lib/smallModel.ts b/packages/ui/src/lib/smallModel.ts index 5eb38fab..e701f395 100644 --- a/packages/ui/src/lib/smallModel.ts +++ b/packages/ui/src/lib/smallModel.ts @@ -40,6 +40,7 @@ export async function summarizeSelectionForNotes(text: string, sessionId?: strin body: JSON.stringify({ prompt: trimmed, system: NOTES_SYSTEM_PROMPT, + sessionID: sessionId || undefined, restrictToPreferredProvider: true, ...(preferredProviderID ? { preferredProviderID } : {}), ...(preferredModelID ? { preferredModelID } : {}), diff --git a/packages/ui/src/stores/DOCUMENTATION.md b/packages/ui/src/stores/DOCUMENTATION.md index ac82998a..cf3964b0 100644 --- a/packages/ui/src/stores/DOCUMENTATION.md +++ b/packages/ui/src/stores/DOCUMENTATION.md @@ -38,7 +38,7 @@ Examples: - `useFeatureFlagsStore.ts` - `useUpdateStore.ts` -These stores coordinate visible app state, navigation, selected context-panel tabs, dialogs, and lightweight feature flags. `useUIStore.activeSurface` selects the primary mobile view and the few desktop views that are promoted out of the context panel. It is not a desktop tab selection. Linear panel list filters (status, assignee, team, priority) live here too: the Linear rail surface remounts on switch, so those filters restore from this store rather than component state. `resetLinearIssueListFilters` restores those four defaults together; search stays local to the rail. `linearIssueFocus` is a one-shot identifier so work-status can open a specific issue in that panel; it is not persisted. +These stores coordinate visible app state, navigation, selected context-panel tabs, dialogs, and lightweight feature flags. `useUIStore.activeSurface` selects the primary mobile view and the few desktop views that are promoted out of the context panel. It is not a desktop tab selection. Linear panel list filters (status, assignee, team, priority) live here too: the Linear rail surface remounts on switch, so those filters restore from this store rather than component state. `resetLinearIssueListFilters` restores those four defaults together; search stays local to the rail. The team filter is the one that is not a plain preference: a Linear team belongs to one workspace, and each OpenChamber instance has its own Linear login, so it is persisted per instance in `linearIssueListTeamIdByRuntime` and the flat `linearIssueListTeamId` is derived from it by `applyLinearIssueListFiltersForRuntime` — on an instance switch and when the rail mounts, since rehydration can run before the runtime endpoint is known. Carried across, a team id filters the new instance's list down to nothing. `linearIssueFocus` is a one-shot identifier so work-status can open a specific issue in that panel; it is not persisted. Context-panel session chats mount only the active chat iframe. After installing its message listener, the iframe requests its authoritative visibility from the @@ -84,7 +84,7 @@ Permission auto-accept policy is authoritative in the active Web server or VS Co Shared safe storage treats durable failures per key. A quota or access failure creates an ephemeral override or tombstone for that key without disabling reads and writes for unrelated keys; later writes retry the durable backend. Deferred adapters retain failed operations for a later flush, and malformed Zustand JSON is removed and treated as missing so hydration can recover. -Project and UI settings use successful settings synchronization as authority. Omitted fields in a complete snapshot reset to canonical client defaults, including an omitted project list becoming empty; transport or settings-load failure dispatches no synchronization event and preserves current state. Settings save responses are partial patches and must not clear unrelated in-memory preferences or local mirrors. Debounced settings writes flush best-effort on page hide, document hidden, app freeze, and unload — canceling the pending timer so the write happens exactly once — because a write lost inside the debounce window lets the stale server snapshot override the change on next startup; a hard process kill can still lose the in-flight request. The unload flush uses `keepalive: true` on the HTTP write, because a plain fetch started from `pagehide`/`beforeunload` is cancelled with the document; `navigator.sendBeacon` is not used, as it cannot carry the runtime bearer header. On Capacitor neither `pagehide` nor `beforeunload` fires when the OS suspends the app, so the flush also runs on `App.appStateChange` going inactive. +Project and UI settings use successful settings synchronization as authority. Omitted fields in a complete snapshot reset to canonical client defaults, including an omitted project list becoming empty. Theme fields are the exception: only bootstrap-grade theme adoption applies fields supplied by the server, while omitted fields preserve this window's current runtime-scoped theme and settings save echoes never adopt a theme. VS Code settings broadcasts may still adopt shared workspace pointers without replacing each webview's editor-derived theme. Transport or settings-load failure dispatches no synchronization event and preserves current state. Settings save responses are partial patches and must not clear unrelated in-memory preferences or local mirrors. Debounced settings writes flush best-effort on page hide, document hidden, app freeze, and unload — canceling the pending timer so the write happens exactly once — because a write lost inside the debounce window lets the stale server snapshot override the change on next startup; a hard process kill can still lose the in-flight request. The unload flush uses `keepalive: true` on the HTTP write, because a plain fetch started from `pagehide`/`beforeunload` is cancelled with the document; `navigator.sendBeacon` is not used, as it cannot carry the runtime bearer header. On Capacitor neither `pagehide` nor `beforeunload` fires when the OS suspends the app, so the flush also runs on `App.appStateChange` going inactive. Project ordering defaults to manual. Session display persistence v3 migrates the previously shipped `recent` project order to `manual` while preserving every other explicit sort mode. diff --git a/packages/ui/src/stores/instanceScopedStores.test.ts b/packages/ui/src/stores/instanceScopedStores.test.ts new file mode 100644 index 00000000..294cf6ef --- /dev/null +++ b/packages/ui/src/stores/instanceScopedStores.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, mock, test } from 'bun:test'; +import type { McpStatus } from '@opencode-ai/sdk/v2'; +import type { McpStatusMap } from './useMcpStore'; + +type Deferred = { promise: Promise; resolve: (value: T) => void }; +const deferred = (): Deferred => { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { resolve = res; }); + return { promise, resolve }; +}; + +type McpStatusResult = Awaited['mcp']['status']>>; +let mcpStatusResponse: Deferred = deferred(); +const opencodeModule = await import('@/lib/opencode/client'); +// Derived from the real client rather than spread from it: the client is a +// class instance, so a spread drops every prototype method the other modules +// loaded in this process call at import time. +// SAFETY: `Object.create` returns `any`; the object delegates to the real +// client for everything the two overrides below do not define. +const opencodeClientStub = Object.create(opencodeModule.opencodeClient) as typeof opencodeModule.opencodeClient; +// The SDK client is derived the same way, so only `mcp.status` is replaced and +// every other endpoint keeps its real implementation and type. +type McpApiClient = ReturnType; +const realApiClient = opencodeModule.opencodeClient.getApiClient(); +const mcpApiStub: McpApiClient = Object.create(realApiClient, { + mcp: { value: { ...realApiClient.mcp, status: () => mcpStatusResponse.promise } }, +}); +opencodeClientStub.getApiClient = () => mcpApiStub; +opencodeClientStub.getScopedApiClient = () => mcpApiStub; +mock.module('@/lib/opencode/client', () => ({ ...opencodeModule, opencodeClient: opencodeClientStub })); + +let skillsResponse: Deferred = deferred(); +const runtimeFetchModule = await import('@/lib/runtime-fetch'); +mock.module('@/lib/runtime-fetch', () => ({ + ...runtimeFetchModule, + runtimeFetch: () => skillsResponse.promise, +})); + +const { useMcpStore } = await import('./useMcpStore'); +const { useSkillsStore } = await import('./useSkillsStore'); + +const mcpStatusResult = (data: McpStatusMap): McpStatusResult => ({ + data, + request: new Request('http://localhost/mcp'), + response: new Response(), +}); + +const connectedServer = (name: string): McpStatusMap => ({ + // SAFETY: the store only reads `status` off each entry; the SDK type carries + // fields no consumer in this test path touches. + [name]: { status: 'connected' } as McpStatus, +}); + +describe('instance-scoped stores reject responses from the previous instance', () => { + beforeEach(() => { + mcpStatusResponse = deferred(); + skillsResponse = deferred(); + useMcpStore.getState().resetForRuntimeSwitch(); + useSkillsStore.getState().resetForRuntimeSwitch(); + }); + + test('an MCP status in flight during a switch does not land in the new instance', async () => { + const refresh = useMcpStore.getState().refresh({ directory: '/repo', silent: true }); + + useMcpStore.getState().resetForRuntimeSwitch(); + mcpStatusResponse.resolve(mcpStatusResult(connectedServer('from-instance-a'))); + await refresh; + + expect(useMcpStore.getState().getStatusForDirectory('/repo')).toEqual({}); + }); + + test('an MCP status that arrives with no switch is stored', async () => { + const refresh = useMcpStore.getState().refresh({ directory: '/repo', silent: true }); + mcpStatusResponse.resolve(mcpStatusResult(connectedServer('server-a'))); + await refresh; + + expect(Object.keys(useMcpStore.getState().getStatusForDirectory('/repo'))).toEqual(['server-a']); + }); + + test('a skills load in flight during a switch does not land in the new instance', async () => { + const load = useSkillsStore.getState().loadSkills('/repo'); + + useSkillsStore.getState().resetForRuntimeSwitch(); + skillsResponse.resolve(new Response( + JSON.stringify({ skills: [{ name: 'from-instance-a', path: '/repo/.agents/skills/a/SKILL.md' }] }), + { status: 200, headers: { 'content-type': 'application/json' } }, + )); + await load; + + expect(useSkillsStore.getState().skillsByDirectory['/repo']).toBe(undefined); + }); +}); diff --git a/packages/ui/src/stores/useGitHubAuthStore.ts b/packages/ui/src/stores/useGitHubAuthStore.ts index 410267cc..2d16b773 100644 --- a/packages/ui/src/stores/useGitHubAuthStore.ts +++ b/packages/ui/src/stores/useGitHubAuthStore.ts @@ -13,6 +13,8 @@ type GitHubAuthStore = { runtimeGitHub?: RuntimeAPIs['github'], options?: { force?: boolean } ) => Promise; + /** Same instance-scoping as Linear: the login lives on the connected instance. */ + resetForRuntimeSwitch: () => void; }; const fetchStatus = async ( @@ -36,6 +38,9 @@ const fetchStatus = async ( // In-flight dedup for refreshStatus let _inFlightAuthRefresh: Promise | null = null; +// Bumped by every reset so a response already in flight for the previous +// instance cannot write itself into the new instance's status. +let authGeneration = 0; export const useGitHubAuthStore = create((set, get) => ({ status: null, @@ -50,13 +55,16 @@ export const useGitHubAuthStore = create((set, get) => ({ if (_inFlightAuthRefresh) return _inFlightAuthRefresh; + const generation = authGeneration; set({ isLoading: true }); _inFlightAuthRefresh = (async () => { try { const payload = await fetchStatus(runtimeGitHub); + if (generation !== authGeneration) return null; set({ status: payload, isLoading: false, hasChecked: true }); return payload; } catch (error) { + if (generation !== authGeneration) return null; const message = error instanceof Error ? error.message : String(error); set({ status: { connected: false, error: message }, @@ -69,4 +77,9 @@ export const useGitHubAuthStore = create((set, get) => ({ return _inFlightAuthRefresh; }, + resetForRuntimeSwitch: () => { + authGeneration += 1; + _inFlightAuthRefresh = null; + set({ status: null, isLoading: false, hasChecked: false }); + }, })); diff --git a/packages/ui/src/stores/useLinearAuthStore.test.ts b/packages/ui/src/stores/useLinearAuthStore.test.ts new file mode 100644 index 00000000..931b129e --- /dev/null +++ b/packages/ui/src/stores/useLinearAuthStore.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test" +import type { LinearAPI, LinearAuthStatus } from "@/lib/api/types" + +mock.module("@/lib/runtime-fetch", () => ({ runtimeFetch: async () => new Response("{}") })) + +const { useLinearAuthStore } = await import("./useLinearAuthStore") + +const deferred = () => { + let resolve!: (value: T) => void + const promise = new Promise((res) => { resolve = res }) + return { promise, resolve } +} + +// Only `authStatus` is exercised here; the rest of the surface is present so +// the stub is a real `LinearAPI` rather than an assertion over a fragment. +const unreachable = () => Promise.reject(new Error("not used in this test")) +const linearApi = (authStatus: LinearAPI["authStatus"]): LinearAPI => ({ + authStatus, + authStart: unreachable, + authDisconnect: unreachable, + authActivate: unreachable, + issuesList: unreachable, + issueGet: unreachable, + issueStates: unreachable, + issueUpdate: unreachable, + mappingGet: unreachable, + mappingSet: unreachable, + sessionStatusPost: unreachable, + preferencesGet: unreachable, + preferencesSet: unreachable, +}) + +describe("Linear auth is scoped to the connected instance", () => { + beforeEach(() => { + useLinearAuthStore.getState().resetForRuntimeSwitch() + }) + + test("a switch drops the previous instance's login", async () => { + await useLinearAuthStore.getState().refreshStatus( + linearApi(async () => ({ connected: true })), + { force: true }, + ) + expect(useLinearAuthStore.getState().status?.connected).toBe(true) + + useLinearAuthStore.getState().resetForRuntimeSwitch() + + expect(useLinearAuthStore.getState().status).toBeNull() + expect(useLinearAuthStore.getState().hasChecked).toBe(false) + }) + + test("a status still in flight for the previous instance cannot land in the new one", async () => { + const pending = deferred() + const refresh = useLinearAuthStore.getState().refreshStatus( + linearApi(() => pending.promise), + { force: true }, + ) + + useLinearAuthStore.getState().resetForRuntimeSwitch() + pending.resolve({ connected: true }) + await refresh + + expect(useLinearAuthStore.getState().status).toBeNull() + expect(useLinearAuthStore.getState().hasChecked).toBe(false) + }) + + test("a failed check is not an authoritative disconnect", async () => { + await useLinearAuthStore.getState().refreshStatus( + linearApi(async () => ({ connected: true })), + { force: true }, + ) + await useLinearAuthStore.getState().refreshStatus( + linearApi(async () => { throw new Error("offline") }), + { force: true }, + ) + + expect(useLinearAuthStore.getState().status?.connected).toBe(true) + expect(useLinearAuthStore.getState().status?.error).toBe("offline") + }) +}) diff --git a/packages/ui/src/stores/useLinearAuthStore.ts b/packages/ui/src/stores/useLinearAuthStore.ts index 95560a2f..70aebfcc 100644 --- a/packages/ui/src/stores/useLinearAuthStore.ts +++ b/packages/ui/src/stores/useLinearAuthStore.ts @@ -12,6 +12,13 @@ type LinearAuthStore = { runtimeLinear?: RuntimeAPIs['linear'], options?: { force?: boolean } ) => Promise; + /** + * Linear is authenticated on the OpenChamber instance, not in the browser, so + * this status belongs to whichever instance is connected. Switching instances + * must drop it — otherwise the previous instance's login stays on screen and + * its issue surfaces remain usable against a runtime that has no Linear at all. + */ + resetForRuntimeSwitch: () => void; }; const fetchStatus = async ( @@ -24,6 +31,9 @@ const fetchStatus = async ( }; let inFlightAuthRefresh: Promise | null = null; +// Bumped by every reset so a response already in flight for the previous +// instance cannot write itself into the new instance's status. +let authGeneration = 0; export const useLinearAuthStore = create((set, get) => ({ status: null, @@ -41,13 +51,16 @@ export const useLinearAuthStore = create((set, get) => ({ if (inFlightAuthRefresh) return inFlightAuthRefresh; + const generation = authGeneration; set({ isLoading: true }); inFlightAuthRefresh = (async () => { try { const payload = await fetchStatus(runtimeLinear); + if (generation !== authGeneration) return null; set({ status: payload, isLoading: false, hasChecked: true }); return payload; } catch (error) { + if (generation !== authGeneration) return null; const message = error instanceof Error ? error.message : String(error); // A failed request is not an authoritative disconnect. Keep the last // known status and leave `hasChecked` false so the next caller retries @@ -64,4 +77,9 @@ export const useLinearAuthStore = create((set, get) => ({ return inFlightAuthRefresh; }, + resetForRuntimeSwitch: () => { + authGeneration += 1; + inFlightAuthRefresh = null; + set({ status: null, isLoading: false, hasChecked: false }); + }, })); diff --git a/packages/ui/src/stores/useMcpStore.ts b/packages/ui/src/stores/useMcpStore.ts index 915c83c1..2946129e 100644 --- a/packages/ui/src/stores/useMcpStore.ts +++ b/packages/ui/src/stores/useMcpStore.ts @@ -54,6 +54,10 @@ type RefreshOptions = { }; const ensureFreshInFlight = new Map>(); +// Bumped on every runtime switch. Status is keyed by directory alone and two +// instances can hold the same project path, so a request already in flight for +// the previous instance would otherwise write its servers over the new one's. +let mcpGeneration = 0; type TestConnectionResult = { status?: McpStatus; @@ -91,6 +95,12 @@ interface McpStore { completeAuth: (name: string, code: string, directory?: string | null) => Promise; clearAuth: (name: string, directory?: string | null) => Promise; testConnection: (name: string, directory?: string | null) => Promise; + /** + * MCP status is keyed by directory alone, and two instances can hold the same + * project path — so on a switch the previous instance's servers would be + * reported for the new one. Drop everything and let consumers re-ask. + */ + resetForRuntimeSwitch: () => void; } export const useMcpStore = create()( @@ -101,6 +111,18 @@ export const useMcpStore = create()( lastErrorKeys: {}, refreshedAtKeys: {}, + resetForRuntimeSwitch: () => { + mcpGeneration += 1; + ensureFreshInFlight.clear(); + set({ + byDirectory: {}, + diagnosticsByDirectory: {}, + loadingKeys: {}, + lastErrorKeys: {}, + refreshedAtKeys: {}, + }); + }, + getStatusForDirectory: (directory) => { const key = toKey(directory ?? useDirectoryStore.getState().currentDirectory); return get().byDirectory[key] ?? EMPTY_STATUS; @@ -127,9 +149,11 @@ export const useMcpStore = create()( })); } + const generation = mcpGeneration; try { const api = getMcpApiClient(directory); const result = await api.mcp.status(); + if (generation !== mcpGeneration) return; const data = (result.data ?? {}) as McpStatusMap; set((state) => ({ @@ -145,6 +169,7 @@ export const useMcpStore = create()( refreshedAtKeys: { ...state.refreshedAtKeys, [key]: Date.now() }, })); } catch (error) { + if (generation !== mcpGeneration) return; const message = error instanceof Error ? error.message : 'Failed to load MCP status'; set((state) => ({ loadingKeys: { ...state.loadingKeys, [key]: false }, diff --git a/packages/ui/src/stores/useQuotaStore.test.ts b/packages/ui/src/stores/useQuotaStore.test.ts new file mode 100644 index 00000000..81d2d4d9 --- /dev/null +++ b/packages/ui/src/stores/useQuotaStore.test.ts @@ -0,0 +1,139 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test" +import type { ProviderResult } from "@/types" + +let runtimeKey = "url:https://instance-a" +let isInitialized = true +const fetched: string[] = [] + +type StubPayload = { usageDropdownProviders: string[] } | ProviderResult +let quotaRequestsFail = false; +const json = (body: StubPayload) => new Response( + JSON.stringify(body), + { status: 200, headers: { "content-type": "application/json" } }, +) + +// Spread the real modules so the overrides stay a patch: `mock.module` is +// process-global, and a partial replacement would break every other module +// that imports something else from these files. +const runtimeSwitch = await import("@/lib/runtime-switch") +mock.module("@/lib/runtime-switch", () => ({ ...runtimeSwitch, getRuntimeKey: () => runtimeKey })) + +const runtimeFetchModule = await import("@/lib/runtime-fetch") +mock.module("@/lib/runtime-fetch", () => ({ + ...runtimeFetchModule, + runtimeFetch: async (path: string) => { + fetched.push(path) + if (quotaRequestsFail) throw new Error("network down") + if (path.startsWith("/api/config/settings")) return json({ usageDropdownProviders: ["claude"] }) + return json({ providerId: "claude", providerName: "Claude", ok: true, configured: true, usage: null, fetchedAt: 1 }) + }, +})) + +const configStoreModule = await import("@/stores/useConfigStore") +mock.module("@/stores/useConfigStore", () => ({ + ...configStoreModule, + useConfigStore: { ...configStoreModule.useConfigStore, getState: () => ({ isInitialized }) }, +})) + +const { useQuotaStore } = await import("./useQuotaStore") + +describe("Usage quotas are loaded once per ready instance", () => { + beforeEach(() => { + runtimeKey = "url:https://instance-a" + isInitialized = true + fetched.length = 0 + quotaRequestsFail = false + useQuotaStore.getState().resetForRuntimeSwitch() + }) + + test("nothing is fetched while the instance has not reported itself initialised", async () => { + isInitialized = false + await useQuotaStore.getState().ensureLoadedForRuntime() + + expect(fetched).toHaveLength(0) + expect(useQuotaStore.getState().loadedRuntimeKey).toBeNull() + + // The instance finishes starting up: the same call now performs the load + // that a mount-time fetch would have answered "nothing configured". + isInitialized = true + await useQuotaStore.getState().ensureLoadedForRuntime() + + expect(fetched.length).toBeGreaterThan(0) + expect(useQuotaStore.getState().results.length).toBeGreaterThan(0) + }) + + test("a second ask for the same instance does not refetch", async () => { + await useQuotaStore.getState().ensureLoadedForRuntime() + const afterFirst = fetched.length + await useQuotaStore.getState().ensureLoadedForRuntime() + + expect(fetched.length).toBe(afterFirst) + }) + + test("a switch drops the previous instance's quotas and reloads for the new one", async () => { + await useQuotaStore.getState().ensureLoadedForRuntime() + expect(useQuotaStore.getState().results.length).toBeGreaterThan(0) + + useQuotaStore.getState().resetForRuntimeSwitch() + expect(useQuotaStore.getState().results).toEqual([]) + expect(useQuotaStore.getState().lastUpdated).toBeNull() + + runtimeKey = "url:https://instance-b" + fetched.length = 0 + await useQuotaStore.getState().ensureLoadedForRuntime() + + expect(fetched.length).toBeGreaterThan(0) + expect(useQuotaStore.getState().loadedRuntimeKey).toBe("url:https://instance-b") + }) + + test("a quota still in flight for the previous instance cannot land in the new one", async () => { + const pending = useQuotaStore.getState().fetchProviderQuota("claude") + useQuotaStore.getState().resetForRuntimeSwitch() + await pending + + expect(useQuotaStore.getState().results).toEqual([]) + }) + + test("a transient runtime key loads nothing", async () => { + runtimeKey = "mobile-disconnected" + await useQuotaStore.getState().ensureLoadedForRuntime() + + expect(fetched).toHaveLength(0) + }) + + test("a failed load is not recorded as loaded, so the next ask retries it", async () => { + quotaRequestsFail = true + await useQuotaStore.getState().ensureLoadedForRuntime() + + expect(useQuotaStore.getState().loadedRuntimeKey).toBeNull() + + quotaRequestsFail = false + fetched.length = 0 + await useQuotaStore.getState().ensureLoadedForRuntime() + + expect(fetched.length).toBeGreaterThan(0) + expect(useQuotaStore.getState().loadedRuntimeKey).toBe("url:https://instance-a") + }) + + test("concurrent asks share one load", async () => { + await Promise.all([ + useQuotaStore.getState().ensureLoadedForRuntime(), + useQuotaStore.getState().ensureLoadedForRuntime(), + ]) + + expect(fetched.filter((path) => path.startsWith("/api/quota/"))).toHaveLength(1) + }) + + test("a switch drops the previous instance's display settings", async () => { + await useQuotaStore.getState().ensureLoadedForRuntime() + expect(useQuotaStore.getState().dropdownProviderIds).toEqual(["claude"]) + useQuotaStore.getState().setDisplayMode("remaining") + + useQuotaStore.getState().resetForRuntimeSwitch() + + // `dropdownProviderIds` decides which providers get queried, so carrying it + // over would ask the new instance through the old one's selection. + expect(useQuotaStore.getState().dropdownProviderIds.length).toBeGreaterThan(1) + expect(useQuotaStore.getState().displayMode).toBe("usage") + }) +}) diff --git a/packages/ui/src/stores/useQuotaStore.ts b/packages/ui/src/stores/useQuotaStore.ts index 19667325..934231e4 100644 --- a/packages/ui/src/stores/useQuotaStore.ts +++ b/packages/ui/src/stores/useQuotaStore.ts @@ -8,8 +8,15 @@ import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { getDefaultModels } from '@/lib/quota/model-families'; import { updateDesktopSettings } from '@/lib/persistence'; import { runtimeFetch } from '@/lib/runtime-fetch'; +import { getRuntimeKey, isTransientRuntimeKey } from '@/lib/runtime-switch'; +import { useConfigStore } from '@/stores/useConfigStore'; const QUOTA_REFRESH_INTERVAL_MS = 3 * 60 * 1000; +// Quotas and their display settings are read from the connected OpenChamber +// instance, so both belong to that instance. Bumped on every reset so a +// response in flight for the previous instance cannot land in the new one. +let quotaGeneration = 0; +let inFlightRuntimeLoad: Promise | null = null; let quotaAutoRefreshConsumers = 0; let quotaAutoRefreshInterval: number | null = null; @@ -22,6 +29,8 @@ interface QuotaSettingsState { interface QuotaStore extends QuotaSettingsState { results: ProviderResult[]; + /** Instance whose quotas `results` describes, or `null` when nothing is loaded. */ + loadedRuntimeKey: string | null; selectedProviderId: QuotaProviderId | null; isLoading: boolean; isFetchingProvider: Record; @@ -30,8 +39,10 @@ interface QuotaStore extends QuotaSettingsState { loadSettings: () => Promise; fetchAllQuotas: () => Promise; - fetchQuotas: (providerIds: QuotaProviderId[]) => Promise; - fetchProviderQuota: (providerId: QuotaProviderId) => Promise; + /** Resolves true when at least one provider answered — see `ensureLoadedForRuntime`. */ + fetchQuotas: (providerIds: QuotaProviderId[]) => Promise; + /** Resolves true when the instance answered, false on a transport failure. */ + fetchProviderQuota: (providerId: QuotaProviderId) => Promise; setSelectedProvider: (providerId: QuotaProviderId | null) => void; setDisplayMode: (mode: 'usage' | 'remaining') => void; setDropdownProviderIds: (providerIds: QuotaProviderId[]) => void; @@ -40,6 +51,18 @@ interface QuotaStore extends QuotaSettingsState { setExpandedFamilies: (providerId: string, familyIds: string[]) => void; toggleFamilyExpanded: (providerId: string, familyId: string) => void; applyDefaultSelections: (providerId: string, availableModels: string[]) => void; + /** + * Load settings and quotas once per instance, when that instance is ready. + * + * Providers report themselves as configured only after the instance can read + * their credentials, which on a remote instance is not true the moment the UI + * mounts. A fetch fired at mount therefore answers "nothing configured", and + * because every provider then has a result, no consumer asks again until the + * three-minute refresh — which is why Usage stayed missing from the + * work-status panel until Settings -> Usage forced a fresh fetch. + */ + ensureLoadedForRuntime: () => Promise; + resetForRuntimeSwitch: () => void; } const parseSettings = (data: Record | null): QuotaSettingsState => { @@ -84,6 +107,13 @@ const parseSettings = (data: Record | null): QuotaSettingsState }; }; +const defaultQuotaSettings = (): QuotaSettingsState => ({ + displayMode: 'usage', + dropdownProviderIds: QUOTA_PROVIDERS.map((provider) => provider.id), + selectedModels: {}, + expandedFamilies: {}, +}); + const loadSettingsFromRuntime = async (): Promise => { const runtimeSettings = getRegisteredRuntimeAPIs()?.settings; if (runtimeSettings) { @@ -107,18 +137,14 @@ const loadSettingsFromRuntime = async (): Promise => { } } - return { - displayMode: 'usage', - dropdownProviderIds: QUOTA_PROVIDERS.map((provider) => provider.id), - selectedModels: {}, - expandedFamilies: {}, - }; + return defaultQuotaSettings(); }; export const useQuotaStore = create()( devtools( (set, get) => ({ results: [], + loadedRuntimeKey: null, selectedProviderId: null, isLoading: false, isFetchingProvider: {}, @@ -130,8 +156,10 @@ export const useQuotaStore = create()( expandedFamilies: {}, loadSettings: async () => { + const generation = quotaGeneration; try { const settings = await loadSettingsFromRuntime(); + if (generation !== quotaGeneration) return; set(settings); } catch (error) { console.warn('Failed to load usage settings:', error); @@ -139,18 +167,23 @@ export const useQuotaStore = create()( }, fetchQuotas: async (providerIds) => { + const generation = quotaGeneration; set({ isLoading: true, error: null }); try { - await Promise.all( + const answered = await Promise.all( providerIds.map((providerId) => get().fetchProviderQuota(providerId)) ); + if (generation !== quotaGeneration) return false; set({ isLoading: false, lastUpdated: Date.now() }); + return answered.some(Boolean); } catch (error) { + if (generation !== quotaGeneration) return false; const message = error instanceof Error ? error.message : 'Failed to fetch quotas'; set({ isLoading: false, error: message }); + return false; } }, @@ -159,6 +192,7 @@ export const useQuotaStore = create()( }, fetchProviderQuota: async (providerId) => { + const generation = quotaGeneration; set((state) => ({ isFetchingProvider: { ...state.isFetchingProvider, [providerId]: true } })); @@ -169,13 +203,16 @@ export const useQuotaStore = create()( throw new Error(payload?.error || 'Failed to fetch quota'); } + if (generation !== quotaGeneration) return false; const result = payload as ProviderResult; set((state) => { const next = state.results.filter((entry) => entry.providerId !== providerId); next.push(result); return { results: next, error: null }; }); + return true; } catch (error) { + if (generation !== quotaGeneration) return false; const message = error instanceof Error ? error.message : 'Failed to fetch quota'; const fallback: ProviderResult = { providerId, @@ -191,13 +228,62 @@ export const useQuotaStore = create()( next.push(fallback); return { results: next, error: message }; }); + return false; } finally { - set((state) => ({ - isFetchingProvider: { ...state.isFetchingProvider, [providerId]: false } - })); + if (generation === quotaGeneration) { + set((state) => ({ + isFetchingProvider: { ...state.isFetchingProvider, [providerId]: false } + })); + } } }, + ensureLoadedForRuntime: async () => { + const runtimeKey = getRuntimeKey(); + if (isTransientRuntimeKey(runtimeKey)) return; + // Wait for the instance to report itself initialised. Asking earlier + // gets an honest-looking "not configured" for every provider, which is + // then cached as if it were the answer. + if (!useConfigStore.getState().isInitialized) return; + if (get().loadedRuntimeKey === runtimeKey) return; + if (inFlightRuntimeLoad) return inFlightRuntimeLoad; + + const generation = quotaGeneration; + inFlightRuntimeLoad = (async () => { + await get().loadSettings(); + if (generation !== quotaGeneration) return; + const { dropdownProviderIds, fetchQuotas } = get(); + if (dropdownProviderIds.length === 0) return; + const answered = await fetchQuotas(dropdownProviderIds); + // Mark the instance loaded only once it actually answered. Claiming it + // up front meant a load that failed on a cold or briefly unreachable + // instance was never attempted again — Usage would stay empty until + // the three-minute refresh, or forever after a switch. + if (answered && generation === quotaGeneration) set({ loadedRuntimeKey: runtimeKey }); + })().finally(() => { inFlightRuntimeLoad = null; }); + + return inFlightRuntimeLoad; + }, + + resetForRuntimeSwitch: () => { + quotaGeneration += 1; + inFlightRuntimeLoad = null; + set({ + // Display mode, the provider selection and the per-provider model + // picks all come from the instance's own settings, and + // `dropdownProviderIds` decides what gets fetched — carrying them + // over would query the new instance through the old one's choices. + ...defaultQuotaSettings(), + results: [], + loadedRuntimeKey: null, + selectedProviderId: null, + isLoading: false, + isFetchingProvider: {}, + lastUpdated: null, + error: null, + }); + }, + setSelectedProvider: (providerId) => set({ selectedProviderId: providerId }), setDisplayMode: (mode) => set({ displayMode: mode }), setDropdownProviderIds: (providerIds) => set({ dropdownProviderIds: providerIds }), diff --git a/packages/ui/src/stores/useSkillsStore.ts b/packages/ui/src/stores/useSkillsStore.ts index a4c6a4e2..eee64ca5 100644 --- a/packages/ui/src/stores/useSkillsStore.ts +++ b/packages/ui/src/stores/useSkillsStore.ts @@ -173,6 +173,12 @@ interface SkillsStore { renameSkill: (name: string, newName: string, directory?: string | null) => Promise; deleteSkill: (name: string, directory?: string | null) => Promise; getSkillByName: (name: string, directory?: string | null) => DiscoveredSkill | undefined; + /** + * Skills are discovered on the connected instance and cached by directory, + * which two instances can share — so a switch must drop the caches rather + * than report the previous instance's skills for the new one. + */ + resetForRuntimeSwitch: () => void; // Supporting files readSupportingFile: (skillName: string, filePath: string, directory?: string | null) => Promise; @@ -192,6 +198,10 @@ const SKILLS_LOAD_CACHE_TTL_MS = 5000; const DEFAULT_SKILLS_CACHE_KEY = '__default__'; const skillsLastLoadedAt = new Map(); const skillsLoadInFlight = new Map>(); +// Bumped on every runtime switch. Skills are discovered on the connected +// instance and cached by directory, which two instances can share, so a load +// already in flight for the previous instance must not write into the new one. +let skillsGeneration = 0; const getSkillsCacheKey = (directory: string | null): string => { return directory?.trim() || DEFAULT_SKILLS_CACHE_KEY; @@ -279,6 +289,13 @@ export const useSkillsStore = create()( isLoading: false, skillDraft: null, + resetForRuntimeSwitch: () => { + skillsGeneration += 1; + skillsLastLoadedAt.clear(); + skillsLoadInFlight.clear(); + set({ skills: [], skillsByDirectory: {}, isLoading: false }); + }, + setSelectedSkill: (name: string | null) => { set({ selectedSkillName: name }); }, @@ -304,6 +321,7 @@ export const useSkillsStore = create()( return inFlight; } + const generation = skillsGeneration; const request = (async () => { set({ isLoading: true }); // Failure must never look like an empty project. The mirror is the @@ -349,6 +367,7 @@ export const useSkillsStore = create()( data.externalSkills ?? null, ); + if (generation !== skillsGeneration) return false; set((state) => { const next: Partial = { skillsByDirectory: { ...state.skillsByDirectory, [cacheKey]: visibleSkills }, @@ -367,6 +386,7 @@ export const useSkillsStore = create()( } console.error("Failed to load skills:", lastError); + if (generation !== skillsGeneration) return false; set((state) => { const next: Partial = { skillsByDirectory: { ...state.skillsByDirectory, [cacheKey]: previousSkills }, diff --git a/packages/ui/src/stores/useUIStore.linearFilters.test.ts b/packages/ui/src/stores/useUIStore.linearFilters.test.ts index 44789aa4..952f79cc 100644 --- a/packages/ui/src/stores/useUIStore.linearFilters.test.ts +++ b/packages/ui/src/stores/useUIStore.linearFilters.test.ts @@ -1,12 +1,19 @@ -import { beforeEach, describe, expect, test } from 'bun:test'; -import { LINEAR_ISSUE_LIST_ALL_TEAMS, useUIStore } from './useUIStore'; +import { beforeEach, describe, expect, mock, test } from 'bun:test'; + +let runtimeKey = 'url:https://instance-a'; +const runtimeSwitch = await import('@/lib/runtime-switch'); +mock.module('@/lib/runtime-switch', () => ({ ...runtimeSwitch, getRuntimeKey: () => runtimeKey })); + +const { LINEAR_ISSUE_LIST_ALL_TEAMS, useUIStore } = await import('./useUIStore'); describe('linear issue list filters', () => { beforeEach(() => { + runtimeKey = 'url:https://instance-a'; useUIStore.setState({ linearIssueListStatus: 'all', linearIssueListAssignee: 'any', linearIssueListTeamId: LINEAR_ISSUE_LIST_ALL_TEAMS, + linearIssueListTeamIdByRuntime: {}, linearIssueListPriority: 'all', linearIssueFocus: null, }); @@ -67,4 +74,43 @@ describe('linear issue list filters', () => { useUIStore.getState().setLinearIssueFocus(null); expect(useUIStore.getState().linearIssueFocus).toBeNull(); }); + + test('keeps the team filter with the instance that owns the workspace', () => { + useUIStore.getState().setLinearIssueListTeamId('team-eng'); + expect(useUIStore.getState().linearIssueListTeamId).toBe('team-eng'); + + // Switching instances: a team belongs to one Linear workspace, so the new + // instance opens on all teams rather than on a filter matching nothing. + runtimeKey = 'url:https://instance-b'; + useUIStore.getState().applyLinearIssueListFiltersForRuntime(); + expect(useUIStore.getState().linearIssueListTeamId).toBe(LINEAR_ISSUE_LIST_ALL_TEAMS); + + useUIStore.getState().setLinearIssueListTeamId('team-ops'); + expect(useUIStore.getState().linearIssueListTeamId).toBe('team-ops'); + + // Switching back restores the first instance's own choice. + runtimeKey = 'url:https://instance-a'; + useUIStore.getState().applyLinearIssueListFiltersForRuntime(); + expect(useUIStore.getState().linearIssueListTeamId).toBe('team-eng'); + }); + + test('a transient runtime key stores nothing and reads as all teams', () => { + runtimeKey = 'mobile-disconnected'; + useUIStore.getState().setLinearIssueListTeamId('team-eng'); + + expect(useUIStore.getState().linearIssueListTeamIdByRuntime).toEqual({}); + + useUIStore.getState().applyLinearIssueListFiltersForRuntime(); + expect(useUIStore.getState().linearIssueListTeamId).toBe(LINEAR_ISSUE_LIST_ALL_TEAMS); + }); + + test('resetting filters clears the stored team for this instance only', () => { + useUIStore.getState().setLinearIssueListTeamId('team-eng'); + runtimeKey = 'url:https://instance-b'; + useUIStore.getState().setLinearIssueListTeamId('team-ops'); + + useUIStore.getState().resetLinearIssueListFilters(); + + expect(useUIStore.getState().linearIssueListTeamIdByRuntime).toEqual({ 'url:https://instance-a': 'team-eng' }); + }); }); diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 0066881c..a5b00cf7 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -12,6 +12,7 @@ import type { ProjectRef } from '@/lib/projectContextApi'; import { useFilesViewTabsStore } from './useFilesViewTabsStore'; import { isWindowsArm64 } from '@/lib/platform'; import { isVSCodeRuntime } from '@/lib/desktop'; +import { getRuntimeKey, isTransientRuntimeKey } from '@/lib/runtime-switch'; export type PendingDiffScope = 'working' | 'staged' | 'turn' | 'branch'; export type ContextPanelMode = 'diff' | 'walkthrough' | 'file' | 'context' | 'plan' | 'chat' | 'browser' | 'git' | 'pr' | 'linear' | 'notes' | 'terminal'; @@ -65,6 +66,38 @@ function sanitizeLinearIssueListTeamId(value: unknown): string { return teamId || LINEAR_ISSUE_LIST_ALL_TEAMS; } +/** + * Store the team filter under the connected instance, dropping the entry when + * it falls back to all teams so the map does not accumulate defaults. Transient + * keys (uninitialised, mobile-disconnected) name no instance and are not written. + */ +function writeLinearTeamIdForRuntime( + entries: Record, + teamId: string, +): Record { + const runtimeKey = getRuntimeKey(); + if (isTransientRuntimeKey(runtimeKey)) return entries; + const next = { ...entries }; + if (teamId === LINEAR_ISSUE_LIST_ALL_TEAMS) { + delete next[runtimeKey]; + } else { + next[runtimeKey] = teamId; + } + return next; +} + +function sanitizeLinearIssueListTeamIdByRuntime(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + const entries: Record = {}; + // SAFETY: guarded above as a non-array object; every value is re-checked below. + for (const [runtimeKey, teamId] of Object.entries(value as Record)) { + if (!runtimeKey.trim() || typeof teamId !== 'string') continue; + const sanitized = sanitizeLinearIssueListTeamId(teamId); + if (sanitized !== LINEAR_ISSUE_LIST_ALL_TEAMS) entries[runtimeKey] = sanitized; + } + return entries; +} + function sanitizeLinearIssueListPriority(value: unknown): LinearIssueListPriority { return value === 'none' || value === 'urgent' || value === 'high' || value === 'medium' || value === 'low' || value === 'all' ? value @@ -820,7 +853,16 @@ interface UIStore { gitChangesViewMode: 'flat' | 'tree'; linearIssueListStatus: LinearIssueListStatus; linearIssueListAssignee: LinearIssueListAssignee; + /** + * Team filter for the instance currently connected. A Linear team belongs to + * one workspace, and each OpenChamber instance has its own Linear login, so + * this is derived from `linearIssueListTeamIdByRuntime` rather than persisted + * on its own — a team id carried across a switch filters the new instance's + * list down to nothing. + */ linearIssueListTeamId: string; + /** Team filter per instance, keyed the same way every runtime-scoped cache is. */ + linearIssueListTeamIdByRuntime: Record; linearIssueListPriority: LinearIssueListPriority; /** One-shot identifier for opening a Linear issue in the rail panel. Not persisted. */ linearIssueFocus: string | null; @@ -1023,6 +1065,8 @@ interface UIStore { setLinearIssueListStatus: (status: LinearIssueListStatus) => void; setLinearIssueListAssignee: (assignee: LinearIssueListAssignee) => void; setLinearIssueListTeamId: (teamId: string) => void; + /** Re-read the team filter for the instance now connected. */ + applyLinearIssueListFiltersForRuntime: () => void; setLinearIssueListPriority: (priority: LinearIssueListPriority) => void; resetLinearIssueListFilters: () => void; setLinearIssueFocus: (identifier: string | null) => void; @@ -1186,6 +1230,7 @@ export const useUIStore = create()( linearIssueListStatus: 'all', linearIssueListAssignee: 'any', linearIssueListTeamId: LINEAR_ISSUE_LIST_ALL_TEAMS, + linearIssueListTeamIdByRuntime: {}, linearIssueListPriority: 'all', linearIssueFocus: null, isTimelineDialogOpen: false, @@ -2113,7 +2158,20 @@ export const useUIStore = create()( }, setLinearIssueListTeamId: (teamId) => { - set({ linearIssueListTeamId: sanitizeLinearIssueListTeamId(teamId) }); + const sanitized = sanitizeLinearIssueListTeamId(teamId); + set((state) => ({ + linearIssueListTeamId: sanitized, + linearIssueListTeamIdByRuntime: writeLinearTeamIdForRuntime(state.linearIssueListTeamIdByRuntime, sanitized), + })); + }, + + applyLinearIssueListFiltersForRuntime: () => { + const runtimeKey = getRuntimeKey(); + set((state) => ({ + linearIssueListTeamId: isTransientRuntimeKey(runtimeKey) + ? LINEAR_ISSUE_LIST_ALL_TEAMS + : state.linearIssueListTeamIdByRuntime[runtimeKey] ?? LINEAR_ISSUE_LIST_ALL_TEAMS, + })); }, setLinearIssueListPriority: (priority) => { @@ -2121,12 +2179,16 @@ export const useUIStore = create()( }, resetLinearIssueListFilters: () => { - set({ + set((state) => ({ linearIssueListStatus: 'all', linearIssueListAssignee: 'any', linearIssueListTeamId: LINEAR_ISSUE_LIST_ALL_TEAMS, + linearIssueListTeamIdByRuntime: writeLinearTeamIdForRuntime( + state.linearIssueListTeamIdByRuntime, + LINEAR_ISSUE_LIST_ALL_TEAMS, + ), linearIssueListPriority: 'all', - }); + })); }, setLinearIssueFocus: (identifier) => { @@ -2581,7 +2643,7 @@ export const useUIStore = create()( { name: 'ui-store', storage: createDeferredSafeJSONStorage(), - version: 18, + version: 19, migrate: (persistedState, version) => { if (!persistedState || typeof persistedState !== 'object') { return persistedState; @@ -2792,7 +2854,11 @@ export const useUIStore = create()( state.linearIssueListStatus = sanitizeLinearIssueListStatus(state.linearIssueListStatus); state.linearIssueListAssignee = sanitizeLinearIssueListAssignee(state.linearIssueListAssignee); - state.linearIssueListTeamId = sanitizeLinearIssueListTeamId(state.linearIssueListTeamId); + // v18 -> v19: the team filter became per instance. The legacy flat + // value names a team in one workspace with nothing to say which + // instance it came from, so it is dropped rather than guessed at. + delete state.linearIssueListTeamId; + state.linearIssueListTeamIdByRuntime = sanitizeLinearIssueListTeamIdByRuntime(state.linearIssueListTeamIdByRuntime); state.linearIssueListPriority = sanitizeLinearIssueListPriority(state.linearIssueListPriority); state.fileEditorKeymap = normalizeFileEditorKeymap(state.fileEditorKeymap); @@ -2874,7 +2940,7 @@ export const useUIStore = create()( gitChangesViewMode: state.gitChangesViewMode, linearIssueListStatus: state.linearIssueListStatus, linearIssueListAssignee: state.linearIssueListAssignee, - linearIssueListTeamId: state.linearIssueListTeamId, + linearIssueListTeamIdByRuntime: state.linearIssueListTeamIdByRuntime, linearIssueListPriority: state.linearIssueListPriority, nativeNotificationsEnabled: state.nativeNotificationsEnabled, notificationMode: state.notificationMode, diff --git a/packages/ui/src/sync/__tests__/issue-2039.test.ts b/packages/ui/src/sync/__tests__/issue-2039.test.ts index 7558bac5..b6fe2b09 100644 --- a/packages/ui/src/sync/__tests__/issue-2039.test.ts +++ b/packages/ui/src/sync/__tests__/issue-2039.test.ts @@ -186,7 +186,11 @@ mock.module("../selection-store", () => ({ }, })) +// Spread the real module so the stub stays a patch: anything else importing +// runtime-switch in this process still gets its remaining exports. +const runtimeSwitchModule = await import("@/lib/runtime-switch") mock.module("@/lib/runtime-switch", () => ({ + ...runtimeSwitchModule, getRuntimeApiBaseUrl: () => "", getRuntimeKey: () => "test-runtime", initializeRuntimeEndpoint: () => undefined, diff --git a/packages/ui/src/types/quota.ts b/packages/ui/src/types/quota.ts index 059e4b98..1524ca51 100644 --- a/packages/ui/src/types/quota.ts +++ b/packages/ui/src/types/quota.ts @@ -18,6 +18,7 @@ export type QuotaProviderId = | 'opencode-go' | 'crof' | 'deepseek' + | 'exe-dev' | 'neuralwatt' | 'xai'; diff --git a/packages/ui/src/vite-env.d.ts b/packages/ui/src/vite-env.d.ts index 2e3fa7b4..0c853341 100644 --- a/packages/ui/src/vite-env.d.ts +++ b/packages/ui/src/vite-env.d.ts @@ -1,6 +1,7 @@ /// interface Window { + __openchamberEnsureNerdFonts?: () => Promise; __opencodeDebug?: { getLastAssistantMessage: () => unknown; getAllMessages: (truncate?: boolean) => unknown[]; diff --git a/packages/vscode/src/DOCUMENTATION.md b/packages/vscode/src/DOCUMENTATION.md index c89e0799..35029076 100644 --- a/packages/vscode/src/DOCUMENTATION.md +++ b/packages/vscode/src/DOCUMENTATION.md @@ -72,6 +72,7 @@ The webview build emits each worker as one self-contained file. VS Code webviews - Includes Zen utility model parity handler used by shared notification settings (`/api/zen/models`). - Owns managed OpenCode upgrade status and mutation handlers, including capability reporting, upgrade serialization, and process restart after a successful upgrade. - Provider handlers cover source lookup, disconnect (`DELETE /api/provider/:id/auth`), and custom provider upsert (`PUT /api/provider`; create/update OpenAI-compatible config with explicit `scope` for user/project/custom layers; requires `env` or stored auth; secrets via OpenCode auth API). + - Quota handlers keep managed exe.dev, Ollama Cloud, and Cursor credentials in the extension data directory with the same private-file contract as the web runtime. exe.dev uses one command-scoped usage token for the aggregate billing shared by every `exe-*` model provider. - `opencode-upgrade-runtime.ts` - Owns managed-versus-external capability decisions, latest-version checks, serialized OpenCode self-upgrades, and restart-after-upgrade behavior. diff --git a/packages/vscode/src/bridge-git-runtime.ts b/packages/vscode/src/bridge-git-runtime.ts index 5abb4217..8fdbeb5e 100644 --- a/packages/vscode/src/bridge-git-runtime.ts +++ b/packages/vscode/src/bridge-git-runtime.ts @@ -83,6 +83,16 @@ export async function handleStandardGitBridgeMessage(message: BridgeMessageInput return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; } + case 'api:git/branch-push-status': { + const { directory, branches } = (payload || {}) as { directory?: string; branches?: string[] }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + if (!Array.isArray(branches) || branches.some((branch) => typeof branch !== 'string')) { + return { id, type, success: false, error: 'branches must be an array of branch names' }; + } + return { id, type, success: true, data: await gitService.getGitUnpushedBranchCounts(directory!, branches) }; + } + case 'api:git/remote-branches': { const { directory, branch, remote } = (payload || {}) as { directory?: string; diff --git a/packages/vscode/src/bridge-system-runtime.ts b/packages/vscode/src/bridge-system-runtime.ts index 96de0bee..a99e8181 100644 --- a/packages/vscode/src/bridge-system-runtime.ts +++ b/packages/vscode/src/bridge-system-runtime.ts @@ -555,7 +555,7 @@ export async function handleSystemBridgeMessage( case 'api:quota:credentials': { const { providerId, method, credential: input } = (payload || {}) as { providerId?: ManagedProvider; method?: string; credential?: unknown }; try { - if (!providerId || !['ollama-cloud', 'cursor'].includes(providerId)) return { id, type, success: false, error: 'Unsupported credential provider' }; + if (!providerId || !['exe-dev', 'ollama-cloud', 'cursor'].includes(providerId)) return { id, type, success: false, error: 'Unsupported credential provider' }; if (method === 'GET') return { id, type, success: true, data: credentialStatus(providerId) }; if (method === 'DELETE') { deleteCredential(providerId); return { id, type, success: true, data: { configured: false } }; } if (method === 'IMPORT') { diff --git a/packages/vscode/src/exeDevQuota.test.ts b/packages/vscode/src/exeDevQuota.test.ts new file mode 100644 index 00000000..ce026823 --- /dev/null +++ b/packages/vscode/src/exeDevQuota.test.ts @@ -0,0 +1,30 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { fetchExeDevUsage, parseExeDevUsage } from './exeDevQuota'; + +const payload = { + monthly_allowance_usd: 20, + period_end: '2026-10-01T00:00:00Z', + total_cost_usd: 0.11, +}; + +describe('exe.dev quota', () => { + it('parses monthly credit usage', () => { + const windows = parseExeDevUsage(payload); + assert.ok(windows); + assert.ok(Math.abs((windows.monthly.usedPercent ?? 0) - 0.55) < 0.0001); + assert.equal(windows.monthly.valueLabel, '$0.11 / $20.00'); + }); + + it('executes only the billing usage command', async () => { + const requests: Array<{ url: string; init?: RequestInit }> = []; + const windows = await fetchExeDevUsage('test-token', async (url, init) => { + requests.push({ url: String(url), init }); + return Response.json(payload); + }); + assert.equal(windows.monthly.valueLabel, '$0.11 / $20.00'); + assert.equal(requests[0]?.url, 'https://exe.dev/exec'); + assert.equal(requests[0]?.init?.body, 'billing credits usage --group=day --json'); + assert.equal(new Headers(requests[0]?.init?.headers).get('Authorization'), 'Bearer test-token'); + }); +}); diff --git a/packages/vscode/src/exeDevQuota.ts b/packages/vscode/src/exeDevQuota.ts new file mode 100644 index 00000000..424cb7e9 --- /dev/null +++ b/packages/vscode/src/exeDevQuota.ts @@ -0,0 +1,67 @@ +type ExeDevUsageWindow = { + usedPercent: number | null; + remainingPercent: number | null; + windowSeconds: null; + resetAfterSeconds: number | null; + resetAt: number; + resetAtFormatted: string; + resetAfterFormatted: string | null; + valueLabel: string; +}; + +type ExeDevUsagePayload = { + total_cost_usd?: number | null; + monthly_allowance_usd?: number | null; + period_end?: string | null; +}; + +const EXEC_URL = 'https://exe.dev/exec'; +const USAGE_COMMAND = 'billing credits usage --group=day --json'; + +const numberValue = (value: number | null | undefined) => { + if (value === null || value === undefined || !Number.isFinite(value)) return null; + return value; +}; + +export const parseExeDevUsage = (payload: ExeDevUsagePayload | null): Record | null => { + if (!payload) return null; + const totalCost = numberValue(payload.total_cost_usd); + const monthlyAllowance = numberValue(payload.monthly_allowance_usd); + const resetAt = payload.period_end ? Date.parse(payload.period_end) : Number.NaN; + if (totalCost === null || monthlyAllowance === null || monthlyAllowance < 0 || !Number.isFinite(resetAt)) return null; + const usedPercent = monthlyAllowance > 0 ? Math.min(100, Math.max(0, (totalCost / monthlyAllowance) * 100)) : null; + const remainingPercent = usedPercent === null ? null : Math.max(0, 100 - usedPercent); + const resetAfterSeconds = Math.max(0, Math.floor((resetAt - Date.now()) / 1000)); + return { + monthly: { + usedPercent, + remainingPercent, + windowSeconds: null, + resetAfterSeconds, + resetAt, + resetAtFormatted: new Date(resetAt).toLocaleString(), + resetAfterFormatted: null, + valueLabel: `$${totalCost.toFixed(2)} / $${monthlyAllowance.toFixed(2)}`, + }, + }; +}; + +export const fetchExeDevUsage = async (usageToken: string, fetchImpl: typeof fetch = fetch) => { + const response = await fetchImpl(EXEC_URL, { + method: 'POST', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${usageToken}`, + 'Content-Type': 'text/plain', + 'User-Agent': 'OpenChamber quota provider', + }, + body: USAGE_COMMAND, + signal: AbortSignal.timeout(15_000), + }); + if (response.status === 401 || response.status === 403) throw new Error('exe.dev authentication failed'); + if (!response.ok) throw new Error(`exe.dev usage API returned HTTP ${response.status}`); + const payload: ExeDevUsagePayload | null = await response.text().then((text) => JSON.parse(text)).catch(() => null); + const windows = parseExeDevUsage(payload); + if (!windows) throw new Error('exe.dev usage data could not be parsed'); + return windows; +}; diff --git a/packages/vscode/src/gitService.ts b/packages/vscode/src/gitService.ts index 7d0abcff..c4141bcc 100644 --- a/packages/vscode/src/gitService.ts +++ b/packages/vscode/src/gitService.ts @@ -671,6 +671,23 @@ export interface GitBranchResult { branches: Record; } +export async function getGitUnpushedBranchCounts(directory: string, requestedBranches: string[]): Promise<{ counts: Record }> { + const requested = [...new Set(requestedBranches)].filter(Boolean).slice(0, 5); + if (requested.length === 0) return { counts: {} }; + const local = new Set((await getGitBranchesRaw(directory)).all.filter((branch) => !branch.startsWith('remotes/'))); + const counts: Record = {}; + await Promise.all(requested.map(async (branch) => { + if (!local.has(branch)) return; + const upstreamResult = await execGit(['rev-parse', '--abbrev-ref', '--symbolic-full-name', `${branch}@{upstream}`], directory); + const upstream = upstreamResult.exitCode === 0 ? upstreamResult.stdout.trim() : ''; + if (!upstream) return; + const countResult = await execGit(['rev-list', '--count', `${upstream}..${branch}`], directory); + const count = countResult.exitCode === 0 ? Number.parseInt(countResult.stdout.trim(), 10) : 0; + if (Number.isFinite(count) && count > 0) counts[branch] = count; + })); + return { counts }; +} + /** * Get all branches for a directory */ diff --git a/packages/vscode/src/opencodeGoQuota.ts b/packages/vscode/src/opencodeGoQuota.ts index eb9739c2..616d736b 100644 --- a/packages/vscode/src/opencodeGoQuota.ts +++ b/packages/vscode/src/opencodeGoQuota.ts @@ -11,7 +11,7 @@ const toWindow = (usedPercent: number, resetAt: string) => ({ }); export const fetchOpenCodeGoUsage = async (credential: OpenCodeGoCredential) => { - const response = await fetch('https://opencode.ai/zen/go/v1/usage', { headers: { Accept: 'application/json', Authorization: `Bearer ${credential.apiKey}` }, signal: AbortSignal.timeout(15_000) }); + const response = await fetch('https://opencode.ai/zen/go/v1/usage', { headers: { Accept: 'application/json', Authorization: `Bearer ${credential.apiKey}`, 'x-opencode-session': 'openchamber-usage' }, signal: AbortSignal.timeout(15_000) }); if (response.status === 401 || response.status === 403 || (response.status >= 300 && response.status < 400)) throw new Error('OpenCode Go authentication failed'); if (!response.ok) throw new Error(`OpenCode Go usage API returned HTTP ${response.status}`); const payload = await response.json().catch(() => null) as { usage?: Record } | null; diff --git a/packages/vscode/src/quotaCredentials.ts b/packages/vscode/src/quotaCredentials.ts index 3aa185dc..b7f5968d 100644 --- a/packages/vscode/src/quotaCredentials.ts +++ b/packages/vscode/src/quotaCredentials.ts @@ -2,10 +2,11 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { execFileSync } from 'node:child_process'; +import { fetchExeDevUsage } from './exeDevQuota'; -export type ManagedProvider = 'ollama-cloud' | 'cursor'; +export type ManagedProvider = 'exe-dev' | 'ollama-cloud' | 'cursor'; export type ManagedCredential = Record; -const providers = new Set(['ollama-cloud', 'cursor']); +const providers = new Set(['exe-dev', 'ollama-cloud', 'cursor']); const directory = () => path.join(process.env.OPENCHAMBER_DATA_DIR ? path.resolve(process.env.OPENCHAMBER_DATA_DIR) : path.join(os.homedir(), '.config', 'openchamber'), 'quota'); const target = (provider: ManagedProvider) => { if (!providers.has(provider)) throw new Error('Unsupported credential provider'); @@ -15,6 +16,7 @@ const clean = (value: unknown) => typeof value === 'string' && !/[\r\n]/.test(va export const normalizeCredential = (provider: ManagedProvider, value: unknown): ManagedCredential | null => { const data = value && typeof value === 'object' ? value as Record : {}; + if (provider === 'exe-dev') return clean(data.usageToken) ? { usageToken: clean(data.usageToken) } : null; if (provider === 'ollama-cloud') return clean(data.cookie) ? { cookie: clean(data.cookie) } : null; const accessToken = clean(data.accessToken); const refreshToken = clean(data.refreshToken); @@ -52,6 +54,7 @@ export const importCursorCredential = () => { }; export const validateCredential = async (provider: ManagedProvider, credential: ManagedCredential) => { + if (provider === 'exe-dev') await fetchExeDevUsage(credential.usageToken); if (provider === 'ollama-cloud') { const response = await fetch('https://ollama.com/settings', { headers: { Cookie: credential.cookie }, redirect: 'manual', signal: AbortSignal.timeout(15_000) }); if (!response.ok || (response.status >= 300 && response.status < 400)) throw new Error('Ollama Cloud authentication failed'); diff --git a/packages/vscode/src/quotaProviders.test.ts b/packages/vscode/src/quotaProviders.test.ts index 783583a5..62a8f014 100644 --- a/packages/vscode/src/quotaProviders.test.ts +++ b/packages/vscode/src/quotaProviders.test.ts @@ -99,6 +99,7 @@ describe('OpenCode Go quota provider (VS Code parity)', () => { assert.equal(result.ok, true); assert.equal((request?.headers as Record).Authorization, 'Bearer test-token'); + assert.equal((request?.headers as Record)['x-opencode-session'], 'openchamber-usage'); assert.equal(result.usage!.windows['5h']!.usedPercent, 25); assert.throws(() => fs.statSync(legacyPath)); }); diff --git a/packages/vscode/src/quotaProviders.ts b/packages/vscode/src/quotaProviders.ts index a9ce3204..c275a07d 100644 --- a/packages/vscode/src/quotaProviders.ts +++ b/packages/vscode/src/quotaProviders.ts @@ -4,6 +4,7 @@ import os from 'node:os'; import { fetchOpenCodeGoUsage } from './opencodeGoQuota'; import { deleteLegacyOpenCodeGoCredential, readCredential } from './quotaCredentials'; import { getProviderAuth, updateProviderAuth } from './opencodeAuth'; +import { fetchExeDevUsage } from './exeDevQuota'; type AuthEntry = Record | string; type AuthFile = Record; @@ -774,6 +775,7 @@ export const listConfiguredQuotaProviders = () => { if (openCodeGoAuth && (typeof openCodeGoAuth.key === 'string' || typeof openCodeGoAuth.token === 'string')) configured.add('opencode-go'); if (readCredential('ollama-cloud')) configured.add('ollama-cloud'); if (readCredential('cursor')) configured.add('cursor'); + if (readCredential('exe-dev')) configured.add('exe-dev'); const anthropicAuth = normalizeAuthEntry(getAuthEntry(auth, ['anthropic', 'claude'])); if (anthropicAuth && ((anthropicAuth as Record).access || (anthropicAuth as Record).token)) { @@ -1946,6 +1948,16 @@ const fetchOllamaCloudQuota = async (): Promise => { } }; +const fetchExeDevQuota = async (): Promise => { + const usageToken = readCredential('exe-dev')?.usageToken; + if (!usageToken) return buildResult({ providerId: 'exe-dev', providerName: 'exe.dev', ok: false, configured: false, error: 'Not configured' }); + try { + return buildResult({ providerId: 'exe-dev', providerName: 'exe.dev', ok: true, configured: true, usage: { windows: await fetchExeDevUsage(usageToken) } }); + } catch (error) { + return buildResult({ providerId: 'exe-dev', providerName: 'exe.dev', ok: false, configured: true, error: error instanceof Error ? error.message : 'Request failed' }); + } +}; + const fetchCursorQuota = async (): Promise => { const accessToken = readCredential('cursor')?.accessToken; if (!accessToken) return buildResult({ providerId: 'cursor', providerName: 'Cursor', ok: false, configured: false, error: 'Not configured' }); @@ -2868,6 +2880,8 @@ const fetchQuotaForProviderUncoalesced = async (providerId: string): Promise ({ return sendBridgeMessage('api:git/branches', { directory, method: 'GET' }); }, + getGitUnpushedBranchCounts: async (directory: string, branches: string[]) => { + return sendBridgeMessage('api:git/branch-push-status', { directory, branches }); + }, + deleteGitBranch: async (directory: string, payload: GitDeleteBranchPayload): Promise<{ success: boolean }> => { return sendBridgeMessage<{ success: boolean }>('api:git/branches', { directory, diff --git a/packages/vscode/webview/main.tsx b/packages/vscode/webview/main.tsx index a49ba39f..03e8b1d1 100644 --- a/packages/vscode/webview/main.tsx +++ b/packages/vscode/webview/main.tsx @@ -1859,7 +1859,7 @@ window.addEventListener('openchamber:vscode-notification-event', (event) => { // Listen for settings sync command from extension (broadcast to all VS Code webviews) onCommand('settingsSynced', () => { import('@openchamber/ui/lib/persistence').then(({ syncDesktopSettings }) => { - void syncDesktopSettings(); + void syncDesktopSettings({ adoptTheme: false }); }); }); diff --git a/packages/web/server/lib/dev-tunnel/DOCUMENTATION.md b/packages/web/server/lib/dev-tunnel/DOCUMENTATION.md index b341090c..c8d264df 100644 --- a/packages/web/server/lib/dev-tunnel/DOCUMENTATION.md +++ b/packages/web/server/lib/dev-tunnel/DOCUMENTATION.md @@ -20,7 +20,13 @@ and what made it fragile per framework. - `client.js` is the local end: it binds a loopback listener on the user's machine and pipes each accepted connection through one WebSocket. It lives in this package because it needs a WebSocket client the package already depends - on; the desktop shell drives it over IPC. + on; the desktop shell drives it over IPC for directly reachable HTTP(S) + runtimes. +- Relay-only runtimes use `packages/electron/relay-dev-tunnel.mjs` for the local + listener. Each accepted connection gets an Electron `MessagePort`; the trusted + renderer carries its bytes through the active E2EE relay. This keeps relay + credentials and encryption in their existing renderer owner instead of + duplicating them in Electron main. - Port discovery is not owned here. `runtime.js` is given the reachable set by the same dev-server discovery the user's own list is built from. - The browser panel decides when to tunnel; this module never chooses a target. @@ -40,9 +46,12 @@ and what made it fragile per framework. usual origin allowlist applies unchanged. That check is a CSRF defence: a hostile page can make a browser open a WebSocket carrying ambient cookies, and the origin is what exposes it. - - With no `Origin` the request must carry client-token auth. A browser cannot - reach this path — the WebSocket API always sends an origin and never lets a - page set an `Authorization` header — so this case is the desktop shell. + - With no `Origin` the request must carry client-token auth or a short-lived + URL token. The bearer case is the desktop main process. The URL-token case + is the trusted renderer carrying the socket through the E2EE relay. + - Through the E2EE relay, the trusted renderer mints a short-lived URL token + and includes it in the virtual WebSocket URL. The relay host and URL-token + allowlists accept exactly `/api/dev-tunnel`, not subpaths. - Concurrency is capped per host, not per page, because one page load opens many sockets. - A connection that cannot be established fails the socket rather than holding diff --git a/packages/web/server/lib/dev-tunnel/runtime.js b/packages/web/server/lib/dev-tunnel/runtime.js index efe265a2..46792f18 100644 --- a/packages/web/server/lib/dev-tunnel/runtime.js +++ b/packages/web/server/lib/dev-tunnel/runtime.js @@ -20,9 +20,9 @@ * * - With an `Origin` header, the request came from a browser context and the * usual origin check applies unchanged. - * - With no `Origin`, the request must carry client-token auth. A browser - * cannot reach this path: the WebSocket API always sends an origin and never - * lets a page set an `Authorization` header. + * - With no `Origin`, the request must carry client-token auth or a short-lived + * URL token. The URL-token case is used only by the trusted renderer through + * the E2EE relay; the UI-auth allowlist limits it to this exact path. */ import net from 'node:net'; import { WebSocketServer } from 'ws'; @@ -133,7 +133,7 @@ export function createDevTunnelRuntime({ void (async () => { try { if (uiAuthController?.enabled) { - const auth = await uiAuthController.resolveAuthContext(req, null, { allowUrlToken: false }); + const auth = await uiAuthController.resolveAuthContext(req, null, { allowUrlToken: true }); if (!auth) { rejectWebSocketUpgrade(socket, 401, 'UI authentication required'); return; diff --git a/packages/web/server/lib/dev-tunnel/tunnel.test.js b/packages/web/server/lib/dev-tunnel/tunnel.test.js index 5e593891..e3ab1ba3 100644 --- a/packages/web/server/lib/dev-tunnel/tunnel.test.js +++ b/packages/web/server/lib/dev-tunnel/tunnel.test.js @@ -1,6 +1,7 @@ import { afterEach, describe, expect, test } from 'bun:test'; import http from 'node:http'; import net from 'node:net'; +import WebSocket from 'ws'; import { createDevTunnelClient } from './client.js'; import { createDevTunnelRuntime, isDevTunnelPath } from './runtime.js'; @@ -228,6 +229,12 @@ describe('dev tunnel authentication', () => { enabled: true, resolveAuthContext: async () => ({ type: 'session' }), }; + const urlTokenAuth = { + enabled: true, + resolveAuthContext: async (req, _res, options) => ( + options?.allowUrlToken === true && req.url.includes('oc_url_token=good') ? { type: 'client', token: 'url:authenticated' } : null + ), + }; test('accepts a bearer-authenticated client that sends no origin', async () => { const devPort = await startDevServer((_req, res) => res.end('ok')); @@ -243,6 +250,19 @@ describe('dev tunnel authentication', () => { expect((await httpGet(localPort, '/')).body).toBe('ok'); }); + test('accepts a URL-token client carried by the E2EE relay', async () => { + const devPort = await startDevServer((_req, res) => res.end('relay-ok')); + const host = await startHost({ allowedPorts: [devPort], auth: urlTokenAuth }); + + const body = await new Promise((resolve, reject) => { + const socket = new WebSocket(`ws://127.0.0.1:${host.port}/api/dev-tunnel?port=${devPort}&oc_url_token=good`); + socket.on('open', () => socket.send('GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n')); + socket.on('message', (data) => resolve(Buffer.from(data).toString())); + socket.on('error', reject); + }); + expect(body).toContain('relay-ok'); + }); + test('rejects a client with no credentials', async () => { const devPort = await startDevServer((_req, res) => res.end('ok')); const host = await startHost({ allowedPorts: [devPort], auth: clientAuth }); diff --git a/packages/web/server/lib/git/DOCUMENTATION.md b/packages/web/server/lib/git/DOCUMENTATION.md index 211ab57e..f328dbb3 100644 --- a/packages/web/server/lib/git/DOCUMENTATION.md +++ b/packages/web/server/lib/git/DOCUMENTATION.md @@ -39,6 +39,7 @@ The following functions are exported and used by the web server: ### Branch Operations - `getBranches(directory)`: Get list of local and remote branches (filtered to active remote branches). +- `getUnpushedBranchCounts(directory, branchNames)`: Count commits ahead of each locally known upstream for up to five supplied local branches. This reads local refs only and omits branches without an upstream. - `createBranch(directory, branchName, options)`: Create and checkout a new branch. - `checkoutBranch(directory, branchName)`: Checkout an existing branch. A remote-tracking name (`origin/main`, or the `remotes/`-prefixed form) resolves to the local branch of that name, created with `--track` when it does not exist yet, because the branch selector offers remote branches as places to work rather than commits to inspect — a literal checkout of the remote ref would detach HEAD. A local branch whose own name looks like a remote ref wins over that resolution, and anything unresolvable is checked out as requested. The returned `branch` is the branch that was actually checked out, which callers should report instead of the requested name. - `deleteBranch(directory, branch, options)`: Delete a branch (supports force flag). diff --git a/packages/web/server/lib/git/routes.js b/packages/web/server/lib/git/routes.js index 0fe38cdf..b04c48d9 100644 --- a/packages/web/server/lib/git/routes.js +++ b/packages/web/server/lib/git/routes.js @@ -873,6 +873,22 @@ export function registerGitRoutes(app) { } }); + app.post('/api/git/branch-push-status', async (req, res) => { + const { getUnpushedBranchCounts } = await getGitLibraries(); + try { + const directory = req.query.directory; + const branches = req.body?.branches; + if (!directory) return res.status(400).json({ error: 'directory parameter is required' }); + if (!Array.isArray(branches) || branches.some((branch) => typeof branch !== 'string')) { + return res.status(400).json({ error: 'branches must be an array of branch names' }); + } + res.json(await getUnpushedBranchCounts(directory, branches)); + } catch (error) { + console.error('Failed to get branch push status:', error); + res.status(500).json({ error: error.message || 'Failed to get branch push status' }); + } + }); + app.post('/api/git/branches', async (req, res) => { const { createBranch } = await getGitLibraries(); try { diff --git a/packages/web/server/lib/git/service.js b/packages/web/server/lib/git/service.js index c103d3fa..76daf5af 100644 --- a/packages/web/server/lib/git/service.js +++ b/packages/web/server/lib/git/service.js @@ -3687,6 +3687,35 @@ export async function getBranches(directory) { } } +/** + * Counts locally unpushed commits for a small caller-supplied set of local + * branches. This deliberately reads only local refs: the branch picker calls + * it when opened, never polls, and never fetches a remote behind the user's + * back. Unknown, remote, and upstream-less branches are omitted. + */ +export async function getUnpushedBranchCounts(directory, branchNames) { + const { git } = await createRepositoryGitContext(directory); + const requested = [...new Set(Array.isArray(branchNames) ? branchNames : [])] + .filter((name) => typeof name === 'string' && name.length > 0) + .slice(0, 5); + if (requested.length === 0) return { counts: {} }; + + const local = new Set((await git.branchLocal()).all); + const counts = {}; + await Promise.all(requested.map(async (branch) => { + if (!local.has(branch)) return; + const upstream = await git.raw(['rev-parse', '--abbrev-ref', '--symbolic-full-name', `${branch}@{upstream}`]) + .then((value) => value.trim()) + .catch(() => ''); + if (!upstream) return; + const count = await git.raw(['rev-list', '--count', `${upstream}..${branch}`]) + .then((value) => Number.parseInt(value.trim(), 10)) + .catch(() => 0); + if (Number.isFinite(count) && count > 0) counts[branch] = count; + })); + return { counts }; +} + async function getRemoteDefaultBranches(git) { let defaults = {}; diff --git a/packages/web/server/lib/git/service.test.js b/packages/web/server/lib/git/service.test.js index 9aa84944..61112684 100644 --- a/packages/web/server/lib/git/service.test.js +++ b/packages/web/server/lib/git/service.test.js @@ -12,6 +12,7 @@ import { createWorktree, getWorktreeBootstrapStatus, getBranches, + getUnpushedBranchCounts, getRangeDiff, getStatus, getWorktrees, @@ -1498,6 +1499,21 @@ describe.runIf(canRunGit())('getBranches', () => { }); }); +describe.runIf(canRunGit())('getUnpushedBranchCounts', () => { + it('counts only commits ahead of a locally known upstream', async () => { + const { repository } = createRepositoryWithRemote(); + runGit(repository, ['branch', '--set-upstream-to=origin/react', 'next']); + fs.writeFileSync(path.join(repository, 'ahead.txt'), 'ahead\n'); + runGit(repository, ['add', 'ahead.txt']); + runGit(repository, ['commit', '-m', 'ahead']); + runGit(repository, ['checkout', '-b', 'no-upstream']); + + await expect(getUnpushedBranchCounts(repository, ['next', 'no-upstream', 'remotes/origin/react'])).resolves.toEqual({ + counts: { next: 1 }, + }); + }); +}); + describe.runIf(canRunGit())('getRangeDiff', () => { it('resolves a base that exists only on a remote other than origin', async () => { const { repository } = createRepositoryWithRemote({ remoteName: 'upstream', defaultBranch: 'react' }); diff --git a/packages/web/server/lib/quota/DOCUMENTATION.md b/packages/web/server/lib/quota/DOCUMENTATION.md index 1897336b..6cc0b45a 100644 --- a/packages/web/server/lib/quota/DOCUMENTATION.md +++ b/packages/web/server/lib/quota/DOCUMENTATION.md @@ -23,6 +23,7 @@ These provider IDs are currently dispatchable via `fetchQuotaForProvider(provide | `cursor` | Cursor | `providers/cursor.js` | Environment/token files, OpenChamber-managed credentials, or explicit one-time Cursor import | | `crof` | CrofAI | `providers/crof.js` | `crof` (API key under `key` or `token`) | | `deepseek` | DeepSeek | `providers/deepseek.js` | `deepseek` (API key under `key` or `token`) | +| `exe-dev` | exe.dev | `providers/exe-dev.js` | Usage API token stored under `~/.config/openchamber/quota/` | | `google` | Google | `providers/google/index.js` | `google`, `google.oauth`, Antigravity accounts file | | `github-copilot` | GitHub Copilot | `providers/copilot.js` | `github-copilot`, `copilot` | | `github-copilot-addon` | GitHub Copilot Add-on | `providers/copilot.js` | `github-copilot`, `copilot` | @@ -51,7 +52,7 @@ All providers should return results via shared helpers to preserve API shape: Provider modules must export `providerId`, `providerName`, `aliases`, `isConfigured(auth?)`, and `fetchQuota()`. `fetchQuota()` should return a quota result with `usage.windows` keyed by window name (for example `5h`, `7d`, `daily`) and optional provider-specific `usage.models` data. -Ollama Cloud and Cursor credentials are explicitly managed through Settings. OpenCode Go usage uses `GET https://opencode.ai/zen/go/v1/usage` with the `opencode-go` API key from OpenCode `auth.json` as a bearer token. The server validates managed credentials before atomic `0600` writes and never returns secrets through its API. OpenChamber never scans browser cookie stores or automatically reads Cursor storage; Cursor import is an explicit one-time user action and never modifies Cursor's database. +exe.dev, Ollama Cloud, and Cursor credentials are explicitly managed through Settings. exe.dev usage uses a separately generated HTTPS API token restricted to `billing credits usage` and aggregates every `exe-*` model provider into one monthly credit window. Generate the token with `ssh exe.dev "ssh-key generate-api-key --label=openchamber --exp=30d --cmds='billing credits usage'"`. OpenCode Go usage uses `GET https://opencode.ai/zen/go/v1/usage` with the `opencode-go` API key from OpenCode `auth.json` as a bearer token and the stable `x-opencode-session: openchamber-usage` workload id. The server validates managed credentials before atomic `0600` writes and never returns secrets through its API. OpenChamber never scans browser cookie stores or automatically reads Cursor storage; Cursor import is an explicit one-time user action and never modifies Cursor's database. Command Code usage resolves account scope through `GET /alpha/whoami`, then reads server-backed credit balances and five-hour/weekly limits from `GET /alpha/billing/credits?orgId=...`. Personal accounts return `org: null` and use `/alpha/billing/credits` without an `orgId`; organization accounts include their organization id. Web/Electron and VS Code read the standard `command-code` OpenCode auth entry (including OAuth `access`) or `COMMAND_CODE_API_KEY`; credentials remain in the owning runtime and are never returned to shared UI. diff --git a/packages/web/server/lib/quota/credentials/providers.js b/packages/web/server/lib/quota/credentials/providers.js index 9fc0b4ae..80cddfb8 100644 --- a/packages/web/server/lib/quota/credentials/providers.js +++ b/packages/web/server/lib/quota/credentials/providers.js @@ -3,6 +3,10 @@ import { deleteQuotaCredential, readQuotaCredential, writeQuotaCredential } from const clean = (value) => typeof value === 'string' && !/[\r\n]/.test(value) ? value.trim() : ''; export const normalizers = { + 'exe-dev': (value) => { + const usageToken = clean(value?.usageToken); + return usageToken ? { usageToken } : null; + }, 'ollama-cloud': (value) => { const cookie = clean(value?.cookie); return cookie ? { cookie } : null; diff --git a/packages/web/server/lib/quota/credentials/store.js b/packages/web/server/lib/quota/credentials/store.js index a83b2008..3027d8d4 100644 --- a/packages/web/server/lib/quota/credentials/store.js +++ b/packages/web/server/lib/quota/credentials/store.js @@ -2,7 +2,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -const MANAGED_QUOTA_PROVIDERS = new Set(['ollama-cloud', 'cursor']); +const MANAGED_QUOTA_PROVIDERS = new Set(['exe-dev', 'ollama-cloud', 'cursor']); const credentialsDirectory = () => path.join( process.env.OPENCHAMBER_DATA_DIR diff --git a/packages/web/server/lib/quota/credentials/store.test.js b/packages/web/server/lib/quota/credentials/store.test.js index 6ad09d25..68bf6726 100644 --- a/packages/web/server/lib/quota/credentials/store.test.js +++ b/packages/web/server/lib/quota/credentials/store.test.js @@ -10,12 +10,12 @@ process.env.OPENCHAMBER_DATA_DIR = temporaryDirectory; describe('quota credential store', () => { it('uses owner-only permissions and rejects arbitrary provider paths', () => { - writeQuotaCredential('ollama-cloud', { cookie: 'secret' }); + writeQuotaCredential('exe-dev', { usageToken: 'secret' }); expect(fs.statSync(path.join(temporaryDirectory, 'quota')).mode & 0o777).toBe(0o700); - expect(fs.statSync(path.join(temporaryDirectory, 'quota', 'ollama-cloud.json')).mode & 0o777).toBe(0o600); - expect(readQuotaCredential('ollama-cloud', (value) => value)).toEqual({ cookie: 'secret' }); + expect(fs.statSync(path.join(temporaryDirectory, 'quota', 'exe-dev.json')).mode & 0o777).toBe(0o600); + expect(readQuotaCredential('exe-dev', (value) => value)).toEqual({ usageToken: 'secret' }); expect(() => writeQuotaCredential('../escape', {})).toThrow('Unsupported credential provider'); - deleteQuotaCredential('ollama-cloud'); + deleteQuotaCredential('exe-dev'); }); it('removes the obsolete OpenCode Go credential without parsing it', () => { diff --git a/packages/web/server/lib/quota/providers/exe-dev.js b/packages/web/server/lib/quota/providers/exe-dev.js new file mode 100644 index 00000000..f3f9155b --- /dev/null +++ b/packages/web/server/lib/quota/providers/exe-dev.js @@ -0,0 +1,75 @@ +import { readManagedCredential } from '../credentials/providers.js'; +import { asObject, buildResult, formatMoney, toNumber, toTimestamp, toUsageWindow } from '../utils/index.js'; + +export const providerId = 'exe-dev'; +export const providerName = 'exe.dev'; +export const aliases = ['exe-dev']; +const EXEC_URL = 'https://exe.dev/exec'; +const USAGE_COMMAND = 'billing credits usage --group=day --json'; + +export const parseExeDevUsage = (payload) => { + const data = asObject(payload); + if (!data) return null; + + const totalCost = toNumber(data.total_cost_usd); + const monthlyAllowance = toNumber(data.monthly_allowance_usd); + const resetAt = toTimestamp(data.period_end); + if (totalCost === null || monthlyAllowance === null || monthlyAllowance < 0 || resetAt === null) return null; + + const usedPercent = monthlyAllowance > 0 + ? Math.min(100, Math.max(0, (totalCost / monthlyAllowance) * 100)) + : null; + const spent = formatMoney(totalCost); + const allowance = formatMoney(monthlyAllowance); + if (spent === null || allowance === null) return null; + + return { + monthly: toUsageWindow({ + usedPercent, + windowSeconds: null, + resetAt, + valueLabel: `$${spent} / $${allowance}`, + }), + }; +}; + +export const fetchExeDevUsage = async (credential, fetchImpl = fetch) => { + const response = await fetchImpl(EXEC_URL, { + method: 'POST', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${credential.usageToken}`, + 'Content-Type': 'text/plain', + 'User-Agent': 'OpenChamber quota provider', + }, + body: USAGE_COMMAND, + signal: AbortSignal.timeout(15_000), + }); + if (response.status === 401 || response.status === 403) throw new Error('exe.dev authentication failed'); + if (!response.ok) throw new Error(`exe.dev usage API returned HTTP ${response.status}`); + const windows = parseExeDevUsage(await response.json().catch(() => null)); + if (!windows) throw new Error('exe.dev usage data could not be parsed'); + return windows; +}; + +export const isConfigured = () => Boolean(readManagedCredential(providerId)); + +export const fetchQuota = async () => { + const credential = readManagedCredential(providerId); + if (!credential) { + return buildResult({ providerId, providerName, ok: false, configured: false, error: 'Not configured' }); + } + + try { + const windows = await fetchExeDevUsage(credential); + return buildResult({ providerId, providerName, ok: true, configured: true, usage: { windows } }); + } catch (error) { + return buildResult({ + providerId, + providerName, + ok: false, + configured: true, + error: error instanceof Error ? error.message : 'Request failed', + }); + } +}; diff --git a/packages/web/server/lib/quota/providers/exe-dev.test.js b/packages/web/server/lib/quota/providers/exe-dev.test.js new file mode 100644 index 00000000..5b2a5d92 --- /dev/null +++ b/packages/web/server/lib/quota/providers/exe-dev.test.js @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'bun:test'; +import { fetchExeDevUsage, parseExeDevUsage } from './exe-dev.js'; + +const payload = { + allowance_spend_usd: 0.11, + extra_spend_usd: 0, + group: 'day', + month: '2026-09', + monthly_allowance_usd: 20, + period_end: '2026-10-01T00:00:00Z', + period_start: '2026-09-01T00:00:00Z', + total_cost_usd: 0.11, + total_requests: 17, +}; + +describe('exe.dev quota provider', () => { + it('parses monthly credit usage', () => { + const windows = parseExeDevUsage(payload); + expect(windows?.monthly.usedPercent).toBeCloseTo(0.55); + expect(windows?.monthly.valueLabel).toBe('$0.11 / $20.00'); + expect(windows?.monthly.resetAt).toBe(Date.parse('2026-10-01T00:00:00Z')); + }); + + it('sends the scoped billing command without exposing the token elsewhere', async () => { + const requests = []; + const windows = await fetchExeDevUsage({ usageToken: 'test-token' }, async (url, init) => { + requests.push({ url, init }); + return Response.json(payload); + }); + expect(windows.monthly.valueLabel).toBe('$0.11 / $20.00'); + expect(requests).toHaveLength(1); + expect(requests[0].url).toBe('https://exe.dev/exec'); + expect(requests[0].init.method).toBe('POST'); + expect(requests[0].init.body).toBe('billing credits usage --group=day --json'); + expect(requests[0].init.headers.Authorization).toBe('Bearer test-token'); + }); + + it('rejects malformed successful responses', async () => { + await expect(fetchExeDevUsage({ usageToken: 'test-token' }, async () => Response.json({}))) + .rejects.toThrow('could not be parsed'); + }); + + it('reports authentication failure without including the token', async () => { + await expect(fetchExeDevUsage({ usageToken: 'test-token' }, async () => new Response('', { status: 401 }))) + .rejects.toThrow('exe.dev authentication failed'); + }); +}); diff --git a/packages/web/server/lib/quota/providers/index.js b/packages/web/server/lib/quota/providers/index.js index 3ae4cc99..b6d7be3d 100644 --- a/packages/web/server/lib/quota/providers/index.js +++ b/packages/web/server/lib/quota/providers/index.js @@ -13,6 +13,7 @@ import * as copilot from './copilot.js'; import * as crof from './crof.js'; import * as cursor from './cursor.js'; import * as deepseek from './deepseek.js'; +import * as exeDev from './exe-dev.js'; import * as google from './google/index.js'; import * as kimi from './kimi.js'; import * as nanogpt from './nanogpt.js'; @@ -59,6 +60,12 @@ const registry = { isConfigured: deepseek.isConfigured, fetchQuota: deepseek.fetchQuota }, + 'exe-dev': { + providerId: exeDev.providerId, + providerName: exeDev.providerName, + isConfigured: exeDev.isConfigured, + fetchQuota: exeDev.fetchQuota + }, google: { providerId: google.providerId, providerName: google.providerName, diff --git a/packages/web/server/lib/quota/providers/opencode-go.js b/packages/web/server/lib/quota/providers/opencode-go.js index 2642cf26..b6b97370 100644 --- a/packages/web/server/lib/quota/providers/opencode-go.js +++ b/packages/web/server/lib/quota/providers/opencode-go.js @@ -37,6 +37,7 @@ export const fetchOpenCodeGoUsage = async (apiKey, fetchImpl = fetch) => { headers: { Accept: 'application/json', Authorization: `Bearer ${apiKey}`, + 'x-opencode-session': 'openchamber-usage', 'User-Agent': 'OpenChamber quota provider', }, signal: AbortSignal.timeout(15_000), diff --git a/packages/web/server/lib/quota/providers/opencode-go.test.js b/packages/web/server/lib/quota/providers/opencode-go.test.js index 1bae1eb0..505cbee5 100644 --- a/packages/web/server/lib/quota/providers/opencode-go.test.js +++ b/packages/web/server/lib/quota/providers/opencode-go.test.js @@ -43,7 +43,11 @@ describe('OpenCode Go quota provider', () => { return new Response(JSON.stringify({ usage: { rolling: { percent: 25, resetsAt: '2026-08-12T12:00:00.000Z' } } })); }); expect(request.url).toBe('https://opencode.ai/zen/go/v1/usage'); - expect(request.options.headers).toMatchObject({ Accept: 'application/json', Authorization: 'Bearer secret' }); + expect(request.options.headers).toMatchObject({ + Accept: 'application/json', + Authorization: 'Bearer secret', + 'x-opencode-session': 'openchamber-usage', + }); expect(request.options.headers.Cookie).toBeUndefined(); expect(usage['5h'].usedPercent).toBe(25); }); diff --git a/packages/web/server/lib/quota/routes.js b/packages/web/server/lib/quota/routes.js index 570fb253..4e4f9f05 100644 --- a/packages/web/server/lib/quota/routes.js +++ b/packages/web/server/lib/quota/routes.js @@ -2,8 +2,10 @@ import express from 'express'; import { deleteManagedCredential, getManagedCredentialStatus, normalizers, readManagedCredential, writeManagedCredential } from './credentials/providers.js'; import { fetchOllamaCloudUsage } from './providers/ollama-cloud.js'; import { importCursorCredential, validateCursorCredential } from './providers/cursor.js'; +import { fetchExeDevUsage } from './providers/exe-dev.js'; const validators = { + 'exe-dev': fetchExeDevUsage, 'ollama-cloud': fetchOllamaCloudUsage, cursor: validateCursorCredential, }; diff --git a/packages/web/server/lib/relay/DOCUMENTATION.md b/packages/web/server/lib/relay/DOCUMENTATION.md index d6df917c..2ce5f53a 100644 --- a/packages/web/server/lib/relay/DOCUMENTATION.md +++ b/packages/web/server/lib/relay/DOCUMENTATION.md @@ -41,7 +41,7 @@ Relay is not a separate link format: it is one transport candidate inside the un Everything a client normally sends to the single OpenChamber origin: - **HTTP** — REST endpoints and proxied OpenCode SDK calls under `/api/*`, plus `/auth/*` and `/health`. - **SSE** — long-lived streamed responses (the event stream and notifications). These are just HTTP responses whose body streams; the tunnel needs no special SSE handling. -- **WebSocket** — the endpoints that use a real socket (the global event stream on platforms that support WS, terminal I/O, dictation). +- **WebSocket** — the endpoints that use a real socket (the global event stream on platforms that support WS, terminal I/O, dictation, and desktop dev-server previews). The host dispatcher restricts tunneled traffic to explicit path allowlists (one for HTTP, one for WS). diff --git a/packages/web/server/lib/relay/tunnel-host.js b/packages/web/server/lib/relay/tunnel-host.js index b052b385..91c31b68 100644 --- a/packages/web/server/lib/relay/tunnel-host.js +++ b/packages/web/server/lib/relay/tunnel-host.js @@ -33,7 +33,9 @@ const ALLOWED_WS_PATHS = new Set([ '/api/event/ws', '/api/terminal/ws', '/api/dictation/ws', + '/api/dev-tunnel', ]); +export const isAllowedRelayWebSocketPath = (pathname) => ALLOWED_WS_PATHS.has(pathname); // Hop-by-hop headers stripped from tunneled requests; `host` is set by fetch // to the loopback origin. content-length is dropped too because the body is @@ -425,7 +427,7 @@ export const createTunnelHost = ({ connectionId, getLocalPort, sendFrame, getBuf void sendAbort(streamId, error?.message ?? 'malformed ws open'); return; } - if (!ALLOWED_WS_PATHS.has(open.path)) { + if (!isAllowedRelayWebSocketPath(open.path)) { void sendAbort(streamId, 'Path is not allowed through the relay'); return; } diff --git a/packages/web/server/lib/relay/tunnel-host.test.js b/packages/web/server/lib/relay/tunnel-host.test.js index 382e2c55..8ef521bd 100644 --- a/packages/web/server/lib/relay/tunnel-host.test.js +++ b/packages/web/server/lib/relay/tunnel-host.test.js @@ -1,7 +1,7 @@ import { describe, test, expect } from 'bun:test'; import http from 'node:http'; -import { createTunnelHost } from './tunnel-host.js'; +import { createTunnelHost, isAllowedRelayWebSocketPath } from './tunnel-host.js'; import { decodeTunnelFrame, encodeTunnelFrame, encodeJsonPayload, TunnelFrameType } from './tunnel-codec.js'; const startLoopback = () => @@ -145,3 +145,11 @@ describe('tunnel-host HTTP body forwarding', () => { await loopback.stop(); }); }); + +describe('relay host WebSocket allowlist', () => { + test('allows only the exact dev-server tunnel path', () => { + expect(isAllowedRelayWebSocketPath('/api/dev-tunnel')).toBe(true); + expect(isAllowedRelayWebSocketPath('/api/dev-tunnel/')).toBe(false); + expect(isAllowedRelayWebSocketPath('/api/database/ws')).toBe(false); + }); +}); diff --git a/packages/web/server/lib/security/request-security.js b/packages/web/server/lib/security/request-security.js index f1285dff..37ab64f5 100644 --- a/packages/web/server/lib/security/request-security.js +++ b/packages/web/server/lib/security/request-security.js @@ -67,6 +67,7 @@ export const createRequestSecurityRuntime = (deps) => { const getRequestOriginCandidates = async (req) => { const origins = new Set(); + const hosts = new Set(); const forwardedProto = typeof req.headers['x-forwarded-proto'] === 'string' ? req.headers['x-forwarded-proto'].split(',')[0].trim().toLowerCase() : ''; @@ -78,6 +79,7 @@ export const createRequestSecurityRuntime = (deps) => { const host = forwardedHost || (typeof req.headers.host === 'string' ? req.headers.host.trim() : ''); if (host) { + hosts.add(host.toLowerCase()); origins.add(`${protocol}://${host}`); const [hostname, port] = host.split(':'); const normalizedHost = typeof hostname === 'string' ? hostname.toLowerCase() : ''; @@ -98,10 +100,10 @@ export const createRequestSecurityRuntime = (deps) => { } catch { } - return origins; + return { origins, hosts }; }; - const isRequestOriginAllowed = async (req) => { + const isRequestOriginAllowed = (req) => { const originHeader = typeof req.headers.origin === 'string' ? req.headers.origin.trim() : ''; if (!originHeader) { return false; @@ -111,15 +113,28 @@ export const createRequestSecurityRuntime = (deps) => { return true; } - let normalizedOrigin = ''; + let origin; try { - normalizedOrigin = new URL(originHeader).origin; + origin = new URL(originHeader); } catch { return false; } - const allowedOrigins = await getRequestOriginCandidates(req); - return allowedOrigins.has(normalizedOrigin); + const forwardedHostHeader = req.headers['x-forwarded-host']; + const forwardedHost = (Array.isArray(forwardedHostHeader) ? forwardedHostHeader[0] : forwardedHostHeader || '') + .split(',')[0].trim().toLowerCase(); + const hostHeader = req.headers.host; + const host = forwardedHost || (Array.isArray(hostHeader) ? hostHeader[0] : hostHeader || '').trim().toLowerCase(); + if (host && host === origin.host.toLowerCase()) return true; + + // TLS commonly ends at a cloud edge before an HTTP hop to OpenChamber. + // In that setup the browser's Origin is https while a generic reverse + // proxy reports the upstream request as http. The external host remains + // authoritative, so compare it directly instead of requiring the proxy to + // preserve the browser-facing protocol. + return getRequestOriginCandidates(req).then((candidates) => ( + candidates.origins.has(origin.origin) || candidates.hosts.has(origin.host.toLowerCase()) + )); }; return { diff --git a/packages/web/server/lib/security/request-security.test.js b/packages/web/server/lib/security/request-security.test.js index e6bbae3d..c948bf36 100644 --- a/packages/web/server/lib/security/request-security.test.js +++ b/packages/web/server/lib/security/request-security.test.js @@ -9,41 +9,75 @@ describe('request security runtime', () => { test('allows packaged client origins for remote client transports', async () => { const runtime = createRuntime(); - await expect(runtime.isRequestOriginAllowed({ + expect(await runtime.isRequestOriginAllowed({ headers: { origin: 'openchamber-ui://app', host: '192.168.1.130:1202', }, socket: {}, - })).resolves.toBe(true); + })).toBe(true); - await expect(runtime.isRequestOriginAllowed({ + expect(await runtime.isRequestOriginAllowed({ headers: { origin: 'capacitor://localhost', host: '192.168.1.130:1202', }, socket: {}, - })).resolves.toBe(true); + })).toBe(true); // Android Capacitor WebView (androidScheme 'https') reports this origin. - await expect(runtime.isRequestOriginAllowed({ + expect(await runtime.isRequestOriginAllowed({ headers: { origin: 'https://localhost', host: '192.168.1.130:1202', }, socket: {}, - })).resolves.toBe(true); + })).toBe(true); }); test('rejects unknown origins', async () => { const runtime = createRuntime(); - await expect(runtime.isRequestOriginAllowed({ + expect(await runtime.isRequestOriginAllowed({ headers: { origin: 'https://evil.example.com', host: '192.168.1.130:1202', }, socket: {}, - })).resolves.toBe(false); + })).toBe(false); + }); + + test('allows the external host when TLS terminates before an HTTP proxy hop', async () => { + const runtime = createRuntime(); + + expect(await runtime.isRequestOriginAllowed({ + headers: { + origin: 'https://devchamber.example.com', + host: 'devchamber.example.com', + 'x-forwarded-proto': 'http', + }, + socket: {}, + })).toBe(true); + }); + + test('uses the forwarded external host without trusting a different origin', async () => { + const runtime = createRuntime(); + const request = { + headers: { + host: '127.0.0.1:3000', + 'x-forwarded-host': 'devchamber.example.com', + 'x-forwarded-proto': 'http', + }, + socket: {}, + }; + + expect(await runtime.isRequestOriginAllowed({ + ...request, + headers: { ...request.headers, origin: 'https://devchamber.example.com' }, + })).toBe(true); + expect(await runtime.isRequestOriginAllowed({ + ...request, + headers: { ...request.headers, origin: 'https://evil.example.com' }, + })).toBe(false); }); }); diff --git a/packages/web/server/lib/session-assist/runtime.js b/packages/web/server/lib/session-assist/runtime.js index ab4b1f45..c479011d 100644 --- a/packages/web/server/lib/session-assist/runtime.js +++ b/packages/web/server/lib/session-assist/runtime.js @@ -257,6 +257,7 @@ export const createSessionAssistRuntime = ({ prompt: `The latest exchange in the conversation:\n\n${transcript}\n\nWrite ${requestedFields} in the SAME language as this sample from the conversation: "${languageSample}"`, system: buildAssistSystemPrompt(targets), directory, + sessionID: sessionId, preferredProviderID: typeof lastAssistantInfo.providerID === 'string' ? lastAssistantInfo.providerID : undefined, preferredModelID: typeof lastAssistantInfo.modelID === 'string' ? lastAssistantInfo.modelID : undefined, }); diff --git a/packages/web/server/lib/session-goal/create.js b/packages/web/server/lib/session-goal/create.js index 2255f9dc..73fad9c3 100644 --- a/packages/web/server/lib/session-goal/create.js +++ b/packages/web/server/lib/session-goal/create.js @@ -14,7 +14,7 @@ export const buildGoalIntroText = (tokenBudget) => { + '\n'; }; -const fitObjective = async ({ objective, directory, providerID, modelID, warn }) => { +const fitObjective = async ({ objective, directory, sessionID, providerID, modelID, warn }) => { if (objective.length <= GOAL_OBJECTIVE_CHAR_LIMIT) return objective; let distilled = null; @@ -32,6 +32,7 @@ const fitObjective = async ({ objective, directory, providerID, modelID, warn }) 'Write in the same language as the task text.', ].join('\n'), directory, + sessionID, preferredProviderID: providerID, preferredModelID: modelID, }); @@ -66,6 +67,7 @@ export const createSessionGoal = async ({ const objectiveText = await fitObjective({ objective: String(objective ?? '').trim(), directory, + sessionID, providerID, modelID, warn, diff --git a/packages/web/server/lib/session-goal/runtime.js b/packages/web/server/lib/session-goal/runtime.js index f97af98d..89c21256 100644 --- a/packages/web/server/lib/session-goal/runtime.js +++ b/packages/web/server/lib/session-goal/runtime.js @@ -378,6 +378,7 @@ export const createSessionGoalRuntime = ({ prompt: `The goal objective:\n\n\n${goal.objective}\n\n\nThe agent's latest turn:\n\n${assistantText}\n\nReturn the verdict JSON. Write the note in the SAME language as this sample from the objective: "${goal.objective.slice(0, 200).replace(/\s+/g, ' ').trim()}"`, system: buildAuditSystemPrompt(), directory, + sessionID: typeof lastAssistantInfo?.sessionID === 'string' ? lastAssistantInfo.sessionID : undefined, preferredProviderID: typeof lastAssistantInfo?.providerID === 'string' ? lastAssistantInfo.providerID : undefined, preferredModelID: typeof lastAssistantInfo?.modelID === 'string' ? lastAssistantInfo.modelID : undefined, }); diff --git a/packages/web/server/lib/small-model/DOCUMENTATION.md b/packages/web/server/lib/small-model/DOCUMENTATION.md index baee6280..cb4a2b07 100644 --- a/packages/web/server/lib/small-model/DOCUMENTATION.md +++ b/packages/web/server/lib/small-model/DOCUMENTATION.md @@ -91,6 +91,10 @@ other runtime API. a blocker instead of a raw 500 message. - `call.js` — wire formats and per-provider auth, replicating OpenCode's plugin auth loaders: + - OpenCode-hosted providers receive `x-opencode-session`. Session-backed + features reuse the real OpenCode session id, walkthrough retries reuse the + walkthrough cache key, and standalone one-shot actions receive a fresh + opaque id for that generation. - **GitHub Copilot**: fetches the requested model's authenticated `/models` metadata from `https://api.githubcopilot.com` (or `copilot-api.`) and honors its advertised endpoint, preferring diff --git a/packages/web/server/lib/small-model/call.js b/packages/web/server/lib/small-model/call.js index 9a7d4e95..7703aa74 100644 --- a/packages/web/server/lib/small-model/call.js +++ b/packages/web/server/lib/small-model/call.js @@ -1,6 +1,7 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; +import { randomUUID } from 'node:crypto'; import { readAuthFile, writeAuthFile } from '../opencode/auth.js'; import { readConfig, readConfigLayers, isPlainObject } from '../opencode/shared.js'; import { getCatalogProvider } from './catalog.js'; @@ -645,7 +646,7 @@ export async function resolveProviderLogin({ auth, workingDirectory, providerID || null; } -export async function callSmallModel({ auth, catalog, workingDirectory, providerID, modelID, prompt, system, maxOutputTokens, responseSchema, timeoutMs, signal }) { +export async function callSmallModel({ auth, catalog, workingDirectory, sessionID, providerID, modelID, prompt, system, maxOutputTokens, responseSchema, timeoutMs, signal }) { const tokens = Number(maxOutputTokens) > 0 ? Number(maxOutputTokens) : DEFAULT_MAX_OUTPUT_TOKENS; const providerConfig = readProviderConfig(workingDirectory, providerID); const runtimeProvider = await getRuntimeProvider(providerID); @@ -795,7 +796,12 @@ export async function callSmallModel({ auth, catalog, workingDirectory, provider baseURL, // Configured headers last: a gateway that authenticates on its own header // must be able to override the bearer default rather than sit beside it. - headers: mergeHeadersCaseInsensitive({ Authorization: `Bearer ${apiKey}` }, providerConfig?.headers), + headers: mergeHeadersCaseInsensitive( + mergeHeadersCaseInsensitive({ Authorization: `Bearer ${apiKey}` }, providerConfig?.headers), + providerID.startsWith('opencode') + ? { 'x-opencode-session': typeof sessionID === 'string' && sessionID.trim() ? sessionID.trim() : randomUUID() } + : null, + ), modelID, prompt, system, diff --git a/packages/web/server/lib/small-model/call.test.js b/packages/web/server/lib/small-model/call.test.js index 7c2a5315..31456f0d 100644 --- a/packages/web/server/lib/small-model/call.test.js +++ b/packages/web/server/lib/small-model/call.test.js @@ -483,6 +483,29 @@ describe('callSmallModel — custom provider config', () => { }); describe('catalog-based base URL (no config override)', () => { + it('identifies OpenCode Go requests with the owning conversation', async () => { + readConfig.mockReturnValue({}); + fetchMock.mockResolvedValue(ok('ok')); + + await callSmallModel({ + auth: { 'opencode-go': { type: 'api', key: 'go-key' } }, + catalog: { + 'opencode-go': { + id: 'opencode-go', + api: 'https://opencode.ai/zen/go/v1', + models: { utility: { id: 'utility' } }, + }, + }, + workingDirectory: '/proj', + sessionID: 'ses_conversation', + providerID: 'opencode-go', + modelID: 'utility', + prompt: 'hi', + }); + + expect(lastCall(fetchMock).init.headers['x-opencode-session']).toBe('ses_conversation'); + }); + it('uses the catalog api field when no config baseURL is set', async () => { readConfig.mockReturnValue({}); fetchMock.mockResolvedValue(ok('ok')); diff --git a/packages/web/server/lib/small-model/index.js b/packages/web/server/lib/small-model/index.js index 667eb526..e59c0fbf 100644 --- a/packages/web/server/lib/small-model/index.js +++ b/packages/web/server/lib/small-model/index.js @@ -102,7 +102,7 @@ const readConfiguredSmallModel = (workingDirectory) => { * Generates text with the user's small model, resolved and authenticated * entirely server-side from the OpenCode config and auth store. */ -export async function generateSmallModelText({ prompt, system, maxOutputTokens, model, directory, preferredProviderID, preferredModelID, restrictToPreferredProvider = false, responseSchema, timeoutMs, signal, onOverflow = 'truncate' }) { +export async function generateSmallModelText({ prompt, system, maxOutputTokens, model, directory, sessionID, preferredProviderID, preferredModelID, restrictToPreferredProvider = false, responseSchema, timeoutMs, signal, onOverflow = 'truncate' }) { if (typeof prompt !== 'string' || !prompt.trim()) { throw Object.assign(new Error('prompt is required'), { statusCode: 400 }); } @@ -169,6 +169,7 @@ export async function generateSmallModelText({ prompt, system, maxOutputTokens, auth, catalog, workingDirectory: directory, + sessionID, providerID: resolved.providerID, modelID: resolved.modelID, prompt: clamped.prompt, diff --git a/packages/web/server/lib/small-model/routes.js b/packages/web/server/lib/small-model/routes.js index 461e0992..9431ff2f 100644 --- a/packages/web/server/lib/small-model/routes.js +++ b/packages/web/server/lib/small-model/routes.js @@ -21,13 +21,14 @@ export function registerSmallModelRoutes(app, { getSmallModelService }) { app.post('/api/small-model/generate', async (req, res) => { try { const { generateSmallModelText } = await getSmallModelService(); - const { prompt, system, maxOutputTokens, model, directory, preferredProviderID, preferredModelID, restrictToPreferredProvider } = req.body || {}; + const { prompt, system, maxOutputTokens, model, directory, sessionID, preferredProviderID, preferredModelID, restrictToPreferredProvider } = req.body || {}; const result = await generateSmallModelText({ prompt, system, maxOutputTokens, model, directory, + sessionID, preferredProviderID, preferredModelID, restrictToPreferredProvider: restrictToPreferredProvider === true, diff --git a/packages/web/server/lib/terminal/runtime.js b/packages/web/server/lib/terminal/runtime.js index d018b6fe..f8c7da28 100644 --- a/packages/web/server/lib/terminal/runtime.js +++ b/packages/web/server/lib/terminal/runtime.js @@ -276,16 +276,39 @@ export function createTerminalRuntime({ const upgradeHandler = (req, socket, head) => { if (parseRequestPathname(req.url) !== TERMINAL_WS_PATH) return; - void (async () => { + const accept = () => { + if (!wsServer) { rejectWebSocketUpgrade(socket, 500, 'Terminal WebSocket unavailable'); return; } try { - if (uiAuthController?.enabled) { - if (!await uiAuthController.ensureSessionToken(req, null)) { rejectWebSocketUpgrade(socket, 401, 'UI authentication required'); return; } - if (!await isRequestOriginAllowed(req)) { rejectWebSocketUpgrade(socket, 403, 'Invalid origin'); return; } - } - if (!wsServer) { rejectWebSocketUpgrade(socket, 500, 'Terminal WebSocket unavailable'); return; } wsServer.handleUpgrade(req, socket, head, (ws) => wsServer.emit('connection', ws, req)); } catch { rejectWebSocketUpgrade(socket, 500, 'Upgrade failed'); } - })(); + }; + const checkOrigin = () => { + try { + const result = isRequestOriginAllowed(req); + if (!(result instanceof Promise)) { + if (result) accept(); + else rejectWebSocketUpgrade(socket, 403, 'Invalid origin'); + return; + } + void result.then((allowed) => { + if (allowed) accept(); + else rejectWebSocketUpgrade(socket, 403, 'Invalid origin'); + }).catch(() => rejectWebSocketUpgrade(socket, 500, 'Upgrade failed')); + } catch { rejectWebSocketUpgrade(socket, 500, 'Upgrade failed'); } + }; + if (!uiAuthController?.enabled) { accept(); return; } + try { + const result = uiAuthController.ensureSessionToken(req, null); + if (!(result instanceof Promise)) { + if (result) checkOrigin(); + else rejectWebSocketUpgrade(socket, 401, 'UI authentication required'); + return; + } + void result.then((sessionToken) => { + if (sessionToken) checkOrigin(); + else rejectWebSocketUpgrade(socket, 401, 'UI authentication required'); + }).catch(() => rejectWebSocketUpgrade(socket, 500, 'Upgrade failed')); + } catch { rejectWebSocketUpgrade(socket, 500, 'Upgrade failed'); } }; server.on('upgrade', upgradeHandler); diff --git a/packages/web/server/lib/ui-auth/ui-auth.js b/packages/web/server/lib/ui-auth/ui-auth.js index ea3e0b99..71232d6e 100644 --- a/packages/web/server/lib/ui-auth/ui-auth.js +++ b/packages/web/server/lib/ui-auth/ui-auth.js @@ -310,6 +310,7 @@ const isUrlAuthWebSocketPath = (pathname) => { || pathname === '/api/openchamber/realtime-proxy/ws' || pathname === '/api/terminal/ws' || pathname === '/api/dictation/ws' + || pathname === '/api/dev-tunnel' || pathname.startsWith('/api/preview/proxy/'); }; @@ -984,7 +985,9 @@ export const createUiAuth = ({ handlePasskeyList, handlePasskeyRevoke, handleResetAuth, - ensureSessionToken: async (req, _res) => { + ensureSessionToken: (req, _res) => { + const urlAuth = authenticateUrlAuthToken(req); + if (urlAuth) return clientSessionToken(urlAuth); return resolveAuthenticatedSessionToken(req); }, dispose, diff --git a/packages/web/server/lib/ui-auth/ui-auth.test.js b/packages/web/server/lib/ui-auth/ui-auth.test.js index 185b0af1..f0d5f019 100644 --- a/packages/web/server/lib/ui-auth/ui-auth.test.js +++ b/packages/web/server/lib/ui-auth/ui-auth.test.js @@ -221,6 +221,22 @@ describe('ui auth client credential seam', () => { }; expect(await auth.ensureSessionToken(dictationWsReq, null)).toBe('client:device-1'); + const devTunnelWsReq = { + method: 'GET', + path: '/api/dev-tunnel', + url: `/api/dev-tunnel?port=4322&oc_url_token=${encodeURIComponent(urlToken)}`, + headers: { upgrade: 'websocket' }, + }; + expect(await auth.ensureSessionToken(devTunnelWsReq, null)).toBe('client:device-1'); + + const devTunnelSubpathWsReq = { + method: 'GET', + path: '/api/dev-tunnel/private', + url: `/api/dev-tunnel/private?port=4322&oc_url_token=${encodeURIComponent(urlToken)}`, + headers: { upgrade: 'websocket' }, + }; + expect(await auth.ensureSessionToken(devTunnelSubpathWsReq, null)).toBe(null); + const dictationHttpReq = { method: 'GET', path: '/api/dictation/ws', diff --git a/packages/web/server/lib/walkthrough/index.js b/packages/web/server/lib/walkthrough/index.js index 601ca0dc..38ac5a56 100644 --- a/packages/web/server/lib/walkthrough/index.js +++ b/packages/web/server/lib/walkthrough/index.js @@ -474,6 +474,7 @@ async function runGeneration({ directory, source, repoRoot, key, force, explicit prompt: options.prompt, system: options.system, directory, + sessionID: `openchamber-walkthrough-${cacheKey}`, model: `${model.providerID}/${model.modelID}`, responseSchema: options.responseSchema, onOverflow: 'error', diff --git a/packages/web/src/api/git.ts b/packages/web/src/api/git.ts index d6bc9fd9..387a0871 100644 --- a/packages/web/src/api/git.ts +++ b/packages/web/src/api/git.ts @@ -23,6 +23,7 @@ export const createWebGitAPI = (): GitAPI => ({ revertGitHunk: gitApiHttp.revertGitHunk, isLinkedWorktree: gitApiHttp.isLinkedWorktree, getGitBranches: gitApiHttp.getGitBranches, + getGitUnpushedBranchCounts: gitApiHttp.getGitUnpushedBranchCounts, deleteGitBranch: gitApiHttp.deleteGitBranch as GitAPI['deleteGitBranch'], deleteRemoteBranch: gitApiHttp.deleteRemoteBranch as GitAPI['deleteRemoteBranch'], removeRemote: gitApiHttp.removeRemote as GitAPI['removeRemote'], diff --git a/packages/web/src/runtimeConfig.ts b/packages/web/src/runtimeConfig.ts index 4d45efa8..d6a968f3 100644 --- a/packages/web/src/runtimeConfig.ts +++ b/packages/web/src/runtimeConfig.ts @@ -1,6 +1,7 @@ import { getRuntimeExtraHeadersSync, refreshLocalRuntimeUrlAuthToken, refreshRuntimeUrlAuthToken, setRuntimeBearerToken, setRuntimeExtraHeaders } from '@openchamber/ui/lib/runtime-auth'; import { installRuntimeFetchBridge } from '@openchamber/ui/lib/runtime-fetch'; import { initializeRuntimeEndpoint, switchRuntimeEndpoint } from '@openchamber/ui/lib/runtime-switch'; +import { warmDesktopHostStatuses } from '@openchamber/ui/lib/desktopHostStatus'; import { restoreDesktopRelayRuntime } from '@openchamber/ui/lib/desktopRelayRestore'; import { getInjectedBootOutcome } from '@openchamber/ui/lib/desktopBoot'; import { configureRuntimeUrlResolver } from '@openchamber/ui/lib/runtime-url'; @@ -8,6 +9,9 @@ import type { EmbeddedSessionRuntimeBootstrap } from '@openchamber/ui/components import { opencodeClient } from '@openchamber/ui/lib/opencode/client'; import { createWebAPIs } from './api'; +// The switcher's statuses are warmed after boot settles, not during it. +const HOST_STATUS_WARMUP_DELAY_MS = 3_000; + const sameOrigin = (left: string, right: string): boolean => { if (!left || !right) return false; try { @@ -93,5 +97,12 @@ export const createConfiguredWebAPIs = (bootstrap?: EmbeddedSessionRuntimeBootst // subscribes to runtime-change events, so bind the SDK explicitly. opencodeClient.reconnectToRuntimeBaseUrl(); }); + // Learn every instance's reachability in the background, so the switcher opens + // on real values instead of probing for the first time under the user's + // cursor. After the endpoint is settled and past the app's own bootstrap: + // this is unprompted work and the machine's network is busiest at launch. + void desktopRelayRestoreReady.then(() => { + window.setTimeout(() => { void warmDesktopHostStatuses().catch(() => {}); }, HOST_STATUS_WARMUP_DELAY_MS); + }); return createWebAPIs({ urls }); }; diff --git a/scripts/oc-dev.config.example.json b/scripts/oc-dev.config.example.json index 971c7f96..ffdc4425 100644 --- a/scripts/oc-dev.config.example.json +++ b/scripts/oc-dev.config.example.json @@ -14,6 +14,7 @@ "host": "example-host", "port": 3002, "dir": "testing-dev", + "lan": false, "apiOnly": true }, { @@ -22,6 +23,7 @@ "host": "example-host", "port": 3002, "dir": "testing-dev", + "lan": true, "apiOnly": false } ] diff --git a/scripts/oc-dev.mjs b/scripts/oc-dev.mjs index 43e6c990..a4346d4a 100755 --- a/scripts/oc-dev.mjs +++ b/scripts/oc-dev.mjs @@ -389,6 +389,7 @@ async function deployRemoteWeb(options, config) { const dir = remote.dir; const port = String(remote.port); const apiOnly = remote.apiOnly ? 'true' : 'false'; + const bindHost = remote.lan === false ? '127.0.0.1' : '0.0.0.0'; const packageBase = path.basename(packageFile); if (!host || !dir || !port) throw new Error(`Remote deployment ${remote.id} must define host, dir, and port.`); @@ -400,9 +401,9 @@ async function deployRemoteWeb(options, config) { run('scp', ['-q', packageFile, `${host}:~/${dir}/releases/${packageBase}`]); }); step('Resetting remote install state', () => run('ssh', [host, `cd ~/${dir} && rm -f package.json package-lock.json pnpm-lock.yaml bun.lockb && rm -rf node_modules`])); - step('Preparing remote package manifest', () => run('ssh', [host, `cd ~/${dir} && ${REMOTE_RUNTIME_ENV}; npm init -y >/dev/null 2>&1`])); - step('Installing remote package', () => run('ssh', [host, `cd ~/${dir} && ${REMOTE_RUNTIME_ENV}; npm install ./releases/${packageBase}`])); - step(`Starting remote instance on ${host}:${port}`, () => run('ssh', [host, `set -e; cd ~/${dir}; ${REMOTE_RUNTIME_ENV}; PASSWORD_VALUE=$(grep '^export OPENCHAMBER_UI_PASSWORD=' ~/.bashrc 2>/dev/null | sed -E 's/.*=["“]?([^"”]+)["”]?/\\1/' || true); if [ -n "$PASSWORD_VALUE" ]; then export OPENCHAMBER_UI_PASSWORD="$PASSWORD_VALUE"; fi; if [ ${quote(apiOnly)} = 'true' ]; then export OPENCHAMBER_API_ONLY=true; fi; OPENCHAMBER_HOST=0.0.0.0 node ./node_modules/@openchamber/web/bin/cli.js --port ${quote(port)} >/dev/null 2>&1; sleep 0.5; if command -v lsof >/dev/null 2>&1; then lsof -ti :${quote(port)} >/dev/null 2>&1 || exit 1; fi`])); + step('Preparing remote package manifest', () => run('ssh', [host, `set -e; cd ~/${dir}; ${REMOTE_RUNTIME_ENV}; if command -v bun >/dev/null 2>&1; then bun init -y; else npm init -y; fi`])); + step('Installing remote package', () => run('ssh', [host, `set -e; cd ~/${dir}; ${REMOTE_RUNTIME_ENV}; if command -v bun >/dev/null 2>&1; then bun add ./releases/${packageBase}; else npm install ./releases/${packageBase}; fi`])); + step(`Starting remote instance on ${host}:${port}`, () => run('ssh', [host, `set -e; cd ~/${dir}; ${REMOTE_RUNTIME_ENV}; PASSWORD_VALUE=$(grep '^export OPENCHAMBER_UI_PASSWORD=' ~/.bashrc 2>/dev/null | sed -E 's/.*=["“]?([^"”]+)["”]?/\\1/' || true); if [ -n "$PASSWORD_VALUE" ]; then export OPENCHAMBER_UI_PASSWORD="$PASSWORD_VALUE"; fi; if [ ${quote(apiOnly)} = 'true' ]; then export OPENCHAMBER_API_ONLY=true; fi; if command -v bun >/dev/null 2>&1; then OPENCHAMBER_HOST=${quote(bindHost)} bun ./node_modules/@openchamber/web/bin/cli.js --port ${quote(port)} >/dev/null 2>&1; else OPENCHAMBER_HOST=${quote(bindHost)} node ./node_modules/@openchamber/web/bin/cli.js --port ${quote(port)} >/dev/null 2>&1; fi; sleep 0.5; if command -v lsof >/dev/null 2>&1; then lsof -ti :${quote(port)} >/dev/null 2>&1 || exit 1; fi`])); log.success(`Remote deployment ready: ${host}:${port}`); }