+
+
+
+ {displayLabel}
- )}
- {isActive && (
- {t('desktopHostSwitcher.header.current')}
- )}
-
- {statusIcon(statusKind)}
-
- {isSsh ? t(sshPhaseLabelKey(sshStatus?.phase)) : t(statusLabelKey(status?.status ?? null))}
- {!isSsh && status?.status === 'ok' && typeof status.latencyMs === 'number'
+ {isSsh && (
+
+ SSH
+
+ )}
+ {isActive && (
+ {t('desktopHostSwitcher.header.current')}
+ )}
+
+
+ {statusIcon(statusKind)}
+
+ {isSsh ? t(sshPhaseLabelKey(sshStatus?.phase)) : t(statusLabelKey(statusKind))}
+ {!isSsh && statusKind === 'ok' && typeof status?.latencyMs === 'number'
? t('desktopHostSwitcher.status.ping', { ms: Math.max(0, Math.round(status.latencyMs)) })
: ''}
@@ -841,52 +894,6 @@ export function DesktopHostSwitcherDialog({
- {!isLocal && !isSsh && (
-
-
-
-
-
- {
- e.stopPropagation();
- beginEdit(host);
- }}
- disabled={isSaving}
- >
-
- {t('desktopHostSwitcher.actions.edit')}
-
- {
- e.stopPropagation();
- void deleteHost(host.id);
- }}
- className="text-destructive focus:text-destructive"
- disabled={isSaving}
- >
-
- {t('desktopHostSwitcher.actions.delete')}
-
-
-
- )}
-
- {isLocal && (
-
- )}
-
{isSsh && !isLocal && (
(sshStatus?.phase === 'idle' || !sshStatus?.phase) ? (
@@ -939,7 +946,7 @@ export function DesktopHostSwitcherDialog({
type="button"
className={cn(
'h-8 w-8 rounded-md inline-flex items-center justify-center hover:bg-interactive-hover transition-colors',
- statusKind === 'unreachable' || statusKind === 'wrong-service'
+ isBlockedDisplayStatus(statusKind)
? 'text-muted-foreground/30 cursor-not-allowed'
: 'text-muted-foreground/60 hover:text-foreground',
)}
@@ -947,14 +954,14 @@ export function DesktopHostSwitcherDialog({
e.stopPropagation();
openInNewWindow(host);
}}
- disabled={statusKind === 'unreachable' || statusKind === 'wrong-service'}
+ disabled={isBlockedDisplayStatus(statusKind)}
aria-label={t('desktopHostSwitcher.actions.openInNewWindowAria')}
>
- {(statusKind === 'unreachable' || statusKind === 'wrong-service')
+ {isBlockedDisplayStatus(statusKind)
? t('desktopHostSwitcher.state.instanceUnreachable')
: t('desktopHostSwitcher.actions.openInNewWindow')}
@@ -1000,68 +1007,16 @@ export function DesktopHostSwitcherDialog({
)}
- {embedded && !isAddFormOpen ? (
-
-
-
- ) : (
-
-
-
{t('desktopHostSwitcher.add.title')}
-
- {embedded && (
-
- )}
-
-
-
-
- setNewLabel(e.target.value)}
- onKeyDown={stopDropdownTypeahead}
- placeholder={t('desktopHostSwitcher.field.labelOptionalPlaceholder')}
- disabled={!tauriAvailable || isSaving}
- />
- setNewUrl(e.target.value)}
- onKeyDown={stopDropdownTypeahead}
- placeholder={t('desktopHostSwitcher.field.urlPlaceholder')}
- disabled={!tauriAvailable || isSaving}
- />
-
-
- )}
+
+
+
{error && (
{error}
@@ -1102,7 +1057,7 @@ export function DesktopHostSwitcherDialog({
type="button"
size="sm"
variant="outline"
- onClick={switchToLocal}
+ onClick={() => void switchToLocal()}
>
{t('desktopHostSwitcher.actions.switchToLocal')}
@@ -1152,6 +1107,7 @@ export function DesktopHostSwitcherButton({ headerIconButtonClass }: DesktopHost
const [open, setOpen] = React.useState(false);
const [label, setLabel] = React.useState('Local');
const [status, setStatus] = React.useState(null);
+ const [localOrigin, setLocalOrigin] = React.useState(() => getLocalOrigin());
const attemptedDefaultSshConnectRef = React.useRef(false);
const [startupSshModal, setStartupSshModal] = React.useState<{
open: boolean;
@@ -1190,7 +1146,11 @@ export function DesktopHostSwitcherButton({ headerIconButtonClass }: DesktopHost
if (!localUrl) {
throw new Error('Connected but missing forwarded URL');
}
- window.location.assign(toNavigationUrl(localUrl));
+ if (isElectronShell()) {
+ switchRuntimeEndpoint({ apiBaseUrl: localUrl, clientToken: null, runtimeKey: `ssh:${hostId}` });
+ } else {
+ window.location.assign(toNavigationUrl(localUrl));
+ }
return true;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
@@ -1214,12 +1174,24 @@ export function DesktopHostSwitcherButton({ headerIconButtonClass }: DesktopHost
connecting: false,
});
+ let nextLocalOrigin = localOrigin;
await desktopHostsGet()
- .then((cfg) => desktopHostsSet({ hosts: cfg.hosts, defaultHostId: LOCAL_HOST_ID }))
+ .then((cfg) => {
+ if (cfg.localOrigin) {
+ nextLocalOrigin = cfg.localOrigin;
+ setLocalOrigin(cfg.localOrigin);
+ }
+ return desktopHostsSet({ hosts: cfg.hosts, defaultHostId: LOCAL_HOST_ID });
+ })
.catch(() => undefined);
- window.location.assign(toNavigationUrl(getLocalOrigin()));
- }, []);
+ if (isElectronShell()) {
+ const clientToken = await getLocalClientToken();
+ switchRuntimeEndpoint({ apiBaseUrl: nextLocalOrigin, clientToken: clientToken || null, runtimeKey: 'local' });
+ } else {
+ window.location.assign(toNavigationUrl(nextLocalOrigin));
+ }
+ }, [localOrigin]);
const retryStartupSsh = React.useCallback(() => {
const hostId = startupSshModal.hostId;
@@ -1236,11 +1208,16 @@ export function DesktopHostSwitcherButton({ headerIconButtonClass }: DesktopHost
const run = async () => {
try {
const cfg = await desktopHostsGet();
- const local = buildLocalHost();
+ const nextLocalOrigin = cfg.localOrigin || localOrigin;
+ if (cfg.localOrigin && cfg.localOrigin !== localOrigin) {
+ setLocalOrigin(cfg.localOrigin);
+ }
+ const local = buildLocalHost(nextLocalOrigin);
const all = [local, ...(cfg.hosts || [])];
const current = resolveCurrentHost(all);
if (
+ !isElectronShell() &&
!attemptedDefaultSshConnectRef.current &&
current.id === LOCAL_HOST_ID &&
cfg.defaultHostId &&
@@ -1290,13 +1267,16 @@ export function DesktopHostSwitcherButton({ headerIconButtonClass }: DesktopHost
cancelled = true;
window.clearInterval(interval);
};
- }, [connectDefaultSshInstance, t]);
+ }, [connectDefaultSshInstance, localOrigin, t]);
if (!isDesktopShell()) {
return null;
}
- const isCurrentlyLocal = locationMatchesHost(window.location.href, getLocalOrigin());
+ const runtimeApiBaseUrl = getRuntimeApiBaseUrl();
+ const isCurrentlyLocal = runtimeApiBaseUrl
+ ? locationMatchesHost(runtimeApiBaseUrl, localOrigin)
+ : locationMatchesHost(window.location.href, localOrigin);
const fallbackLabel = typeof window !== 'undefined' && window.location.hostname
? window.location.hostname
diff --git a/packages/ui/src/components/layout/ContextPanel.tsx b/packages/ui/src/components/layout/ContextPanel.tsx
index d3f178b2..c6de7376 100644
--- a/packages/ui/src/components/layout/ContextPanel.tsx
+++ b/packages/ui/src/components/layout/ContextPanel.tsx
@@ -19,6 +19,10 @@ import { useSessionUIStore } from '@/sync/session-ui-store';
import { useInputStore } from '@/sync/input-store';
import { ContextPanelContent } from './ContextSidebarTab';
import { toast } from '@/components/ui';
+import { runtimeFetch } from '@/lib/runtime-fetch';
+import { refreshRuntimeUrlAuthToken } from '@/lib/runtime-auth';
+import { getRuntimeUrlResolver } from '@/lib/runtime-url';
+import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
import { Icon } from "@/components/icon/Icon";
import { OpenChamberLogo } from "@/components/ui/OpenChamberLogo";
import { invokeDesktopCommand } from '@/lib/desktopNative';
@@ -436,15 +440,42 @@ type PreviewPaneProps = {
type PreviewProxyState =
| { status: 'idle' }
| { status: 'loading' }
- | { status: 'ready'; proxyBasePath: string; expiresAt: number }
+ | { status: 'ready'; proxyBasePath: string; previewToken?: string; expiresAt: number }
| { status: 'error'; message: string };
+const getPreviewProxyOrigin = (proxySrc: string): string => {
+ if (typeof window === 'undefined') return '';
+ try {
+ return new URL(proxySrc || window.location.href, window.location.href).origin;
+ } catch {
+ return window.location.origin;
+ }
+};
+
+const postPreviewBridgeMessage = (frameWindow: Window, proxySrc: string, payload: Record): void => {
+ const targetOrigin = getPreviewProxyOrigin(proxySrc);
+ frameWindow.postMessage(payload, targetOrigin);
+};
+
+const stripPreviewTokenFromUrl = (value: string): string => {
+ if (!value) return value;
+ try {
+ const parsed = new URL(value);
+ parsed.searchParams.delete('oc_preview_token');
+ parsed.searchParams.delete('oc_client_token');
+ parsed.searchParams.delete('oc_url_token');
+ return parsed.toString();
+ } catch {
+ return value;
+ }
+};
const PreviewPane: React.FC = ({ rawUrl, onNavigate }) => {
const { t } = useI18n();
const { currentTheme } = useThemeSystem();
const [reloadNonce, bumpReload] = React.useReducer((x: number) => x + 1, 0);
const [proxyRegistrationNonce, bumpProxyRegistration] = React.useReducer((x: number) => x + 1, 0);
const [proxyState, setProxyState] = React.useState({ status: 'idle' });
+ const [urlAuthReadyKey, setUrlAuthReadyKey] = React.useState('');
const iframeRef = React.useRef(null);
const nextConsoleEventIdRef = React.useRef(1);
const [bridgeReady, setBridgeReady] = React.useState(false);
@@ -480,6 +511,7 @@ const PreviewPane: React.FC = ({ rawUrl, onNavigate }) => {
: null;
const targetKey = normalizedUrl ? normalizedUrl.toString() : '';
+ const proxyCacheKey = targetKey ? `${getRuntimeApiBaseUrl() || 'same-origin'}|${targetKey}` : '';
const previewColorScheme = currentTheme.metadata.variant;
React.useEffect(() => {
@@ -488,18 +520,21 @@ const PreviewPane: React.FC = ({ rawUrl, onNavigate }) => {
return;
}
- const cached = getCachedProxyTarget(targetKey);
- if (cached) {
- setProxyState({ status: 'ready', proxyBasePath: cached.proxyBasePath, expiresAt: cached.expiresAt });
+ const cached = getCachedProxyTarget(proxyCacheKey);
+ if (cached?.previewToken) {
+ setProxyState({ status: 'ready', proxyBasePath: cached.proxyBasePath, previewToken: cached.previewToken, expiresAt: cached.expiresAt });
return;
}
+ if (cached) {
+ previewProxyTargetCache.delete(proxyCacheKey);
+ }
let cancelled = false;
setProxyState({ status: 'loading' });
void (async () => {
try {
- const response = await fetch('/api/preview/targets', {
+ const response = await runtimeFetch('/api/preview/targets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
@@ -507,7 +542,7 @@ const PreviewPane: React.FC = ({ rawUrl, onNavigate }) => {
});
if (!response.ok) {
- previewProxyTargetCache.delete(targetKey);
+ previewProxyTargetCache.delete(proxyCacheKey);
const errorBody = await response.json().catch(() => ({}));
const message = typeof errorBody?.error === 'string'
? errorBody.error
@@ -518,23 +553,24 @@ const PreviewPane: React.FC = ({ rawUrl, onNavigate }) => {
return;
}
- const body = await response.json() as { proxyBasePath?: unknown; expiresAt?: unknown };
+ const body = await response.json() as { proxyBasePath?: unknown; previewToken?: unknown; expiresAt?: unknown };
const proxyBasePath = typeof body.proxyBasePath === 'string' ? body.proxyBasePath : '';
+ const previewToken = typeof body.previewToken === 'string' ? body.previewToken : '';
const expiresAt = typeof body.expiresAt === 'number' ? body.expiresAt : 0;
- if (!proxyBasePath) {
- previewProxyTargetCache.delete(targetKey);
+ if (!proxyBasePath || !previewToken) {
+ previewProxyTargetCache.delete(proxyCacheKey);
if (!cancelled) {
setProxyState({ status: 'error', message: t('contextPanel.preview.proxyError') });
}
return;
}
- previewProxyTargetCache.set(targetKey, { proxyBasePath, expiresAt });
+ previewProxyTargetCache.set(proxyCacheKey, { proxyBasePath, previewToken, expiresAt });
if (!cancelled) {
- setProxyState({ status: 'ready', proxyBasePath, expiresAt });
+ setProxyState({ status: 'ready', proxyBasePath, previewToken, expiresAt });
}
} catch (error) {
- previewProxyTargetCache.delete(targetKey);
+ previewProxyTargetCache.delete(proxyCacheKey);
if (!cancelled) {
const message = error instanceof Error ? error.message : String(error);
setProxyState({ status: 'error', message });
@@ -545,27 +581,51 @@ const PreviewPane: React.FC = ({ rawUrl, onNavigate }) => {
return () => {
cancelled = true;
};
- }, [isLoopback, proxyRegistrationNonce, t, targetKey]);
+ }, [isLoopback, proxyCacheKey, proxyRegistrationNonce, t, targetKey]);
const directSrc = normalizedUrl
&& (normalizedUrl.protocol === 'http:' || normalizedUrl.protocol === 'https:')
? normalizedUrl.toString()
: '';
- const proxySrc = isLoopback && proxyState.status === 'ready' && normalizedUrl
+ const proxyUrlAuthKey = isLoopback && proxyState.status === 'ready'
+ ? `${proxyState.proxyBasePath}|${proxyState.previewToken || ''}|${reloadNonce}`
+ : '';
+
+ React.useEffect(() => {
+ if (!proxyUrlAuthKey) {
+ setUrlAuthReadyKey('');
+ return;
+ }
+
+ let cancelled = false;
+ setUrlAuthReadyKey('');
+ void refreshRuntimeUrlAuthToken(getRuntimeApiBaseUrl())
+ .then((token) => {
+ if (!cancelled && token) setUrlAuthReadyKey(proxyUrlAuthKey);
+ })
+ .catch(() => {});
+
+ return () => {
+ cancelled = true;
+ };
+ }, [proxyUrlAuthKey]);
+
+ const proxySrc = isLoopback && proxyState.status === 'ready' && normalizedUrl && urlAuthReadyKey === proxyUrlAuthKey
? (() => {
const path = normalizedUrl.pathname || '/';
const searchParams = new URLSearchParams(normalizedUrl.search);
searchParams.set('ocPreview', String(reloadNonce));
+ searchParams.set('oc_preview_token', proxyState.previewToken || '');
const search = searchParams.toString();
const hash = normalizedUrl.hash || '';
- return `${proxyState.proxyBasePath}${path}${search ? `?${search}` : ''}${hash}`;
+ return getRuntimeUrlResolver().authenticatedAsset(`${proxyState.proxyBasePath}${path}${search ? `?${search}` : ''}${hash}`);
})()
: '';
const effectiveSrc = isLoopback ? proxySrc : directSrc;
- const headerSrc = effectiveSrc || directSrc;
- const showLoading = isLoopback && (proxyState.status === 'loading' || proxyState.status === 'idle');
+ const headerSrc = isLoopback ? stripPreviewTokenFromUrl(proxySrc) : directSrc;
+ const showLoading = isLoopback && (proxyState.status === 'loading' || proxyState.status === 'idle' || urlAuthReadyKey !== proxyUrlAuthKey);
const showError = isLoopback && proxyState.status === 'error';
const attachPreviewAnnotation = React.useCallback((target: PreviewElementMetadata) => {
@@ -630,26 +690,26 @@ const PreviewPane: React.FC = ({ rawUrl, onNavigate }) => {
if (!bridgeReady || !frameWindow) {
return;
}
- frameWindow.postMessage({
+ postPreviewBridgeMessage(frameWindow, proxySrc, {
source: 'openchamber-preview-parent',
version: 1,
type: 'set-inspect-mode',
enabled: inspectMode,
- }, window.location.origin);
- }, [bridgeReady, inspectMode]);
+ });
+ }, [bridgeReady, inspectMode, proxySrc]);
React.useEffect(() => {
const frameWindow = iframeRef.current?.contentWindow;
if (!bridgeReady || !frameWindow) {
return;
}
- frameWindow.postMessage({
+ postPreviewBridgeMessage(frameWindow, proxySrc, {
source: 'openchamber-preview-parent',
version: 1,
type: 'set-color-scheme',
scheme: previewColorScheme,
- }, window.location.origin);
- }, [bridgeReady, previewColorScheme]);
+ });
+ }, [bridgeReady, previewColorScheme, proxySrc]);
React.useEffect(() => {
if (!inspectMode || typeof window === 'undefined') return;
@@ -860,7 +920,7 @@ const PreviewPane: React.FC = ({ rawUrl, onNavigate }) => {
void (async () => {
const probe = async (): Promise => {
try {
- return await fetch(proxySrc, {
+ return await runtimeFetch(proxySrc, {
method: 'GET',
credentials: 'include',
cache: 'no-store',
@@ -882,7 +942,7 @@ const PreviewPane: React.FC = ({ rawUrl, onNavigate }) => {
}
if (response.status === 403 || response.status === 404) {
- previewProxyTargetCache.delete(targetKey);
+ previewProxyTargetCache.delete(proxyCacheKey);
setProxyState({ status: 'loading' });
bumpProxyRegistration();
return;
@@ -918,7 +978,7 @@ const PreviewPane: React.FC = ({ rawUrl, onNavigate }) => {
return () => {
cancelled = true;
};
- }, [proxySrc, reloadNonce, targetKey]);
+ }, [proxyCacheKey, proxySrc, reloadNonce]);
const showUpstreamStarting = isLoopback
&& proxyState.status === 'ready'
@@ -943,7 +1003,8 @@ const PreviewPane: React.FC = ({ rawUrl, onNavigate }) => {
try {
const location = frameWindow.location;
- if (location.origin !== window.location.origin) {
+ const proxyOrigin = getPreviewProxyOrigin(proxySrc);
+ if (location.origin !== proxyOrigin) {
return;
}
if (location.pathname.startsWith(proxyState.proxyBasePath)) {
@@ -955,7 +1016,7 @@ const PreviewPane: React.FC = ({ rawUrl, onNavigate }) => {
} catch {
// Cross-origin frames are expected for non-loopback/direct previews.
}
- }, [isLoopback, proxyState]);
+ }, [isLoopback, proxySrc, proxyState]);
return (
@@ -1195,6 +1256,7 @@ const IframeBrowserPane: React.FC
= ({ initialUrl, dire
const [isInspecting, setIsInspecting] = React.useState(false);
const [hoverTarget, setHoverTarget] = React.useState(null);
const [proxyState, setProxyState] = React.useState({ status: 'idle' });
+ const [urlAuthReadyKey, setUrlAuthReadyKey] = React.useState('');
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const newSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft?.open);
const addInlineCommentDraft = useInlineCommentDraftStore((state) => state.addDraft);
@@ -1264,10 +1326,13 @@ const IframeBrowserPane: React.FC = ({ initialUrl, dire
const proxyTargetKey = getBrowserProxyTargetKey(currentUrl);
const cached = getCachedProxyTarget(proxyTargetKey);
- if (cached) {
- setProxyState({ status: 'ready', proxyBasePath: cached.proxyBasePath, expiresAt: cached.expiresAt });
+ if (cached?.previewToken) {
+ setProxyState({ status: 'ready', proxyBasePath: cached.proxyBasePath, previewToken: cached.previewToken, expiresAt: cached.expiresAt });
return;
}
+ if (cached) {
+ previewProxyTargetCache.delete(proxyTargetKey);
+ }
let cancelled = false;
setProxyState({ status: 'loading' });
@@ -1275,7 +1340,7 @@ const IframeBrowserPane: React.FC = ({ initialUrl, dire
void (async () => {
try {
- const response = await fetch('/api/preview/targets', {
+ const response = await runtimeFetch('/api/preview/targets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
@@ -1293,19 +1358,20 @@ const IframeBrowserPane: React.FC = ({ initialUrl, dire
return;
}
- const body = await response.json() as { proxyBasePath?: unknown; expiresAt?: unknown };
+ const body = await response.json() as { proxyBasePath?: unknown; previewToken?: unknown; expiresAt?: unknown };
const proxyBasePath = typeof body.proxyBasePath === 'string' ? body.proxyBasePath : '';
+ const previewToken = typeof body.previewToken === 'string' ? body.previewToken : '';
const expiresAt = typeof body.expiresAt === 'number' ? body.expiresAt : 0;
- if (!proxyBasePath) {
+ if (!proxyBasePath || !previewToken) {
if (!cancelled) {
setProxyState({ status: 'error', message: t('contextPanel.preview.proxyError') });
}
return;
}
- previewProxyTargetCache.set(proxyTargetKey, { proxyBasePath, expiresAt });
+ previewProxyTargetCache.set(proxyTargetKey, { proxyBasePath, previewToken, expiresAt });
if (!cancelled) {
- setProxyState({ status: 'ready', proxyBasePath, expiresAt });
+ setProxyState({ status: 'ready', proxyBasePath, previewToken, expiresAt });
}
} catch (error) {
if (!cancelled) {
@@ -1320,16 +1386,44 @@ const IframeBrowserPane: React.FC = ({ initialUrl, dire
};
}, [currentUrl, t]);
+ const proxyUrlAuthKey = currentUrl && proxyState.status === 'ready'
+ ? `${proxyState.proxyBasePath}|${proxyState.previewToken || ''}|${reloadNonce}`
+ : '';
+
+ React.useEffect(() => {
+ if (!proxyUrlAuthKey) {
+ setUrlAuthReadyKey('');
+ return;
+ }
+
+ let cancelled = false;
+ setUrlAuthReadyKey('');
+ void refreshRuntimeUrlAuthToken(getRuntimeApiBaseUrl())
+ .then((token) => {
+ if (!cancelled && token) setUrlAuthReadyKey(proxyUrlAuthKey);
+ })
+ .catch(() => {});
+
+ return () => {
+ cancelled = true;
+ };
+ }, [proxyUrlAuthKey]);
+
const proxySrc = React.useMemo(() => {
+ if (urlAuthReadyKey !== proxyUrlAuthKey) return '';
if (!currentUrl || proxyState.status !== 'ready') return '';
try {
const parsed = new URL(currentUrl);
const path = parsed.pathname || '/';
- return `${proxyState.proxyBasePath}${path}${parsed.search}${parsed.hash}`;
+ const searchParams = new URLSearchParams(parsed.search);
+ searchParams.set('ocPreview', String(reloadNonce));
+ searchParams.set('oc_preview_token', proxyState.previewToken || '');
+ const search = searchParams.toString();
+ return getRuntimeUrlResolver().authenticatedAsset(`${proxyState.proxyBasePath}${path}${search ? `?${search}` : ''}${parsed.hash}`);
} catch {
return '';
}
- }, [currentUrl, proxyState]);
+ }, [currentUrl, proxyState, proxyUrlAuthKey, reloadNonce, urlAuthReadyKey]);
const iframeSrc = proxySrc || (proxyState.status === 'error' ? currentUrl : '');
diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx
index df1b3501..7903aa89 100644
--- a/packages/ui/src/components/layout/Header.tsx
+++ b/packages/ui/src/components/layout/Header.tsx
@@ -32,6 +32,7 @@ import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay';
+import { UpdateDialog } from '@/components/ui/UpdateDialog';
import { useDeviceInfo, useTabletStandalonePwaRuntime } from '@/lib/device';
import { cn, hasModifier } from '@/lib/utils';
import { McpDropdownContent } from '@/components/mcp/McpDropdown';
@@ -62,11 +63,14 @@ import { forceKillTerminal } from '@/lib/terminalApi';
import { useTerminalStore } from '@/stores/useTerminalStore';
import { ProjectActionsButton } from '@/components/layout/ProjectActionsButton';
import { SessionSwitcherDropdown } from '@/components/session/SessionSwitcherDropdown';
-import { canUseElectronDesktopIPC, invokeDesktop, isDesktopShell, isVSCodeRuntime, startDesktopWindowDrag } from '@/lib/desktop';
-import { desktopHostsGet, locationMatchesHost, redactSensitiveUrl } from '@/lib/desktopHosts';
+import { canUseElectronDesktopIPC, invokeDesktop, isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime, startDesktopWindowDrag, type UpdateInfo } from '@/lib/desktop';
+import { desktopHostsGet, getDesktopHostApiUrl, locationMatchesHost, redactSensitiveUrl } from '@/lib/desktopHosts';
import { resolveSessionDiffStats } from '@/components/session/sidebar/utils';
import { Icon } from "@/components/icon/Icon";
import { useI18n } from '@/lib/i18n';
+import { runtimeFetch } from '@/lib/runtime-fetch';
+import { getRuntimeBearerTokenSync } from '@/lib/runtime-auth';
+import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
import type { Session } from '@opencode-ai/sdk/v2/client';
import type { IconName } from "@/components/icon/icons";
@@ -323,6 +327,7 @@ type DesktopServicesMenuProps = {
isDesktopApp: boolean;
currentInstanceLabel: string;
compactCurrentInstanceLabel: string;
+ currentInstanceIsLocal: boolean;
isDesktopServicesOpen: boolean;
setIsDesktopServicesOpen: React.Dispatch>;
refreshCurrentInstanceLabel: () => Promise;
@@ -346,6 +351,10 @@ type DesktopServicesMenuProps = {
showDevShutdown: boolean;
isDevShutdownInFlight: boolean;
onDevShutdown: () => Promise;
+ remoteUpdateInfo: UpdateInfo | null;
+ remoteUpdateChecking: boolean;
+ remoteUpdateError: string | null;
+ onOpenRemoteUpdate: () => void;
showPredValues: boolean;
};
@@ -353,6 +362,7 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({
isDesktopApp,
currentInstanceLabel,
compactCurrentInstanceLabel,
+ currentInstanceIsLocal,
isDesktopServicesOpen,
setIsDesktopServicesOpen,
refreshCurrentInstanceLabel,
@@ -376,6 +386,10 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({
showDevShutdown,
isDevShutdownInFlight,
onDevShutdown,
+ remoteUpdateInfo,
+ remoteUpdateChecking,
+ remoteUpdateError,
+ onOpenRemoteUpdate,
showPredValues,
}: DesktopServicesMenuProps) {
const { t } = useI18n();
@@ -453,12 +467,39 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({
{isDesktopApp && desktopServicesTab === 'instance' ? (
- {}}
- onHostSwitched={() => setIsDesktopServicesOpen(false)}
- />
+
+ {!currentInstanceIsLocal ? (
+
+
+
+
{t('header.services.remoteUpdate.title')}
+
+ {remoteUpdateInfo?.available
+ ? t('header.services.remoteUpdate.available', { version: remoteUpdateInfo.version || '' })
+ : remoteUpdateChecking
+ ? t('header.services.remoteUpdate.checking')
+ : remoteUpdateError || t('header.services.remoteUpdate.upToDate')}
+
+
+ {remoteUpdateInfo?.available ? (
+
+ ) : null}
+
+
+ ) : null}
+
{}}
+ onHostSwitched={() => setIsDesktopServicesOpen(false)}
+ />
+
) : null}
{desktopServicesTab === 'mcp' ? (
@@ -889,6 +930,11 @@ export const Header: React.FC = ({
const [isDesktopServicesOpen, setIsDesktopServicesOpen] = React.useState(false);
const [isUsageRefreshSpinning, setIsUsageRefreshSpinning] = React.useState(false);
const [currentInstanceLabel, setCurrentInstanceLabel] = React.useState('Local');
+ const [currentInstanceIsLocal, setCurrentInstanceIsLocal] = React.useState(true);
+ const [remoteUpdateDialogOpen, setRemoteUpdateDialogOpen] = React.useState(false);
+ 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 [desktopServicesTab, setDesktopServicesTab] = React.useState<'instance' | 'usage' | 'mcp'>(
isDesktopApp ? 'instance' : 'usage'
@@ -912,17 +958,25 @@ export const Header: React.FC = ({
}
try {
- const cfg = await desktopHostsGet();
- const currentHref = window.location.href;
- const localOrigin = window.__OPENCHAMBER_LOCAL_ORIGIN__ || window.location.origin;
-
- if (locationMatchesHost(currentHref, localOrigin)) {
+ if (isDesktopLocalOriginActive()) {
setCurrentInstanceLabel('Local');
+ setCurrentInstanceIsLocal(true);
+ return;
+ }
+ setCurrentInstanceIsLocal(false);
+
+ const cfg = await desktopHostsGet();
+ const localOrigin = window.__OPENCHAMBER_LOCAL_ORIGIN__ || window.location.origin;
+ const runtimeApiBaseUrl = getRuntimeApiBaseUrl();
+
+ if (runtimeApiBaseUrl && locationMatchesHost(runtimeApiBaseUrl, localOrigin)) {
+ setCurrentInstanceLabel('Local');
+ setCurrentInstanceIsLocal(true);
return;
}
const match = cfg.hosts.find((host) => {
- return locationMatchesHost(currentHref, host.url);
+ return runtimeApiBaseUrl ? locationMatchesHost(runtimeApiBaseUrl, getDesktopHostApiUrl(host)) : false;
});
if (match?.label?.trim()) {
@@ -933,12 +987,98 @@ export const Header: React.FC = ({
setCurrentInstanceLabel('Instance');
} catch {
setCurrentInstanceLabel('Local');
+ setCurrentInstanceIsLocal(true);
}
}, [isDesktopApp]);
useEffect(() => {
void refreshCurrentInstanceLabel();
}, [refreshCurrentInstanceLabel]);
+
+ const checkRemoteInstanceUpdate = React.useCallback(async () => {
+ if (currentInstanceIsLocal) {
+ setRemoteUpdateInfo(null);
+ setRemoteUpdateError(null);
+ return;
+ }
+
+ setRemoteUpdateChecking(true);
+ setRemoteUpdateError(null);
+ try {
+ const params = new URLSearchParams({ appType: 'web', instanceMode: 'remote' });
+ const response = await runtimeFetch(`/api/openchamber/update-check?${params.toString()}`, {
+ method: 'GET',
+ headers: { Accept: 'application/json' },
+ });
+ if (!response.ok) {
+ throw new Error(`Server responded with ${response.status}`);
+ }
+ const data = await response.json();
+ setRemoteUpdateInfo({
+ available: data.available ?? false,
+ version: data.version,
+ currentVersion: data.currentVersion ?? 'unknown',
+ body: data.body,
+ nextSuggestedCheckInSec: typeof data.nextSuggestedCheckInSec === 'number' ? data.nextSuggestedCheckInSec : undefined,
+ packageManager: data.packageManager,
+ updateCommand: data.updateCommand,
+ });
+ } catch (error) {
+ setRemoteUpdateInfo(null);
+ setRemoteUpdateError(error instanceof Error ? error.message : t('header.services.remoteUpdate.error'));
+ } finally {
+ setRemoteUpdateChecking(false);
+ }
+ }, [currentInstanceIsLocal, t]);
+
+ React.useEffect(() => {
+ setRemoteUpdateInfo(null);
+ setRemoteUpdateError(null);
+ setRemoteUpdateDialogOpen(false);
+ }, [currentInstanceIsLocal, currentInstanceLabel]);
+
+ React.useEffect(() => {
+ if (!isDesktopApp || currentInstanceIsLocal) {
+ return;
+ }
+
+ const initialDelayMs = 3000;
+ const intervalMs = 60 * 60 * 1000;
+ let disposed = false;
+ let timer: number | null = null;
+
+ const schedule = (delayMs: number) => {
+ timer = window.setTimeout(() => {
+ if (disposed || (typeof document !== 'undefined' && document.visibilityState !== 'visible')) {
+ schedule(intervalMs);
+ return;
+ }
+ void checkRemoteInstanceUpdate().finally(() => {
+ if (!disposed) {
+ schedule(intervalMs);
+ }
+ });
+ }, delayMs);
+ };
+
+ schedule(initialDelayMs);
+
+ return () => {
+ disposed = true;
+ if (timer !== null) {
+ window.clearTimeout(timer);
+ }
+ };
+ }, [checkRemoteInstanceUpdate, currentInstanceIsLocal, currentInstanceLabel, isDesktopApp]);
+
+ const openRemoteInstanceUpdate = React.useCallback(() => {
+ if (remoteUpdateInfo?.available) {
+ setRemoteUpdateDialogOpen(true);
+ return;
+ }
+ void checkRemoteInstanceUpdate();
+ }, [checkRemoteInstanceUpdate, remoteUpdateInfo?.available]);
+
useQuotaAutoRefresh();
const selectedModels = useQuotaStore((state) => state.selectedModels);
const expandedFamilies = useQuotaStore((state) => state.expandedFamilies);
@@ -1300,7 +1440,7 @@ export const Header: React.FC = ({
const payload = runtimeApis.github
? await runtimeApis.github.authActivate(accountId)
: await (async () => {
- const response = await fetch('/api/github/auth/activate', {
+ const response = await runtimeFetch('/api/github/auth/activate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -1366,6 +1506,8 @@ export const Header: React.FC = ({
void invokeDesktop('desktop_open_draft_mini_chat_window', {
directory: normalize(openDirectory || activeProject?.path || ''),
projectId: activeProject?.id ?? null,
+ apiBaseUrl: getRuntimeApiBaseUrl(),
+ clientToken: getRuntimeBearerTokenSync(),
}).catch((error) => {
console.warn('[header] failed to open draft mini chat window', error);
});
@@ -1383,6 +1525,8 @@ export const Header: React.FC = ({
void invokeDesktop('desktop_open_session_mini_chat_window', {
sessionId: currentSessionId,
directory: normalize(openDirectory || activeProject?.path || ''),
+ apiBaseUrl: getRuntimeApiBaseUrl(),
+ clientToken: getRuntimeBearerTokenSync(),
}).catch((error) => {
console.warn('[header] failed to open session mini chat window', error);
});
@@ -1740,7 +1884,7 @@ export const Header: React.FC = ({
}
try {
- const devRes = await fetch('/api/system/dev-shutdown', {
+ const devRes = await runtimeFetch('/api/system/dev-shutdown', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ previewUrls }),
@@ -1748,7 +1892,7 @@ export const Header: React.FC = ({
if (devRes.ok) {
shutdownRequested = true;
} else {
- const shutdownRes = await fetch('/api/system/shutdown', { method: 'POST' });
+ const shutdownRes = await runtimeFetch('/api/system/shutdown', { method: 'POST' });
shutdownRequested = shutdownRes.ok;
}
} catch {
@@ -1929,6 +2073,7 @@ export const Header: React.FC = ({
isDesktopApp={isDesktopApp}
currentInstanceLabel={currentInstanceLabel}
compactCurrentInstanceLabel={compactCurrentInstanceLabel}
+ currentInstanceIsLocal={currentInstanceIsLocal}
isDesktopServicesOpen={isDesktopServicesOpen}
setIsDesktopServicesOpen={setIsDesktopServicesOpen}
refreshCurrentInstanceLabel={refreshCurrentInstanceLabel}
@@ -1953,6 +2098,10 @@ export const Header: React.FC = ({
showDevShutdown={showDevShutdown}
isDevShutdownInFlight={isDevShutdownInFlight}
onDevShutdown={handleDevShutdown}
+ remoteUpdateInfo={remoteUpdateInfo}
+ remoteUpdateChecking={remoteUpdateChecking}
+ remoteUpdateError={remoteUpdateError}
+ onOpenRemoteUpdate={openRemoteInstanceUpdate}
/>
= ({
);
return (
-
- {isMobile ? renderMobile() : renderDesktop()}
-
+ <>
+
+ {isMobile ? renderMobile() : renderDesktop()}
+
+ {}}
+ onRestart={() => {}}
+ runtimeType="web"
+ />
+ >
);
};
diff --git a/packages/ui/src/components/layout/MainLayout.tsx b/packages/ui/src/components/layout/MainLayout.tsx
index d6f74cec..b23425a5 100644
--- a/packages/ui/src/components/layout/MainLayout.tsx
+++ b/packages/ui/src/components/layout/MainLayout.tsx
@@ -24,13 +24,13 @@ import { cn } from '@/lib/utils';
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
import { ChatView } from '@/components/views/ChatView';
+import { DiffView } from '@/components/views/DiffView';
+import { FilesView } from '@/components/views/FilesView';
+import { GitView } from '@/components/views/GitView';
+import { PlanView } from '@/components/views/PlanView';
// Heavy views loaded on-demand to reduce initial bundle parse time.
-const PlanView = lazyWithChunkRecovery(() => import('@/components/views/PlanView').then(m => ({ default: m.PlanView })));
-const GitView = lazyWithChunkRecovery(() => import('@/components/views/GitView').then(m => ({ default: m.GitView })));
-const DiffView = lazyWithChunkRecovery(() => import('@/components/views/DiffView').then(m => ({ default: m.DiffView })));
const TerminalView = lazyWithChunkRecovery(() => import('@/components/views/TerminalView').then(m => ({ default: m.TerminalView })));
-const FilesView = lazyWithChunkRecovery(() => import('@/components/views/FilesView').then(m => ({ default: m.FilesView })));
const SettingsView = lazyWithChunkRecovery(() => import('@/components/views/SettingsView').then(m => ({ default: m.SettingsView })));
const SettingsWindow = lazyWithChunkRecovery(() => import('@/components/views/SettingsWindow').then(m => ({ default: m.SettingsWindow })));
const MultiRunWindow = lazyWithChunkRecovery(() => import('@/components/views/MultiRunWindow').then(m => ({ default: m.MultiRunWindow })));
diff --git a/packages/ui/src/components/layout/ProjectEditDialog.tsx b/packages/ui/src/components/layout/ProjectEditDialog.tsx
index 06ee364e..4fb3aff0 100644
--- a/packages/ui/src/components/layout/ProjectEditDialog.tsx
+++ b/packages/ui/src/components/layout/ProjectEditDialog.tsx
@@ -10,7 +10,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { toast } from '@/components/ui';
import { cn } from '@/lib/utils';
-import { PROJECT_ICONS, PROJECT_COLORS, PROJECT_COLOR_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
+import { PROJECT_ICONS, PROJECT_COLORS, PROJECT_COLOR_MAP, ProjectIconImage } from '@/lib/projectMeta';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { useI18n } from '@/lib/i18n';
@@ -146,19 +146,8 @@ export const ProjectEditDialog: React.FC = ({
const hasCustomIcon = currentIconImage?.source === 'custom';
const effectiveHasImageIcon = (hasStoredImageIcon && !pendingRemoveImageIcon) || hasPendingUploadImageIcon;
const hasRemovableImageIcon = effectiveHasImageIcon;
- const iconPreviewUrl = !previewImageFailed
- ? (hasPendingUploadImageIcon
- ? pendingUploadIconPreviewUrl
- : (hasStoredImageIcon && !pendingRemoveImageIcon
- ? getProjectIconImageUrl(
- { id: projectId, iconImage: currentIconImage ?? null },
- {
- themeVariant: currentTheme.metadata.variant,
- iconColor: currentTheme.colors.surface.foreground,
- },
- )
- : null))
- : null;
+ const showStoredImagePreview = hasStoredImageIcon && !pendingRemoveImageIcon;
+ const showImagePreview = !previewImageFailed && (hasPendingUploadImageIcon || showStoredImagePreview);
React.useEffect(() => {
setPreviewImageFailed(false);
@@ -352,7 +341,7 @@ export const ProjectEditDialog: React.FC = ({
);
})}
- {effectiveHasImageIcon && iconPreviewUrl && (
+ {effectiveHasImageIcon && showImagePreview && (
{t('projectEditDialog.field.preview')}
@@ -360,13 +349,25 @@ export const ProjectEditDialog: React.FC = ({
className="inline-flex h-4 w-4 items-center justify-center overflow-hidden rounded-[2px]"
style={iconBackground ? { backgroundColor: iconBackground } : undefined}
>
-
setPreviewImageFailed(true)}
- />
+ {hasPendingUploadImageIcon && pendingUploadIconPreviewUrl ? (
+
setPreviewImageFailed(true)}
+ />
+ ) : (
+ setPreviewImageFailed(true)}
+ />
+ )}
diff --git a/packages/ui/src/components/multirun/MultiRunFusionDialog.tsx b/packages/ui/src/components/multirun/MultiRunFusionDialog.tsx
index b0e98355..adf0e057 100644
--- a/packages/ui/src/components/multirun/MultiRunFusionDialog.tsx
+++ b/packages/ui/src/components/multirun/MultiRunFusionDialog.tsx
@@ -166,21 +166,20 @@ export function MultiRunFusionDialog({
useSessionUIStore.getState().setCurrentSession(fusionSession.id, directory);
onOpenChange(false);
- await opencodeClient.withDirectory(directory ?? opencodeClient.getDirectory(), () =>
- opencodeClient.sendMessage({
- id: fusionSession.id,
- providerID,
- modelID,
- variant: variant || undefined,
- agent: agent || undefined,
- text: visiblePrompt,
- additionalParts: [
- { text: instructionsPrompt, synthetic: true },
- ...usableSources.map((item, index) => ({ text: buildSourcePart(item.source, item.text, index), synthetic: true })),
- { text: '\n\n--- FUSION INPUTS END ---\nNow write the final fused answer.', synthetic: true },
- ],
- })
- );
+ await opencodeClient.sendMessage({
+ id: fusionSession.id,
+ providerID,
+ modelID,
+ variant: variant || undefined,
+ agent: agent || undefined,
+ text: visiblePrompt,
+ additionalParts: [
+ { text: instructionsPrompt, synthetic: true },
+ ...usableSources.map((item, index) => ({ text: buildSourcePart(item.source, item.text, index), synthetic: true })),
+ { text: '\n\n--- FUSION INPUTS END ---\nNow write the final fused answer.', synthetic: true },
+ ],
+ directory: directory ?? opencodeClient.getDirectory(),
+ });
} catch (error) {
console.error('[MultiRunFusion] Failed to start fusion', error);
toast.error(t('multirun.fusion.toast.failed'));
diff --git a/packages/ui/src/components/multirun/MultiRunLauncher.tsx b/packages/ui/src/components/multirun/MultiRunLauncher.tsx
index 8a5ddefc..c56d6c54 100644
--- a/packages/ui/src/components/multirun/MultiRunLauncher.tsx
+++ b/packages/ui/src/components/multirun/MultiRunLauncher.tsx
@@ -26,7 +26,7 @@ import { Icon } from "@/components/icon/Icon";
import { isDesktopShell } from '@/lib/desktop';
import { useTabletStandalonePwaRuntime } from '@/lib/device';
import { useThemeSystem } from '@/contexts/useThemeSystem';
-import { PROJECT_ICON_MAP, PROJECT_COLOR_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
+import { PROJECT_ICON_MAP, PROJECT_COLOR_MAP, ProjectIconImage } from '@/lib/projectMeta';
import type { ProjectEntry } from '@/lib/api/types';
import { startDesktopWindowDrag } from '@/lib/desktopNative';
import { useI18n } from '@/lib/i18n';
@@ -145,30 +145,32 @@ export const MultiRunLauncher: React.FC
= ({
const renderProjectLabel = React.useCallback((project: ProjectEntry) => {
const displayLabel = project.label?.trim() || formatDirectoryName(project.path, homeDirectory);
- const imageUrl = getProjectIconImageUrl(
- { id: project.id, iconImage: project.iconImage ?? null },
- {
- themeVariant: currentTheme.metadata.variant,
- iconColor: currentTheme.colors.surface.foreground,
- },
- );
const projectIconName = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
const iconColor = project.color ? PROJECT_COLOR_MAP[project.color] : undefined;
+ const fallbackIcon = projectIconName ? (
+
+ ) : (
+
+ );
return (
- {imageUrl ? (
+ {project.iconImage ? (
-
+
- ) : projectIconName ? (
-
- ) : (
-
- )}
+ ) : fallbackIcon}
{displayLabel}
);
diff --git a/packages/ui/src/components/onboarding/ChooserScreen.tsx b/packages/ui/src/components/onboarding/ChooserScreen.tsx
index ddf68cf8..88e19162 100644
--- a/packages/ui/src/components/onboarding/ChooserScreen.tsx
+++ b/packages/ui/src/components/onboarding/ChooserScreen.tsx
@@ -10,6 +10,7 @@ import { cn } from '@/lib/utils';
import { RemoteConnectionForm } from './RemoteConnectionForm';
import { desktopHostsGet, desktopHostsSet } from '@/lib/desktopHosts';
import { useI18n } from '@/lib/i18n';
+import { runtimeFetch } from '@/lib/runtime-fetch';
const INSTALL_COMMAND = 'curl -fsSL https://opencode.ai/install | bash';
const DOCS_URL = 'https://opencode.ai/docs';
@@ -78,7 +79,7 @@ export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) {
let cancelled = false;
void (async () => {
try {
- const response = await fetch('/api/config/settings', { method: 'GET', headers: { Accept: 'application/json' } });
+ const response = await runtimeFetch('/api/config/settings', { method: 'GET', headers: { Accept: 'application/json' } });
if (!response.ok) return;
const data = (await response.json().catch(() => null)) as null | { opencodeBinary?: unknown };
if (!data || cancelled) return;
@@ -105,7 +106,7 @@ export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) {
const checkCliAvailability = React.useCallback(async (): Promise => {
try {
- const response = await fetch('/health');
+ const response = await runtimeFetch('/health');
if (!response.ok) return false;
const data = await response.json();
return data.openCodeRunning === true || data.isOpenCodeReady === true;
@@ -206,7 +207,7 @@ export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) {
await restartDesktopApp();
return;
}
- await fetch('/api/config/reload', { method: 'POST' });
+ await runtimeFetch('/api/config/reload', { method: 'POST' });
} finally {
setTimeout(() => setIsApplyingPath(false), 1000);
}
diff --git a/packages/ui/src/components/onboarding/DesktopConnectionRecovery.tsx b/packages/ui/src/components/onboarding/DesktopConnectionRecovery.tsx
index b3ae0307..4e4941d3 100644
--- a/packages/ui/src/components/onboarding/DesktopConnectionRecovery.tsx
+++ b/packages/ui/src/components/onboarding/DesktopConnectionRecovery.tsx
@@ -50,7 +50,7 @@ export function DesktopConnectionRecovery({
if (variant === 'remote-unreachable') {
return { host: t('onboarding.desktopRecovery.placeholders.remoteServer') };
}
- if (variant === 'remote-wrong-service') {
+ if (variant === 'remote-wrong-service' || variant === 'remote-incompatible') {
return { host: t('onboarding.desktopRecovery.placeholders.unknownServer') };
}
return undefined;
@@ -84,7 +84,7 @@ export function DesktopConnectionRecovery({
{/* Host info if available */}
- {hostUrl && (variant === 'remote-unreachable' || variant === 'remote-wrong-service') && (
+ {hostUrl && (variant === 'remote-unreachable' || variant === 'remote-wrong-service' || variant === 'remote-incompatible') && (
{t('onboarding.remoteConnection.field.serverAddress')}
{redactSensitiveUrl(hostUrl)}
diff --git a/packages/ui/src/components/onboarding/LocalSetupScreen.tsx b/packages/ui/src/components/onboarding/LocalSetupScreen.tsx
index f33ae588..b04c4246 100644
--- a/packages/ui/src/components/onboarding/LocalSetupScreen.tsx
+++ b/packages/ui/src/components/onboarding/LocalSetupScreen.tsx
@@ -7,6 +7,7 @@ import { updateDesktopSettings } from '@/lib/persistence';
import { copyTextToClipboard } from '@/lib/clipboard';
import { restartDesktopApp } from '@/lib/desktop';
import { useI18n } from '@/lib/i18n';
+import { runtimeFetch } from '@/lib/runtime-fetch';
const INSTALL_COMMAND = 'curl -fsSL https://opencode.ai/install | bash';
const DOCS_URL = 'https://opencode.ai/docs';
@@ -99,7 +100,7 @@ export function LocalSetupScreen({
let cancelled = false;
void (async () => {
try {
- const response = await fetch('/api/config/settings', { method: 'GET', headers: { Accept: 'application/json' } });
+ const response = await runtimeFetch('/api/config/settings', { method: 'GET', headers: { Accept: 'application/json' } });
if (!response.ok) return;
const data = (await response.json().catch(() => null)) as null | { opencodeBinary?: unknown };
if (!data || cancelled) return;
@@ -134,7 +135,7 @@ export function LocalSetupScreen({
const checkCliAvailability = React.useCallback(async (): Promise
=> {
try {
- const response = await fetch('/health');
+ const response = await runtimeFetch('/health');
if (!response.ok) return false;
const data = await response.json();
return data.openCodeRunning === true || data.isOpenCodeReady === true;
@@ -182,7 +183,7 @@ export function LocalSetupScreen({
return;
}
- await fetch('/api/config/reload', { method: 'POST' });
+ await runtimeFetch('/api/config/reload', { method: 'POST' });
} finally {
setTimeout(() => setIsRetrying(false), 1000);
}
diff --git a/packages/ui/src/components/onboarding/RecoveryScreen.tsx b/packages/ui/src/components/onboarding/RecoveryScreen.tsx
index fc9b0c96..83fa4552 100644
--- a/packages/ui/src/components/onboarding/RecoveryScreen.tsx
+++ b/packages/ui/src/components/onboarding/RecoveryScreen.tsx
@@ -4,6 +4,7 @@ import { DesktopConnectionRecovery, type RecoveryVariant } from './DesktopConnec
import { RemoteConnectionForm } from './RemoteConnectionForm';
import { resolveRecoveryNextStep } from './desktopRecoveryRouting';
import { desktopHostsGet, desktopHostsSet } from '@/lib/desktopHosts';
+import { runtimeFetch } from '@/lib/runtime-fetch';
type RecoveryScreenProps = {
/** Recovery variant */
@@ -62,7 +63,7 @@ export function RecoveryScreen({
return;
}
- await fetch('/api/config/reload', { method: 'POST' });
+ await runtimeFetch('/api/config/reload', { method: 'POST' });
onRetry?.();
}, [onRetry]);
diff --git a/packages/ui/src/components/onboarding/RemoteConnectionForm.tsx b/packages/ui/src/components/onboarding/RemoteConnectionForm.tsx
index 31a83637..a17a91e9 100644
--- a/packages/ui/src/components/onboarding/RemoteConnectionForm.tsx
+++ b/packages/ui/src/components/onboarding/RemoteConnectionForm.tsx
@@ -3,7 +3,7 @@ import {
desktopHostsGet,
desktopHostsSet,
desktopHostProbe,
- normalizeHostUrl,
+ resolveDesktopHostUrl,
type HostProbeResult,
} from '@/lib/desktopHosts';
import { Button } from '@/components/ui/button';
@@ -37,6 +37,10 @@ function getProbeStatusMessageKey(status: ProbeStatus): string | null {
return null; // Success is shown separately
case 'auth':
return 'onboarding.remoteConnection.probe.authMessage';
+ case 'update-recommended':
+ return 'onboarding.remoteConnection.probe.updateRecommendedMessage';
+ case 'incompatible':
+ return 'onboarding.remoteConnection.probe.incompatibleMessage';
case 'wrong-service':
return 'onboarding.remoteConnection.probe.wrongServiceMessage';
case 'unreachable':
@@ -47,7 +51,7 @@ function getProbeStatusMessageKey(status: ProbeStatus): string | null {
}
function isBlockingStatus(status: ProbeStatus): boolean {
- return status === 'wrong-service' || status === 'unreachable';
+ return status === 'wrong-service' || status === 'unreachable' || status === 'incompatible';
}
export function RemoteConnectionForm({
@@ -66,7 +70,8 @@ export function RemoteConnectionForm({
const [probeResult, setProbeResult] = useState(null);
const [error, setError] = useState('');
- const normalizedUrl = normalizeHostUrl(url);
+ const resolvedUrl = resolveDesktopHostUrl(url);
+ const normalizedUrl = resolvedUrl?.persistedUrl ?? null;
const handleUrlChange = useCallback((e: React.ChangeEvent) => {
setUrl(e.target.value);
@@ -89,7 +94,7 @@ export function RemoteConnectionForm({
try {
const result = await desktopHostProbe(normalizedUrl);
setProbeResult(result);
- setState(result.status === 'ok' ? 'success' : 'error');
+ setState(result.status === 'ok' || result.status === 'update-recommended' ? 'success' : 'error');
} catch (err) {
setError(err instanceof Error ? err.message : t('onboarding.remoteConnection.errors.connectionTestFailed'));
setState('error');
@@ -97,14 +102,15 @@ export function RemoteConnectionForm({
}, [normalizedUrl, t]);
const handleConnect = useCallback(async () => {
- if (!normalizedUrl) return;
+ if (!resolvedUrl) return;
+ const targetUrl = resolvedUrl.persistedUrl;
setState('testing');
setProbeResult(null);
setError('');
try {
- const probe = await desktopHostProbe(normalizedUrl);
+ const probe = await desktopHostProbe(targetUrl);
setProbeResult(probe);
// Block connection on wrong-service or unreachable
@@ -114,10 +120,10 @@ export function RemoteConnectionForm({
}
const config = await desktopHostsGet();
- const hostLabel = label.trim() || normalizedUrl;
+ const hostLabel = label.trim() || targetUrl;
const existingHost = config.hosts.find(
- (h) => h.url === normalizedUrl
+ (h) => h.url === targetUrl
);
const hostId = existingHost ? existingHost.id : `host-${Date.now().toString(16)}`;
@@ -125,7 +131,8 @@ export function RemoteConnectionForm({
const newHost = {
id: hostId,
label: hostLabel,
- url: normalizedUrl,
+ url: targetUrl,
+ apiUrl: targetUrl,
};
const updatedHosts = existingHost
@@ -141,6 +148,11 @@ export function RemoteConnectionForm({
onConnect?.();
+ if (resolvedUrl.redeemUrl) {
+ window.location.assign(resolvedUrl.redeemUrl);
+ return;
+ }
+
if (isTauriShell()) {
const tauri = (window as unknown as { __TAURI__?: { core?: { invoke?: (cmd: string, args?: Record) => Promise } } }).__TAURI__;
await tauri?.core?.invoke?.('desktop_restart');
@@ -149,7 +161,7 @@ export function RemoteConnectionForm({
setError(err instanceof Error ? err.message : t('onboarding.remoteConnection.errors.failedToSaveConnection'));
setState('error');
}
- }, [normalizedUrl, label, onConnect, t]);
+ }, [resolvedUrl, label, onConnect, t]);
const isTesting = state === 'testing';
const canTest = normalizedUrl !== null && !isTesting;
@@ -157,6 +169,7 @@ export function RemoteConnectionForm({
const probeMessageKey = getProbeStatusMessageKey(probeResult?.status ?? null);
const isSuccess = probeResult?.status === 'ok';
+ const isUpdateRecommended = probeResult?.status === 'update-recommended';
const isAuth = probeResult?.status === 'auth';
const isBlocking = isBlockingStatus(probeResult?.status ?? null);
@@ -238,6 +251,18 @@ export function RemoteConnectionForm({
)}
+ {probeResult && isUpdateRecommended && (
+ {
expect(config.useRemoteLabel).toBe('Use Remote');
});
+ test('remote-incompatible exposes retry and both actions', () => {
+ const config = getDesktopRecoveryConfig('remote-incompatible', 'Old Server', 'https://old.example');
+
+ expect(config.showRetry).toBe(true);
+ expect(config.showUseLocal).toBe(true);
+ expect(config.showUseRemote).toBe(true);
+ expect(config.titleKey).toBe('onboarding.desktopRecovery.remoteIncompatible.title');
+ });
+
// ---------------------------------------------------------------------------
// 4. missing-default-host: chooser-with-context (both actions, no retry)
// ---------------------------------------------------------------------------
diff --git a/packages/ui/src/components/onboarding/desktopRecoveryConfig.ts b/packages/ui/src/components/onboarding/desktopRecoveryConfig.ts
index bdbaf369..95034a76 100644
--- a/packages/ui/src/components/onboarding/desktopRecoveryConfig.ts
+++ b/packages/ui/src/components/onboarding/desktopRecoveryConfig.ts
@@ -3,6 +3,7 @@ import { redactSensitiveUrl } from '@/lib/desktopHosts';
export type RecoveryVariant =
| 'local-unavailable'
| 'remote-unreachable'
+ | 'remote-incompatible'
| 'remote-wrong-service'
| 'remote-missing'
| 'missing-default-host';
@@ -113,6 +114,27 @@ export function getDesktopRecoveryConfig(
};
}
+ case 'remote-incompatible': {
+ const host = formatHostDisplay(hostLabel, hostUrl);
+ return {
+ title: 'Server Update Required',
+ description: `The OpenChamber server at "${host || 'unknown'}" is not compatible with this app version. Update OpenChamber on the server, then try again.`,
+ titleKey: 'onboarding.desktopRecovery.remoteIncompatible.title',
+ descriptionKey: 'onboarding.desktopRecovery.remoteIncompatible.description',
+ descriptionParams: host ? { host } : undefined,
+ iconKey: 'remote',
+ showRetry: true,
+ retryLabel: 'Retry Connection',
+ retryLabelKey: 'onboarding.desktopRecovery.remoteUnreachable.retry',
+ showUseLocal: true,
+ showUseRemote: true,
+ useLocalLabel: 'Use Local',
+ useLocalLabelKey: 'onboarding.desktopRecovery.common.useLocal',
+ useRemoteLabel: 'Use Remote',
+ useRemoteLabelKey: 'onboarding.desktopRecovery.common.useRemote',
+ };
+ }
+
case 'missing-default-host':
return {
title: 'No Default Connection',
diff --git a/packages/ui/src/components/onboarding/desktopRecoveryRouting.test.ts b/packages/ui/src/components/onboarding/desktopRecoveryRouting.test.ts
index b270f56c..476b7466 100644
--- a/packages/ui/src/components/onboarding/desktopRecoveryRouting.test.ts
+++ b/packages/ui/src/components/onboarding/desktopRecoveryRouting.test.ts
@@ -17,6 +17,10 @@ const EXPECTED_ROUTING: Record
= {
};
const saveBehaviorSetting = async (settings: Partial, fallbackError: string) => {
- const response = await fetch('/api/config/settings', {
+ const response = await runtimeFetch('/api/config/settings', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
@@ -104,12 +105,12 @@ export const BehaviorPage: React.FC = () => {
const load = async () => {
try {
const [settingsRes, agentsMdRes] = await Promise.all([
- fetch('/api/config/settings', {
+ runtimeFetch('/api/config/settings', {
method: 'GET',
headers: { Accept: 'application/json' },
signal: abort.signal,
}),
- fetch('/api/behavior/agents-md', {
+ runtimeFetch('/api/behavior/agents-md', {
method: 'GET',
headers: { Accept: 'application/json' },
signal: abort.signal,
@@ -204,7 +205,7 @@ export const BehaviorPage: React.FC = () => {
setIsSaving(true);
try {
const content = normalizeAgentsMdContent(prompt);
- const response = await fetch('/api/behavior/agents-md', {
+ const response = await runtimeFetch('/api/behavior/agents-md', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
diff --git a/packages/ui/src/components/sections/mcp/McpOAuthCallbackPage.tsx b/packages/ui/src/components/sections/mcp/McpOAuthCallbackPage.tsx
index d6565726..20a998f5 100644
--- a/packages/ui/src/components/sections/mcp/McpOAuthCallbackPage.tsx
+++ b/packages/ui/src/components/sections/mcp/McpOAuthCallbackPage.tsx
@@ -2,6 +2,7 @@ import React from 'react';
import { Button } from '@/components/ui/button';
import { useMcpStore } from '@/stores/useMcpStore';
import { parseMcpOAuthCallbackContext, parseMcpOAuthCallbackStateKey } from '@/components/sections/mcp/mcpOAuth';
+import { runtimeFetch } from '@/lib/runtime-fetch';
const parseQueryParam = (params: URLSearchParams, key: string): string | null => {
const value = params.get(key);
@@ -42,7 +43,7 @@ export const McpOAuthCallbackPage: React.FC = () => {
if (error) {
if (callbackStateKey) {
- void fetch(`/api/mcp/auth/pending?state=${encodeURIComponent(callbackStateKey)}`, { method: 'DELETE' }).catch(() => undefined);
+ void runtimeFetch(`/api/mcp/auth/pending?state=${encodeURIComponent(callbackStateKey)}`, { method: 'DELETE' }).catch(() => undefined);
}
setStatus('error');
setMessage(errorDescription ?? error);
@@ -57,7 +58,7 @@ export const McpOAuthCallbackPage: React.FC = () => {
let pendingContext = callbackContext;
if (!pendingContext && callbackStateKey) {
- const response = await fetch(`/api/mcp/auth/pending?state=${encodeURIComponent(callbackStateKey)}`);
+ const response = await runtimeFetch(`/api/mcp/auth/pending?state=${encodeURIComponent(callbackStateKey)}`);
if (response.ok) {
const payload = await response.json().catch(() => null) as { name?: string; directory?: string | null } | null;
if (payload?.name?.trim()) {
@@ -75,13 +76,13 @@ export const McpOAuthCallbackPage: React.FC = () => {
await completeAuth(pendingContext.name, code, pendingContext.directory);
if (callbackStateKey) {
- await fetch(`/api/mcp/auth/pending?state=${encodeURIComponent(callbackStateKey)}`, { method: 'DELETE' }).catch(() => undefined);
+ await runtimeFetch(`/api/mcp/auth/pending?state=${encodeURIComponent(callbackStateKey)}`, { method: 'DELETE' }).catch(() => undefined);
}
setStatus('success');
setMessage('Authorization completed. You can close this tab and return to OpenChamber.');
} catch (authError) {
if (callbackStateKey) {
- await fetch(`/api/mcp/auth/pending?state=${encodeURIComponent(callbackStateKey)}`, { method: 'DELETE' }).catch(() => undefined);
+ await runtimeFetch(`/api/mcp/auth/pending?state=${encodeURIComponent(callbackStateKey)}`, { method: 'DELETE' }).catch(() => undefined);
}
setStatus('error');
setMessage(normalizeMcpAuthErrorMessage(authError, 'Failed to complete MCP authorization.'));
diff --git a/packages/ui/src/components/sections/mcp/McpPage.tsx b/packages/ui/src/components/sections/mcp/McpPage.tsx
index 404c9fd6..f98f94a5 100644
--- a/packages/ui/src/components/sections/mcp/McpPage.tsx
+++ b/packages/ui/src/components/sections/mcp/McpPage.tsx
@@ -20,6 +20,8 @@ import {
} from './mcpImport';
import { useMcpStore } from '@/stores/useMcpStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
+import { runtimeFetch } from '@/lib/runtime-fetch';
+import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
import { cn } from '@/lib/utils';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { MCP_OAUTH_CALLBACK_PATH, parseMcpOAuthCallbackContext, parseMcpOAuthCallbackStateKey } from '@/components/sections/mcp/mcpOAuth';
@@ -501,7 +503,7 @@ const buildMcpOAuthRedirectUri = (name?: string | null, directory?: string | nul
return null;
}
- const url = new URL(MCP_OAUTH_CALLBACK_PATH, window.location.origin);
+ const url = new URL(MCP_OAUTH_CALLBACK_PATH, getRuntimeApiBaseUrl() || window.location.origin);
if (typeof name === 'string' && name.trim()) {
url.searchParams.set('server', name.trim());
}
@@ -516,7 +518,7 @@ const queuePendingMcpAuthContext = async (input: {
name: string;
directory?: string | null;
}): Promise => {
- const response = await fetch('/api/mcp/auth/pending', {
+ const response = await runtimeFetch('/api/mcp/auth/pending', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -533,7 +535,7 @@ const queuePendingMcpAuthContext = async (input: {
};
const getPendingMcpAuthContext = async (stateKey: string): Promise<{ name: string; directory: string | null } | null> => {
- const response = await fetch(`/api/mcp/auth/pending?state=${encodeURIComponent(stateKey)}`);
+ const response = await runtimeFetch(`/api/mcp/auth/pending?state=${encodeURIComponent(stateKey)}`);
if (!response.ok) {
return null;
}
@@ -554,7 +556,7 @@ const clearPendingMcpAuthContext = async (stateKey: string | null | undefined):
return;
}
- await fetch(`/api/mcp/auth/pending?state=${encodeURIComponent(stateKey.trim())}`, { method: 'DELETE' }).catch(() => undefined);
+ await runtimeFetch(`/api/mcp/auth/pending?state=${encodeURIComponent(stateKey.trim())}`, { method: 'DELETE' }).catch(() => undefined);
};
const normalizeMcpAuthErrorMessage = (
diff --git a/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx b/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx
index 76d86864..1d60f005 100644
--- a/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx
+++ b/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx
@@ -10,6 +10,7 @@ import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { parseModelIdentifier } from '@/lib/modelIdentifier';
+import { runtimeFetch } from '@/lib/runtime-fetch';
const getDisplayModel = (
storedModel: string | undefined
@@ -76,7 +77,7 @@ export const DefaultsSettings: React.FC = () => {
}
if (!data) {
- const response = await fetch('/api/config/settings', {
+ const response = await runtimeFetch('/api/config/settings', {
method: 'GET',
headers: { Accept: 'application/json' },
});
@@ -131,7 +132,7 @@ export const DefaultsSettings: React.FC = () => {
try {
await updateDesktopSettings({ defaultModel: newValue ?? '', defaultVariant: '' });
- const response = await fetch('/api/config/settings', {
+ const response = await runtimeFetch('/api/config/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ defaultModel: newValue }),
diff --git a/packages/ui/src/components/sections/openchamber/DesktopNetworkSettings.tsx b/packages/ui/src/components/sections/openchamber/DesktopNetworkSettings.tsx
index dc8e8a3a..948a2235 100644
--- a/packages/ui/src/components/sections/openchamber/DesktopNetworkSettings.tsx
+++ b/packages/ui/src/components/sections/openchamber/DesktopNetworkSettings.tsx
@@ -12,6 +12,8 @@ import {
setDesktopLaunchAtLogin,
} from '@/lib/desktop';
import { useI18n } from '@/lib/i18n';
+import { runtimeFetch } from '@/lib/runtime-fetch';
+import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
export const DesktopNetworkSettings: React.FC = () => {
const { t } = useI18n();
@@ -37,7 +39,7 @@ export const DesktopNetworkSettings: React.FC = () => {
let cancelled = false;
void (async () => {
try {
- const response = await fetch('/api/config/settings', {
+ const response = await runtimeFetch('/api/config/settings', {
method: 'GET',
headers: { Accept: 'application/json' },
});
@@ -123,7 +125,14 @@ export const DesktopNetworkSettings: React.FC = () => {
return null;
}
- const parsed = Number(window.location.port);
+ const runtimeApiBaseUrl = getRuntimeApiBaseUrl();
+ const portSource = runtimeApiBaseUrl || window.location.href;
+ let parsed = 0;
+ try {
+ parsed = Number(new URL(portSource).port);
+ } catch {
+ parsed = Number(window.location.port);
+ }
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}, []);
const lanUrl = draftValue && lanAddress && currentPort ? `http://${lanAddress}:${currentPort}` : null;
@@ -165,7 +174,7 @@ export const DesktopNetworkSettings: React.FC = () => {
setError(null);
try {
- const response = await fetch('/api/config/settings', {
+ const response = await runtimeFetch('/api/config/settings', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
diff --git a/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx b/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx
index 6aa19a03..dd7a2398 100644
--- a/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx
+++ b/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx
@@ -9,6 +9,7 @@ import { cn } from '@/lib/utils';
import { openExternalUrl } from '@/lib/url';
import { useI18n } from '@/lib/i18n';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
+import { runtimeFetch } from '@/lib/runtime-fetch';
import { Icon } from "@/components/icon/Icon";
type GitHubUser = {
@@ -81,7 +82,7 @@ export const GitHubSettings: React.FC = () => {
const payload = runtimeGitHub
? await runtimeGitHub.authStart()
: await (async () => {
- const response = await fetch('/api/github/auth/start', {
+ const response = await runtimeFetch('/api/github/auth/start', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -114,7 +115,7 @@ export const GitHubSettings: React.FC = () => {
return runtimeGitHub.authComplete(deviceCode) as Promise;
}
- const response = await fetch('/api/github/auth/complete', {
+ const response = await runtimeFetch('/api/github/auth/complete', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -181,7 +182,7 @@ export const GitHubSettings: React.FC = () => {
if (runtimeGitHub) {
await runtimeGitHub.authDisconnect();
} else {
- const response = await fetch('/api/github/auth', {
+ const response = await runtimeFetch('/api/github/auth', {
method: 'DELETE',
headers: { Accept: 'application/json' },
});
@@ -206,7 +207,7 @@ export const GitHubSettings: React.FC = () => {
const payload = runtimeGitHub
? await runtimeGitHub.authActivate(accountId)
: await (async () => {
- const response = await fetch('/api/github/auth/activate', {
+ const response = await runtimeFetch('/api/github/auth/activate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
diff --git a/packages/ui/src/components/sections/openchamber/GitSettings.tsx b/packages/ui/src/components/sections/openchamber/GitSettings.tsx
index beeb9efe..8a9fb78c 100644
--- a/packages/ui/src/components/sections/openchamber/GitSettings.tsx
+++ b/packages/ui/src/components/sections/openchamber/GitSettings.tsx
@@ -7,6 +7,7 @@ import { useUIStore } from '@/stores/useUIStore';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { setFilesViewShowGitignored, useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
import { useI18n } from '@/lib/i18n';
+import { runtimeFetch } from '@/lib/runtime-fetch';
export const GitSettings: React.FC = () => {
const { t } = useI18n();
@@ -63,7 +64,7 @@ export const GitSettings: React.FC = () => {
// 2. Fetch API (Web/server)
if (!data) {
- const response = await fetch('/api/config/settings', {
+ const response = await runtimeFetch('/api/config/settings', {
method: 'GET',
headers: { Accept: 'application/json' },
});
diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx
index 05401002..f1a64b0f 100644
--- a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx
+++ b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx
@@ -15,8 +15,19 @@ import { KeyboardShortcutsSettings } from './KeyboardShortcutsSettings';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { useDeviceInfo } from '@/lib/device';
import { isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime, isWebRuntime } from '@/lib/desktop';
+import { subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
import type { OpenChamberSection } from './types';
+const useRuntimeEndpointEpoch = (): number => {
+ const [epoch, setEpoch] = React.useState(0);
+
+ React.useEffect(() => {
+ return subscribeRuntimeEndpointChanged(() => setEpoch((current) => current + 1));
+ }, []);
+
+ return epoch;
+};
+
interface OpenChamberPageProps {
/** Which section to display. If undefined, shows all sections (mobile/legacy behavior) */
section?: OpenChamberSection;
@@ -24,8 +35,10 @@ interface OpenChamberPageProps {
export const OpenChamberPage: React.FC = ({ section }) => {
const { isMobile } = useDeviceInfo();
+ const runtimeEndpointEpoch = useRuntimeEndpointEpoch();
const showAbout = isMobile && isWebRuntime();
const isVSCode = isVSCodeRuntime();
+ void runtimeEndpointEpoch;
const showDesktopNetworkSettings = isDesktopShell() && isDesktopLocalOriginActive();
// If no section specified, show all (mobile/legacy behavior)
@@ -135,6 +148,8 @@ const ChatSectionContent: React.FC = () => {
// Sessions section: Default model & agent, Session retention
const SessionsSectionContent: React.FC = () => {
const isVSCode = isVSCodeRuntime();
+ const runtimeEndpointEpoch = useRuntimeEndpointEpoch();
+ void runtimeEndpointEpoch;
const showDesktopNetworkSettings = isDesktopShell() && isDesktopLocalOriginActive();
return (
diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx
index 4c3fd11e..f9df4f25 100644
--- a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx
+++ b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx
@@ -1,4 +1,5 @@
import React from 'react';
+import { runtimeFetch } from '@/lib/runtime-fetch';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useThemeSystem } from '@/contexts/useThemeSystem';
@@ -27,6 +28,7 @@ import { CODE_FONT_OPTIONS, DEFAULT_MONO_FONT, DEFAULT_UI_FONT, UI_FONT_OPTIONS,
import { useI18n, type Locale } from '@/lib/i18n';
import { useConfigStore } from '@/stores/useConfigStore';
import { normalizeMobileKeyboardMode, supportsMobileKeyboardResizeContent, type MobileKeyboardMode } from '@/lib/mobileKeyboardMode';
+import { getStoredMobileLayoutPreference, setStoredMobileLayoutPreference, type MobileLayoutPreference } from '@/lib/mobileLayoutPreference';
import {
setDirectoryShowHidden,
useDirectoryShowHidden,
@@ -129,6 +131,17 @@ const MOBILE_KEYBOARD_MODE_OPTIONS: Option[] = [
},
];
+const MOBILE_LAYOUT_OPTIONS: Array<{ value: MobileLayoutPreference; labelKey: string }> = [
+ {
+ value: 'default',
+ labelKey: 'settings.openchamber.visual.option.mobileLayout.default',
+ },
+ {
+ value: 'new',
+ labelKey: 'settings.openchamber.visual.option.mobileLayout.new',
+ },
+];
+
type PwaInstallNameWindow = Window & {
__OPENCHAMBER_SET_PWA_INSTALL_NAME__?: (value: string) => string;
__OPENCHAMBER_SET_PWA_ORIENTATION__?: (value: 'system' | 'portrait' | 'landscape') => 'system' | 'portrait' | 'landscape';
@@ -483,9 +496,10 @@ export const OpenChamberVisualSettings: React.FC
const isVSCode = isVSCodeRuntime();
const hasThemeSettings = shouldShow('theme') && !isVSCode;
const hasLocalizationSettings = shouldShow('theme') || shouldShow('timeFormat') || shouldShow('weekStart');
+ const showMobileLayoutSetting = isMobile && isWebRuntime() && !isDesktopShell() && !isVSCode;
const hasAppearanceSettings = isVSCode
? hasLocalizationSettings
- : (shouldShow('theme') || shouldShow('pwaInstallName') || shouldShow('pwaOrientation') || shouldShow('timeFormat') || shouldShow('weekStart'));
+ : (shouldShow('theme') || showMobileLayoutSetting || shouldShow('pwaInstallName') || shouldShow('pwaOrientation') || shouldShow('timeFormat') || shouldShow('weekStart'));
const hasLayoutSettings = shouldShow('fontSize') || shouldShow('terminalFontSize') || shouldShow('spacing') || shouldShow('inputBarOffset');
const hasNavigationSettings = shouldShow('terminalQuickKeys') && !isMobile;
const hasBehaviorSettings = shouldShow('mermaidRendering')
@@ -509,6 +523,7 @@ export const OpenChamberVisualSettings: React.FC
const showPwaInstallNameSetting = shouldShow('pwaInstallName') && isWebRuntime() && browserTab && !isDesktopShell() && !isVSCode;
const showPwaOrientationSetting = shouldShow('pwaOrientation') && isWebRuntime() && !isDesktopShell() && !isVSCode;
const showMobileKeyboardModeSetting = shouldShow('mobileKeyboardMode') && isWebRuntime() && !isDesktopShell() && !isVSCode && supportsMobileKeyboardResizeContent();
+ const [mobileLayoutPreference, setMobileLayoutPreference] = React.useState(() => getStoredMobileLayoutPreference());
const [pwaInstallName, setPwaInstallName] = React.useState('');
const [pwaOrientation, setPwaOrientation] = React.useState<'system' | 'portrait' | 'landscape'>('system');
const selectedTimeFormatLabel = React.useMemo(() => {
@@ -528,6 +543,16 @@ export const OpenChamberVisualSettings: React.FC
return option ? tUnsafe(option.labelKey) : undefined;
}, [mobileKeyboardMode, tUnsafe]);
+ const handleMobileLayoutPreferenceChange = React.useCallback((value: MobileLayoutPreference) => {
+ if (value === mobileLayoutPreference) {
+ return;
+ }
+
+ setMobileLayoutPreference(value);
+ setStoredMobileLayoutPreference(value);
+ window.location.reload();
+ }, [mobileLayoutPreference]);
+
const applyPwaInstallName = React.useCallback(async (value: string) => {
if (typeof window === 'undefined') {
return;
@@ -578,7 +603,7 @@ export const OpenChamberVisualSettings: React.FC
const loadPwaInstallName = async () => {
try {
- const response = await fetch('/api/config/settings', {
+ const response = await runtimeFetch('/api/config/settings', {
method: 'GET',
headers: { Accept: 'application/json' },
cache: 'no-store',
@@ -656,6 +681,26 @@ export const OpenChamberVisualSettings: React.FC
+ {showMobileLayoutSetting && (
+
{t('settings.openchamber.visual.field.lightTheme')}
diff --git a/packages/ui/src/components/sections/openchamber/OpenCodeCliSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenCodeCliSettings.tsx
index e78090d3..7feaa686 100644
--- a/packages/ui/src/components/sections/openchamber/OpenCodeCliSettings.tsx
+++ b/packages/ui/src/components/sections/openchamber/OpenCodeCliSettings.tsx
@@ -9,6 +9,7 @@ import { updateDesktopSettings } from '@/lib/persistence';
import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
import { useUIStore } from '@/stores/useUIStore';
import { useI18n } from '@/lib/i18n';
+import { runtimeFetch } from '@/lib/runtime-fetch';
export const OpenCodeCliSettings: React.FC = () => {
const { t } = useI18n();
@@ -22,7 +23,7 @@ export const OpenCodeCliSettings: React.FC = () => {
let cancelled = false;
void (async () => {
try {
- const response = await fetch('/api/config/settings', {
+ const response = await runtimeFetch('/api/config/settings', {
method: 'GET',
headers: { Accept: 'application/json' },
});
diff --git a/packages/ui/src/components/sections/openchamber/TunnelSettings.tsx b/packages/ui/src/components/sections/openchamber/TunnelSettings.tsx
index 853144c6..2669a5ad 100644
--- a/packages/ui/src/components/sections/openchamber/TunnelSettings.tsx
+++ b/packages/ui/src/components/sections/openchamber/TunnelSettings.tsx
@@ -1,6 +1,7 @@
import React from 'react';
import QRCode from 'qrcode';
import { toast } from '@/components/ui';
+import { runtimeFetch } from '@/lib/runtime-fetch';
import { Button } from '@/components/ui/button';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { Input } from '@/components/ui/input';
@@ -12,6 +13,7 @@ import { updateDesktopSettings } from '@/lib/persistence';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { openExternalUrl } from '@/lib/url';
+import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
type TunnelState =
| 'checking'
@@ -364,7 +366,14 @@ export const TunnelSettings: React.FC = () => {
if (typeof window === 'undefined') {
return null;
}
- const parsed = Number(window.location.port);
+ const runtimeApiBaseUrl = getRuntimeApiBaseUrl();
+ const portSource = runtimeApiBaseUrl || window.location.href;
+ let parsed = 0;
+ try {
+ parsed = Number(new URL(portSource).port);
+ } catch {
+ parsed = Number(window.location.port);
+ }
if (Number.isFinite(parsed) && parsed > 0) {
return parsed;
}
@@ -398,10 +407,10 @@ export const TunnelSettings: React.FC = () => {
const checkAvailabilityAndStatus = React.useCallback(async (signal: AbortSignal) => {
try {
const [checkRes, statusRes, settingsRes, providersRes] = await Promise.all([
- fetch('/api/openchamber/tunnel/check', { signal }),
- fetch('/api/openchamber/tunnel/status', { signal }),
- fetch('/api/config/settings', { signal, headers: { Accept: 'application/json' } }),
- fetch('/api/openchamber/tunnel/providers', { signal }),
+ runtimeFetch('/api/openchamber/tunnel/check', { signal }),
+ runtimeFetch('/api/openchamber/tunnel/status', { signal }),
+ runtimeFetch('/api/config/settings', { signal, headers: { Accept: 'application/json' } }),
+ runtimeFetch('/api/openchamber/tunnel/providers', { signal }),
]);
const checkData = await checkRes.json();
@@ -614,7 +623,7 @@ export const TunnelSettings: React.FC = () => {
let cancelled = false;
const refreshSessions = async () => {
try {
- const statusRes = await fetch('/api/openchamber/tunnel/status');
+ const statusRes = await runtimeFetch('/api/openchamber/tunnel/status');
if (!statusRes.ok || cancelled) {
return;
}
@@ -818,7 +827,7 @@ export const TunnelSettings: React.FC = () => {
});
}
- const res = await fetch('/api/openchamber/tunnel/start', {
+ const res = await runtimeFetch('/api/openchamber/tunnel/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -913,8 +922,8 @@ export const TunnelSettings: React.FC = () => {
setState('stopping');
try {
- await fetch('/api/openchamber/tunnel/stop', { method: 'POST' });
- const statusRes = await fetch('/api/openchamber/tunnel/status');
+ await runtimeFetch('/api/openchamber/tunnel/stop', { method: 'POST' });
+ const statusRes = await runtimeFetch('/api/openchamber/tunnel/status');
if (statusRes.ok) {
const statusData = (await statusRes.json()) as TunnelStatusResponse;
setSessionRecords(Array.isArray(statusData.activeSessions) ? statusData.activeSessions : []);
diff --git a/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx b/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx
index 6beeaf48..8b1bf656 100644
--- a/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx
+++ b/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx
@@ -20,6 +20,7 @@ import { audioStreamService } from '@/lib/voice/audioStreamService';
import { wasmSttService, WASM_MODELS } from '@/lib/voice/wasmSttService';
import type { WasmModelStatus } from '@/lib/voice/wasmSttService';
import { cn } from '@/lib/utils';
+import { runtimeFetch } from '@/lib/runtime-fetch';
import { useI18n } from '@/lib/i18n';
import { disposePreviewAudio } from './voicePreviewAudio';
const LANGUAGE_OPTIONS = [
@@ -278,7 +279,7 @@ export const VoiceSettings: React.FC = () => {
const checkOpenAIAvailability = async () => {
try {
- const response = await fetch('/api/tts/status');
+ const response = await runtimeFetch('/api/tts/status');
const data = await response.json();
const hasServerKey = data.available;
const hasSettingsKey = openaiApiKey.trim().length > 0;
@@ -298,7 +299,7 @@ export const VoiceSettings: React.FC = () => {
return;
}
- fetch('/api/tts/say/status')
+ runtimeFetch('/api/tts/say/status')
.then(res => res.json())
.then(data => {
setIsSayAvailable(data.available);
@@ -327,7 +328,7 @@ export const VoiceSettings: React.FC = () => {
setIsPreviewPlaying(true);
let audio: HTMLAudioElement | null = null;
try {
- const response = await fetch('/api/tts/say/speak', {
+ const response = await runtimeFetch('/api/tts/say/speak', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -381,7 +382,7 @@ export const VoiceSettings: React.FC = () => {
setIsOpenAIPreviewPlaying(true);
let audio: HTMLAudioElement | null = null;
try {
- const response = await fetch('/api/tts/speak', {
+ const response = await runtimeFetch('/api/tts/speak', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -441,7 +442,7 @@ export const VoiceSettings: React.FC = () => {
setIsCompatiblePreviewPlaying(true);
let audio: HTMLAudioElement | null = null;
try {
- const response = await fetch('/api/tts/speak', {
+ const response = await runtimeFetch('/api/tts/speak', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
diff --git a/packages/ui/src/components/sections/projects/ProjectsPage.tsx b/packages/ui/src/components/sections/projects/ProjectsPage.tsx
index ec58255e..3ac2f9c1 100644
--- a/packages/ui/src/components/sections/projects/ProjectsPage.tsx
+++ b/packages/ui/src/components/sections/projects/ProjectsPage.tsx
@@ -6,7 +6,7 @@ import { toast } from '@/components/ui';
import { cn } from '@/lib/utils';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useUIStore } from '@/stores/useUIStore';
-import { PROJECT_COLORS, PROJECT_ICONS, PROJECT_COLOR_MAP as COLOR_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
+import { PROJECT_COLORS, PROJECT_ICONS, PROJECT_COLOR_MAP as COLOR_MAP, ProjectIconImage } from '@/lib/projectMeta';
import { WorktreeSectionContent } from '@/components/sections/openchamber/WorktreeSectionContent';
import { ProjectActionsSection } from '@/components/sections/projects/ProjectActionsSection';
import { Icon } from "@/components/icon/Icon";
@@ -160,16 +160,8 @@ export const ProjectsPage: React.FC = () => {
const hasCustomIcon = selectedProject?.iconImage?.source === 'custom';
const effectiveHasImageIcon = (hasStoredImageIcon && !pendingRemoveImageIcon) || hasPendingUploadImageIcon;
const hasRemovableImageIcon = effectiveHasImageIcon;
- const iconPreviewUrl = !previewImageFailed
- ? (hasPendingUploadImageIcon
- ? pendingUploadIconPreviewUrl
- : (selectedProject && hasStoredImageIcon && !pendingRemoveImageIcon
- ? getProjectIconImageUrl(selectedProject, {
- themeVariant: currentTheme.metadata.variant,
- iconColor: currentTheme.colors.surface.foreground,
- })
- : null))
- : null;
+ const showStoredImagePreview = Boolean(selectedProject && hasStoredImageIcon && !pendingRemoveImageIcon);
+ const showImagePreview = !previewImageFailed && (hasPendingUploadImageIcon || showStoredImagePreview);
const handleUploadIcon = React.useCallback((file: File | null) => {
if (!selectedProject || !file || isUploadingIcon) {
@@ -368,7 +360,7 @@ export const ProjectsPage: React.FC = () => {
);
})}
- {effectiveHasImageIcon && iconPreviewUrl && (
+ {effectiveHasImageIcon && showImagePreview && (
{t('settings.projects.page.field.preview')}
@@ -376,13 +368,25 @@ export const ProjectsPage: React.FC = () => {
className="inline-flex h-4 w-4 items-center justify-center overflow-hidden rounded-[2px]"
style={iconBackground ? { backgroundColor: iconBackground } : undefined}
>
-
setPreviewImageFailed(true)}
- />
+ {hasPendingUploadImageIcon && pendingUploadIconPreviewUrl ? (
+
setPreviewImageFailed(true)}
+ />
+ ) : selectedProject ? (
+ setPreviewImageFailed(true)}
+ />
+ ) : null}
diff --git a/packages/ui/src/components/sections/projects/ProjectsSidebar.tsx b/packages/ui/src/components/sections/projects/ProjectsSidebar.tsx
index 0c0d0b49..bb820307 100644
--- a/packages/ui/src/components/sections/projects/ProjectsSidebar.tsx
+++ b/packages/ui/src/components/sections/projects/ProjectsSidebar.tsx
@@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button';
import { SettingsSidebarLayout } from '@/components/sections/shared/SettingsSidebarLayout';
import { SettingsSidebarItem } from '@/components/sections/shared/SettingsSidebarItem';
import { Icon } from "@/components/icon/Icon";
-import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
+import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta';
import { cn } from '@/lib/utils';
import { isVSCodeRuntime } from '@/lib/desktop';
import { sessionEvents } from '@/lib/sessionEvents';
@@ -18,7 +18,6 @@ export const ProjectsSidebar: React.FC<{ onItemSelect?: () => void }> = ({ onIte
const selectedId = useUIStore((state) => state.settingsProjectsSelectedId);
const setSelectedId = useUIStore((state) => state.setSettingsProjectsSelectedId);
const { currentTheme } = useThemeSystem();
- const [brokenIconIds, setBrokenIconIds] = React.useState
>(new Set());
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
@@ -66,45 +65,32 @@ export const ProjectsSidebar: React.FC<{ onItemSelect?: () => void }> = ({ onIte
{projects.map((project) => {
const selected = project.id === selectedId;
const iconName = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
- const imageFailureKey = `${project.id}:${project.iconImage?.updatedAt ?? 0}`;
- const imageUrl = brokenIconIds.has(imageFailureKey)
- ? null
- : getProjectIconImageUrl(project, {
- themeVariant: currentTheme.metadata.variant,
- iconColor: currentTheme.colors.surface.foreground,
- });
const color = project.color ? (PROJECT_COLOR_MAP[project.color] ?? null) : null;
- const icon = imageUrl
- ? (
-
-
{
- setBrokenIconIds((prev) => {
- if (prev.has(imageFailureKey)) {
- return prev;
- }
- const next = new Set(prev);
- next.add(imageFailureKey);
- return next;
- });
- }}
- />
-
- )
- : iconName
+ const fallbackIcon = iconName
? (
)
: (
);
+ const icon = project.iconImage
+ ? (
+
+
+
+ )
+ : fallbackIcon;
return (
{
const loadAuthMethods = async () => {
setAuthLoading(true);
try {
- const response = await fetch('/api/provider/auth', {
- method: 'GET',
- headers: { Accept: 'application/json' },
- });
-
- if (!response.ok) {
- throw new Error(`Auth methods request failed (${response.status})`);
+ const result = await opencodeClient.getSdkClient().provider.auth();
+ if (result.error) {
+ throw new Error(`provider.auth failed: ${String(result.error)}`);
}
-
- const payload = await response.json().catch(() => ({}));
if (!isMounted) return;
- setAuthMethodsByProvider(parseAuthPayload(payload));
+ setAuthMethodsByProvider(parseAuthPayload(result.data));
} catch (error) {
if (!isMounted) return;
console.error('Failed to load provider auth methods:', error);
@@ -217,18 +213,12 @@ export const ProvidersPage: React.FC = () => {
setAvailableLoading(true);
setAvailableError(null);
try {
- const response = await fetch('/api/provider', {
- method: 'GET',
- headers: { Accept: 'application/json' },
- });
-
- if (!response.ok) {
- throw new Error(`Provider list request failed (${response.status})`);
+ const result = await opencodeClient.getSdkClient().provider.list();
+ if (result.error) {
+ throw new Error(`provider.list failed: ${String(result.error)}`);
}
-
- const payload = await response.json().catch(() => ({}));
if (!isMounted) return;
- setAvailableProviders(parseProvidersPayload(payload));
+ setAvailableProviders(parseProvidersPayload(result.data));
} catch (error) {
if (!isMounted) return;
console.error('Failed to load available providers:', error);
@@ -292,7 +282,9 @@ export const ProvidersPage: React.FC = () => {
const loadSources = async () => {
try {
- const response = await fetch(`/api/provider/${encodeURIComponent(selectedProviderId)}/source`, {
+ // OpenChamber-only metadata endpoint: the SDK exposes provider data but
+ // not local auth/source-file provenance used by this settings UI.
+ const response = await runtimeFetch(`/api/provider/${encodeURIComponent(selectedProviderId)}/source`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
@@ -337,16 +329,12 @@ export const ProvidersPage: React.FC = () => {
setAuthBusyKey(busyKey);
try {
- const response = await fetch(`/api/auth/${encodeURIComponent(providerId)}`, {
- method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ type: 'api', key: apiKey }),
+ const result = await opencodeClient.getSdkClient().auth.set({
+ providerID: providerId,
+ auth: { type: 'api', key: apiKey },
});
-
- const payload = await response.json().catch(() => null);
- if (!response.ok) {
- const message = payload?.error || t('settings.providers.page.toast.apiKeySaveFailed');
- throw new Error(message);
+ if (result.error) {
+ throw new Error(t('settings.providers.page.toast.apiKeySaveFailed'));
}
toast.success(t('settings.providers.page.toast.apiKeySaved'));
@@ -366,20 +354,17 @@ export const ProvidersPage: React.FC = () => {
setAuthBusyKey(busyKey);
try {
- const response = await fetch(`/api/provider/${encodeURIComponent(providerId)}/oauth/authorize`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ method: methodIndex }),
+ const result = await opencodeClient.getSdkClient().provider.oauth.authorize({
+ providerID: providerId,
+ method: methodIndex,
});
-
- const payload = await response.json().catch(() => null);
- if (!response.ok) {
- const message = payload?.error || t('settings.providers.page.toast.oauthStartFailed');
- throw new Error(message);
+ if (result.error) {
+ throw new Error(t('settings.providers.page.toast.oauthStartFailed'));
}
- const payloadRecord = isRecord(payload) ? payload : {};
- const dataRecord = isRecord(payloadRecord.data) ? payloadRecord.data : payloadRecord;
+ const payloadRecord: Record = isRecord(result.data) ? result.data : {};
+ const nestedData = payloadRecord.data;
+ const dataRecord: Record = isRecord(nestedData) ? nestedData : payloadRecord;
const urlCandidate =
(typeof dataRecord.url === 'string' && dataRecord.url) ||
(typeof dataRecord.verification_uri_complete === 'string' && dataRecord.verification_uri_complete) ||
@@ -435,16 +420,13 @@ export const ProvidersPage: React.FC = () => {
requestBody.code = code;
}
- const response = await fetch(`/api/provider/${encodeURIComponent(providerId)}/oauth/callback`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(requestBody),
+ const result = await opencodeClient.getSdkClient().provider.oauth.callback({
+ providerID: providerId,
+ method: requestBody.method,
+ code: requestBody.code,
});
-
- const responsePayload = await response.json().catch(() => null);
- if (!response.ok) {
- const message = responsePayload?.error || t('settings.providers.page.toast.oauthCompleteFailed');
- throw new Error(message);
+ if (result.error) {
+ throw new Error(t('settings.providers.page.toast.oauthCompleteFailed'));
}
toast.success(t('settings.providers.page.toast.oauthCompleted'));
@@ -485,15 +467,9 @@ export const ProvidersPage: React.FC = () => {
setAuthBusyKey(busyKey);
try {
- const response = await fetch(`/api/provider/${encodeURIComponent(providerId)}/auth?scope=all`, {
- method: 'DELETE',
- headers: { 'Content-Type': 'application/json' },
- });
-
- const payload = await response.json().catch(() => null);
- if (!response.ok) {
- const message = payload?.error || t('settings.providers.page.toast.providerDisconnectFailed');
- throw new Error(message);
+ const result = await opencodeClient.getSdkClient().auth.remove({ providerID: providerId });
+ if (result.error) {
+ throw new Error(t('settings.providers.page.toast.providerDisconnectFailed'));
}
toast.success(t('settings.providers.page.toast.providerDisconnected'));
diff --git a/packages/ui/src/components/sections/providers/ProvidersSidebar.tsx b/packages/ui/src/components/sections/providers/ProvidersSidebar.tsx
index 59957c7b..39dac0a2 100644
--- a/packages/ui/src/components/sections/providers/ProvidersSidebar.tsx
+++ b/packages/ui/src/components/sections/providers/ProvidersSidebar.tsx
@@ -9,6 +9,7 @@ import { SettingsProjectSelector } from '@/components/sections/shared/SettingsPr
import { Icon } from "@/components/icon/Icon";
import { opencodeClient } from '@/lib/opencode/client';
import { useI18n } from '@/lib/i18n';
+import { runtimeFetch } from '@/lib/runtime-fetch';
const ADD_PROVIDER_ID = '__add_provider__';
@@ -61,7 +62,9 @@ export const ProvidersSidebar: React.FC = ({ onItemSelect
const tasks = providers.map(async (provider) => {
try {
const query = directory ? `?directory=${encodeURIComponent(directory)}` : '';
- const response = await fetch(`/api/provider/${encodeURIComponent(provider.id)}/source${query}`, {
+ // OpenChamber-only metadata endpoint: the SDK exposes provider data but
+ // not local auth/source-file provenance used by this settings sidebar.
+ const response = await runtimeFetch(`/api/provider/${encodeURIComponent(provider.id)}/source${query}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
diff --git a/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx b/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx
index 6a6461d9..0b755f08 100644
--- a/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx
+++ b/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx
@@ -1,4 +1,5 @@
import React from 'react';
+import QRCode from 'qrcode';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { NumberInput } from '@/components/ui/number-input';
@@ -27,6 +28,9 @@ import { Icon } from "@/components/icon/Icon";
import { copyTextToClipboard } from '@/lib/clipboard';
import { openExternalUrl } from '@/lib/url';
import { useI18n, type I18nKey } from '@/lib/i18n';
+import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
+import type { RemoteClientRecord } from '@/lib/api/types';
+import { buildClientConnectionPayload, encodeClientConnectionPayload, parseClientConnectionPayload } from '@/lib/connectionPayload';
import {
desktopSshLogsClear,
desktopSshLogs,
@@ -34,6 +38,16 @@ import {
type DesktopSshPortForward,
type DesktopSshPortForwardType,
} from '@/lib/desktopSsh';
+import {
+ desktopHostsGet,
+ desktopHostsSet,
+ normalizeHostUrl,
+ redactSensitiveUrl,
+ resolveDesktopHostUrl,
+ type DesktopHost,
+} from '@/lib/desktopHosts';
+import { isDesktopShell } from '@/lib/desktop';
+import { getRuntimeApiBaseUrl, switchRuntimeEndpoint } from '@/lib/runtime-switch';
const randomPort = (): number => {
return Math.floor(20000 + Math.random() * 30000);
@@ -241,9 +255,12 @@ const normalizeForSave = (instance: DesktopSshInstance): DesktopSshInstance => {
export const RemoteInstancesPage: React.FC = () => {
const { t } = useI18n();
+ const { clientAuth } = useRuntimeAPIs();
+ const showInstanceManagement = isDesktopShell();
const instances = useDesktopSshStore((state) => state.instances);
const statusesById = useDesktopSshStore((state) => state.statusesById);
const importCandidates = useDesktopSshStore((state) => state.importCandidates);
+ const isLoading = useDesktopSshStore((state) => state.isLoading);
const isImportsLoading = useDesktopSshStore((state) => state.isImportsLoading);
const isSaving = useDesktopSshStore((state) => state.isSaving);
const error = useDesktopSshStore((state) => state.error);
@@ -277,12 +294,276 @@ export const RemoteInstancesPage: React.FC = () => {
const [isPrimaryActionPending, setIsPrimaryActionPending] = React.useState(false);
const [isRetryPending, setIsRetryPending] = React.useState(false);
const [clockMs, setClockMs] = React.useState(() => Date.now());
+ const [directHosts, setDirectHosts] = React.useState([]);
+ const [directDefaultHostId, setDirectDefaultHostId] = React.useState('local');
+ const [directLoading, setDirectLoading] = React.useState(false);
+ const [directSaving, setDirectSaving] = React.useState(false);
+ const [directLabel, setDirectLabel] = React.useState('');
+ const [directUrl, setDirectUrl] = React.useState('');
+ const [directToken, setDirectToken] = React.useState('');
+ const [directConnectLink, setDirectConnectLink] = React.useState('');
+ const [directError, setDirectError] = React.useState(null);
+ const [directAddDialogOpen, setDirectAddDialogOpen] = React.useState(false);
+ const [directImportDialogOpen, setDirectImportDialogOpen] = React.useState(false);
+ const [directEditingId, setDirectEditingId] = React.useState(null);
+ const [directEditLabel, setDirectEditLabel] = React.useState('');
+ const [directEditUrl, setDirectEditUrl] = React.useState('');
+ const [directEditToken, setDirectEditToken] = React.useState('');
+ const [remoteClients, setRemoteClients] = React.useState([]);
+ const [remoteClientsLoading, setRemoteClientsLoading] = React.useState(false);
+ const [remoteClientLabel, setRemoteClientLabel] = React.useState('');
+ const [createdRemoteClientToken, setCreatedRemoteClientToken] = React.useState(null);
+ const [remoteClientError, setRemoteClientError] = React.useState(null);
+ const [pairingUrl, setPairingUrl] = React.useState(null);
+ const [pairingQrDataUrl, setPairingQrDataUrl] = React.useState(null);
+ const revokedClientCount = React.useMemo(() => remoteClients.filter((client) => Boolean(client.revokedAt)).length, [remoteClients]);
+ const [sshAddDialogOpen, setSshAddDialogOpen] = React.useState(false);
+ const [sshCommandDraft, setSshCommandDraft] = React.useState('ssh user@example.com');
+ const [sshNameDraft, setSshNameDraft] = React.useState('');
React.useEffect(() => {
void load();
void loadImports();
}, [load, loadImports]);
+ const loadDirectHosts = React.useCallback(async () => {
+ setDirectLoading(true);
+ setDirectError(null);
+ try {
+ const config = await desktopHostsGet();
+ setDirectHosts(config.hosts || []);
+ setDirectDefaultHostId(config.defaultHostId || 'local');
+ } catch (err) {
+ setDirectError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setDirectLoading(false);
+ }
+ }, []);
+
+ React.useEffect(() => {
+ void loadDirectHosts();
+ }, [loadDirectHosts]);
+
+ const persistDirectHosts = React.useCallback(async (hosts: DesktopHost[], defaultHostId: string | null = directDefaultHostId) => {
+ setDirectSaving(true);
+ setDirectError(null);
+ try {
+ await desktopHostsSet({ hosts, defaultHostId, initialHostChoiceCompleted: true });
+ setDirectHosts(hosts);
+ setDirectDefaultHostId(defaultHostId);
+ } catch (err) {
+ setDirectError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setDirectSaving(false);
+ }
+ }, [directDefaultHostId]);
+
+ const handleAddDirectHost = React.useCallback(async () => {
+ const resolved = resolveDesktopHostUrl(directUrl);
+ if (!resolved) {
+ setDirectError(t('desktopHostSwitcher.error.invalidUrl'));
+ return;
+ }
+ const url = resolved.persistedUrl;
+ const id = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
+ ? crypto.randomUUID()
+ : `host-${Date.now()}-${Math.random().toString(16).slice(2)}`;
+ const host: DesktopHost = {
+ id,
+ label: directLabel.trim() || redactSensitiveUrl(url),
+ url,
+ apiUrl: url,
+ ...(directToken.trim() ? { clientToken: directToken.trim() } : {}),
+ };
+ await persistDirectHosts([host, ...directHosts], directDefaultHostId);
+ setDirectLabel('');
+ setDirectUrl('');
+ setDirectToken('');
+ setDirectAddDialogOpen(false);
+ if (resolved.redeemUrl) {
+ navigateToUrl(resolved.redeemUrl);
+ }
+ }, [directDefaultHostId, directHosts, directLabel, directToken, directUrl, persistDirectHosts, t]);
+
+ const importDirectConnectLink = React.useCallback(async () => {
+ const payload = parseClientConnectionPayload(directConnectLink);
+ if (!payload) {
+ setDirectError(t('settings.remoteInstances.direct.error.invalidConnectLink'));
+ return;
+ }
+ const url = normalizeHostUrl(payload.serverUrl);
+ if (!url) {
+ setDirectError(t('desktopHostSwitcher.error.invalidUrl'));
+ return;
+ }
+ const existing = directHosts.find((host) => normalizeHostUrl(host.apiUrl || host.url) === url);
+ if (existing) {
+ const nextHosts = directHosts.map((host) => host.id === existing.id
+ ? { ...host, label: payload.label || host.label, url, apiUrl: url, clientToken: payload.token }
+ : host);
+ await persistDirectHosts(nextHosts, directDefaultHostId);
+ } else {
+ const id = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
+ ? crypto.randomUUID()
+ : `host-${Date.now()}-${Math.random().toString(16).slice(2)}`;
+ await persistDirectHosts([{ id, label: payload.label || redactSensitiveUrl(url), url, apiUrl: url, clientToken: payload.token }, ...directHosts], directDefaultHostId);
+ }
+ setDirectConnectLink('');
+ setDirectError(null);
+ setDirectImportDialogOpen(false);
+ }, [directConnectLink, directDefaultHostId, directHosts, persistDirectHosts, t]);
+
+ const handleRemoveDirectHost = React.useCallback(async (id: string) => {
+ const nextHosts = directHosts.filter((host) => host.id !== id);
+ const nextDefault = directDefaultHostId === id ? 'local' : directDefaultHostId;
+ await persistDirectHosts(nextHosts, nextDefault);
+ if (directEditingId === id) {
+ setDirectEditingId(null);
+ }
+ }, [directDefaultHostId, directEditingId, directHosts, persistDirectHosts]);
+
+ const beginEditDirectHost = React.useCallback((host: DesktopHost) => {
+ setDirectEditingId(host.id);
+ setDirectEditLabel(host.label);
+ setDirectEditUrl(host.apiUrl || host.url);
+ setDirectEditToken(host.clientToken || '');
+ setDirectError(null);
+ }, []);
+
+ const saveDirectHostEdit = React.useCallback(async () => {
+ if (!directEditingId) return;
+ const resolved = resolveDesktopHostUrl(directEditUrl);
+ if (!resolved) {
+ setDirectError(t('desktopHostSwitcher.error.invalidUrl'));
+ return;
+ }
+ const url = resolved.persistedUrl;
+ const nextHosts = directHosts.map((host) => host.id === directEditingId
+ ? {
+ ...host,
+ label: directEditLabel.trim() || redactSensitiveUrl(url),
+ url,
+ apiUrl: url,
+ clientToken: directEditToken.trim() || undefined,
+ }
+ : host);
+ await persistDirectHosts(nextHosts, directDefaultHostId);
+ setDirectEditingId(null);
+ if (resolved.redeemUrl) {
+ navigateToUrl(resolved.redeemUrl);
+ }
+ }, [directDefaultHostId, directEditLabel, directEditToken, directEditUrl, directEditingId, directHosts, persistDirectHosts, t]);
+
+ const createSshInstanceFromDialog = React.useCallback(async () => {
+ const command = sshCommandDraft.trim();
+ if (!command) {
+ toast.error(t('settings.remoteInstances.page.toast.sshCommandRequired'));
+ return;
+ }
+ const id = `ssh-${Date.now()}-${Math.random().toString(16).slice(2)}`;
+ try {
+ await createFromCommand(id, command, sshNameDraft.trim() || t('settings.remoteInstances.sidebar.newSshInstanceName'));
+ setSelectedId(id);
+ setSshAddDialogOpen(false);
+ setSshCommandDraft('ssh user@example.com');
+ setSshNameDraft('');
+ toast.success(t('settings.remoteInstances.page.toast.instanceCreated'));
+ } catch (error) {
+ toast.error(t('settings.remoteInstances.sidebar.toast.createFailed'), {
+ description: error instanceof Error ? error.message : String(error),
+ });
+ }
+ }, [createFromCommand, setSelectedId, sshCommandDraft, sshNameDraft, t]);
+
+ const setDefaultDirectHost = React.useCallback(async (id: string) => {
+ await persistDirectHosts(directHosts, id);
+ }, [directHosts, persistDirectHosts]);
+
+ const loadRemoteClients = React.useCallback(async () => {
+ if (!clientAuth) return;
+ setRemoteClientsLoading(true);
+ setRemoteClientError(null);
+ try {
+ setRemoteClients(await clientAuth.listClients());
+ } catch (err) {
+ setRemoteClientError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setRemoteClientsLoading(false);
+ }
+ }, [clientAuth]);
+
+ React.useEffect(() => {
+ void loadRemoteClients();
+ }, [loadRemoteClients]);
+
+ const createRemoteClient = React.useCallback(async () => {
+ if (!clientAuth) return;
+ setRemoteClientError(null);
+ try {
+ const result = await clientAuth.createClient({ label: remoteClientLabel.trim() || undefined });
+ setCreatedRemoteClientToken(result.token);
+ setRemoteClientLabel('');
+ await loadRemoteClients();
+ } catch (err) {
+ setRemoteClientError(err instanceof Error ? err.message : String(err));
+ }
+ }, [clientAuth, loadRemoteClients, remoteClientLabel]);
+
+ const createPairingLink = React.useCallback(async () => {
+ if (!clientAuth) return;
+ setRemoteClientError(null);
+ try {
+ const serverUrl = normalizeHostUrl(getRuntimeApiBaseUrl()) || window.location.origin;
+ const result = await clientAuth.createClient({ label: remoteClientLabel.trim() || 'Paired client' });
+ const payload = buildClientConnectionPayload({ serverUrl, token: result.token, label: remoteClientLabel || 'OpenChamber' });
+ const encoded = encodeClientConnectionPayload(payload);
+ setCreatedRemoteClientToken(result.token);
+ setPairingUrl(encoded);
+ setPairingQrDataUrl(await QRCode.toDataURL(encoded, { width: 192, margin: 1 }));
+ setRemoteClientLabel('');
+ await loadRemoteClients();
+ } catch (err) {
+ setRemoteClientError(err instanceof Error ? err.message : String(err));
+ }
+ }, [clientAuth, loadRemoteClients, remoteClientLabel]);
+
+ const revokeRemoteClient = React.useCallback(async (client: RemoteClientRecord) => {
+ if (!clientAuth) return;
+ const isLocalDesktopClient = client.clientKind === 'desktop-local';
+ setRemoteClientError(null);
+ try {
+ await clientAuth.revokeClient(client.id);
+ if (isLocalDesktopClient && isDesktopShell()) {
+ const config = await desktopHostsGet();
+ await desktopHostsSet({
+ hosts: config.hosts,
+ defaultHostId: config.defaultHostId,
+ initialHostChoiceCompleted: config.initialHostChoiceCompleted,
+ localClientToken: null,
+ });
+ setRemoteClients((clients) => clients.map((entry) => entry.id === client.id
+ ? { ...entry, revokedAt: new Date().toISOString() }
+ : entry));
+ switchRuntimeEndpoint({ apiBaseUrl: getRuntimeApiBaseUrl(), clientToken: null, runtimeKey: 'local' });
+ return;
+ }
+ await loadRemoteClients();
+ } catch (err) {
+ setRemoteClientError(err instanceof Error ? err.message : String(err));
+ }
+ }, [clientAuth, loadRemoteClients]);
+
+ const purgeRevokedRemoteClients = React.useCallback(async () => {
+ if (!clientAuth) return;
+ setRemoteClientError(null);
+ try {
+ await clientAuth.purgeRevokedClients();
+ await loadRemoteClients();
+ } catch (err) {
+ setRemoteClientError(err instanceof Error ? err.message : String(err));
+ }
+ }, [clientAuth, loadRemoteClients]);
+
React.useEffect(() => {
setDraft(selectedInstance);
}, [selectedInstance]);
@@ -674,17 +955,271 @@ export const RemoteInstancesPage: React.FC = () => {
if (!draft) {
return (
-
-
-
{t('settings.remoteInstances.page.title')}
-
{t('settings.remoteInstances.page.description')}
+ {clientAuth ? (
+
+
+
{t('settings.remoteInstances.clientAuth.title')}
+
{t('settings.remoteInstances.clientAuth.description')}
+
+
+
+ setRemoteClientLabel(event.target.value)} placeholder={t('settings.remoteInstances.clientAuth.field.labelPlaceholder')} />
+
+
+
+ {pairingUrl ? (
+
+ {pairingQrDataUrl ?

: null}
+
+
{t('settings.remoteInstances.clientAuth.pairingUrl')}
+
{pairingUrl}
+
+
+
+ ) : null}
+ {createdRemoteClientToken ? (
+
+
{t('settings.remoteInstances.clientAuth.createdToken')}
+
{createdRemoteClientToken}
+
+ ) : null}
+
+ {revokedClientCount > 0 ? (
+
+
+
+ ) : null}
+ {remoteClientsLoading ? (
+
{t('settings.remoteInstances.clientAuth.state.loading')}
+ ) : remoteClients.length === 0 ? (
+
{t('settings.remoteInstances.clientAuth.state.empty')}
+ ) : remoteClients.map((client) => {
+ const isLocalDesktopClient = client.clientKind === 'desktop-local';
+ return (
+
+
+
+
{client.label}
+ {isLocalDesktopClient ? (
+
+ {t('settings.remoteInstances.clientAuth.state.thisDevice')}
+
+ ) : null}
+
+
{client.revokedAt ? t('settings.remoteInstances.clientAuth.state.revoked') : client.lastUsedAt ? t('settings.remoteInstances.clientAuth.lastUsed', { date: client.lastUsedAt }) : t('settings.remoteInstances.clientAuth.neverUsed')}
+
+
+
+ );
+ })}
+
+ {remoteClientError ? {remoteClientError}
: null}
+
-
- {t('settings.remoteInstances.page.empty.selectInstance')}
-
-
+ ) : null}
-
+ {showInstanceManagement ?
+
+
{t('settings.remoteInstances.direct.title')}
+
{t('settings.remoteInstances.direct.description')}
+
+
+
+
{t('settings.remoteInstances.direct.note')}
+
+
+
+
+
+
+
+ {directLoading ? (
+
{t('settings.remoteInstances.direct.state.loading')}
+ ) : directHosts.length === 0 ? (
+
{t('settings.remoteInstances.direct.state.empty')}
+ ) : directHosts.map((host) => (
+
+
+
+
+
{redactSensitiveUrl(host.label)}
+ {directDefaultHostId === host.id ?
{t('desktopHostSwitcher.header.default')} : null}
+
+
{redactSensitiveUrl(host.apiUrl || host.url)}
+
+
+
+
+
+
+
+
+ ))}
+
+
+ {directError ? {directError}
: null}
+
+
: null}
+
+ {showInstanceManagement ?
: null}
+
+ {showInstanceManagement ?
: null}
+
+ {showInstanceManagement ?
: null}
+
+ {showInstanceManagement ?
+
+
+
+
{t('settings.remoteInstances.sidebar.title')}
+
{t('settings.remoteInstances.sidebar.total', { count: instances.length })}
+
+
+
+
+
+ {isLoading ? (
+ {t('settings.remoteInstances.page.import.loading')}
+ ) : instances.length === 0 ? (
+ {t('settings.remoteInstances.page.import.noneFound')}
+ ) : instances.map((instance) => {
+ const instanceStatus = statusesById[instance.id];
+ const title = instance.nickname?.trim() || instance.sshParsed?.destination || instance.id;
+ const phase = instanceStatus?.phase;
+ const ready = phase === 'ready';
+ return (
+
+
+
+
+ {t(phaseLabelKey(phase))}{instanceStatus?.localUrl ? ` · ${instanceStatus.localUrl}` : ''}
+
+
+
+
+
+
+
+
+ );
+ })}
+
+
: null}
+
+ {showInstanceManagement ?
: null}
+
+ {showInstanceManagement ?
{t('settings.remoteInstances.page.import.sectionTitle')}
@@ -694,15 +1229,15 @@ export const RemoteInstancesPage: React.FC = () => {
) : importCandidates.length === 0 ? (
{t('settings.remoteInstances.page.import.noneFound')}
) : (
-
+
{importCandidates.map((candidate) => (
-
+
-
+
{candidate.host}
{candidate.pattern ? ` ${t('settings.remoteInstances.page.import.patternSuffix')}` : ''}
-
{candidate.source} config
+
{candidate.sshCommand}
))}
)}
-
+
: null}