Decouple bundled UI from runtime API and add remote instance tooling (#1228)

Add a packaged-client runtime boundary so the shared UI can talk to local,
desktop, remote, and VS Code runtimes through the right transport instead of
assuming one same-origin web server.

Centralize OpenChamber-owned API access behind RuntimeAPIs, runtimeFetch, and
runtime URL helpers, while keeping official OpenCode traffic on the SDK path.
Support runtime switching, remote host selection, desktop client credentials,
and headless connection links for pairing packaged clients with remote
OpenChamber servers.

Harden the new auth model by moving long-lived client tokens out of browser
URLs, introducing short-lived scoped URL tokens for browser-owned transports,
restricting URL-token access to explicit readable/realtime routes, and making
client-token management session-scoped or self-scoped as appropriate.

Update browser-owned assets and preview proxy flows to work with the split
runtime model, including authenticated project icons, preview token propagation,
CSP-safe preview bridge injection, and preview proxy auth that survives
short-lived URL-token expiry.

Tighten Electron security boundaries for packaged clients by gating privileged
preload state to trusted origins and requiring explicit confirmation before
connect deep-links import or switch remote runtimes.

Also refresh agent guidance and project skills so future runtime/API, auth,
preview, UI, CLI, settings, locale, and drag-to-reorder work follows the new
architecture.
This commit is contained in:
Bohdan Triapitsyn
2026-06-02 00:43:05 +03:00
committed by GitHub
parent a4314c189b
commit 2031e3b4a8
282 changed files with 16524 additions and 4259 deletions
@@ -11,6 +11,9 @@ import { DesktopHostSwitcherInline } from '@/components/desktop/DesktopHostSwitc
import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
import { Icon } from "@/components/icon/Icon";
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { getRuntimeApiBaseUrl, getRuntimeKey, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch';
import { desktopHostsGet, desktopHostsSet, getDesktopHostApiUrl, normalizeHostUrl } from '@/lib/desktopHosts';
import {
authenticateWithPasskey,
cancelPasskeyCeremony,
@@ -23,9 +26,47 @@ import {
const STATUS_CHECK_ENDPOINT = '/auth/session';
const TRUST_DEVICE_STORAGE_KEY = 'openchamber.uiAuth.trustDevice';
const LOCAL_DESKTOP_CLIENT_KIND = 'desktop-local';
const LOCAL_DESKTOP_CLIENT_DEDUPE_KEY = 'desktop-local';
const readLocalOrigin = (): string => {
if (typeof window === 'undefined') return '';
const injected = (window as typeof window & { __OPENCHAMBER_LOCAL_ORIGIN__?: string }).__OPENCHAMBER_LOCAL_ORIGIN__;
return typeof injected === 'string' ? injected.trim() : '';
};
const sameOrigin = (left: string, right: string): boolean => {
const normalizedLeft = normalizeHostUrl(left);
const normalizedRight = normalizeHostUrl(right);
if (!normalizedLeft || !normalizedRight) return false;
try {
return new URL(normalizedLeft).origin === new URL(normalizedRight).origin;
} catch {
return false;
}
};
const shouldIssueDesktopClientToken = (): boolean => {
return isDesktopShell();
};
const isLocalDesktopRuntime = (): boolean => {
if (!isDesktopShell()) return false;
const apiBaseUrl = getRuntimeApiBaseUrl();
const localOrigin = readLocalOrigin();
return Boolean(localOrigin && sameOrigin(localOrigin, apiBaseUrl));
};
const desktopClientAuthMetadata = (): { clientKind?: string; dedupeKey?: string } => {
if (!isLocalDesktopRuntime()) return {};
return {
clientKind: LOCAL_DESKTOP_CLIENT_KIND,
dedupeKey: LOCAL_DESKTOP_CLIENT_DEDUPE_KEY,
};
};
const fetchSessionStatus = async (): Promise<Response> => {
const response = await fetch(STATUS_CHECK_ENDPOINT, {
const response = await runtimeFetch(STATUS_CHECK_ENDPOINT, {
method: 'GET',
credentials: 'include',
headers: {
@@ -43,18 +84,106 @@ const readStoredTrustDevice = (): boolean => {
};
const submitPassword = async (password: string, trustDevice: boolean): Promise<Response> => {
const response = await fetch(STATUS_CHECK_ENDPOINT, {
const issueClientToken = shouldIssueDesktopClientToken();
const response = await runtimeFetch(STATUS_CHECK_ENDPOINT, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ password, trustDevice }),
body: JSON.stringify({
password,
trustDevice,
issueClientToken,
clientLabel: 'OpenChamber Desktop',
...desktopClientAuthMetadata(),
}),
});
return response;
};
const issueDesktopClientToken = async (): Promise<string> => {
if (!isDesktopShell()) {
return '';
}
const response = await runtimeFetch('/api/client-auth/clients', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ label: 'OpenChamber Desktop', ...desktopClientAuthMetadata() }),
}).catch(() => null);
if (!response?.ok) {
return '';
}
const payload = await response.json().catch(() => null) as { token?: unknown } | null;
return typeof payload?.token === 'string' ? payload.token.trim() : '';
};
const issueDesktopClientTokenViaShell = async (password: string, trustDevice: boolean): Promise<string> => {
if (!isDesktopShell() || typeof window === 'undefined') {
return '';
}
const invoke = (window as unknown as { __TAURI__?: { core?: { invoke?: (cmd: string, args?: Record<string, unknown>) => Promise<unknown> } } }).__TAURI__?.core?.invoke;
if (typeof invoke !== 'function') {
return '';
}
const response = await invoke('desktop_remote_password_login', {
url: getRuntimeApiBaseUrl(),
password,
trustDevice,
}).catch(() => null);
if (!response || typeof response !== 'object') {
return '';
}
const token = (response as { token?: unknown }).token;
return typeof token === 'string' ? token.trim() : '';
};
const persistDesktopClientToken = async (apiBaseUrl: string, clientToken: string): Promise<void> => {
if (!isDesktopShell() || !clientToken) return;
const cfg = await desktopHostsGet().catch(() => null);
if (!cfg) return;
if (cfg.localOrigin && sameOrigin(cfg.localOrigin, apiBaseUrl)) {
await desktopHostsSet({
hosts: cfg.hosts,
defaultHostId: cfg.defaultHostId,
initialHostChoiceCompleted: cfg.initialHostChoiceCompleted,
localClientToken: clientToken,
}).catch(() => undefined);
return;
}
let changed = false;
const hosts = cfg.hosts.map((host) => {
if (!sameOrigin(getDesktopHostApiUrl(host), apiBaseUrl)) {
return host;
}
if (host.clientToken === clientToken) {
return host;
}
changed = true;
return { ...host, clientToken };
});
if (!changed) return;
await desktopHostsSet({
hosts,
defaultHostId: cfg.defaultHostId,
initialHostChoiceCompleted: cfg.initialHostChoiceCompleted,
}).catch(() => undefined);
};
const applyDesktopClientToken = async (clientToken: string): Promise<void> => {
if (!clientToken) return;
const apiBaseUrl = getRuntimeApiBaseUrl();
await persistDesktopClientToken(apiBaseUrl, clientToken);
switchRuntimeEndpoint({ apiBaseUrl, clientToken, runtimeKey: getRuntimeKey() });
};
const AuthShell: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const titlebarDragStyle = React.useMemo<React.CSSProperties>(() => {
return {
@@ -268,6 +397,21 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
void checkStatus();
}, [checkStatus, skipAuth]);
React.useEffect(() => {
if (skipAuth) {
return;
}
return subscribeRuntimeEndpointChanged(() => {
setPassword('');
setErrorMessage('');
setRetryAfter(undefined);
setIsTunnelLocked(false);
setState('pending');
void checkStatus();
});
}, [checkStatus, skipAuth]);
React.useEffect(() => {
if (!skipAuth && state === 'locked') {
hasResyncedRef.current = false;
@@ -336,8 +480,18 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
try {
const response = await submitPassword(password, trustDevice);
if (response.ok) {
const payload = await response.json().catch(() => null) as { clientToken?: unknown } | null;
const shouldUseClientToken = shouldIssueDesktopClientToken();
const clientToken = shouldUseClientToken
? (typeof payload?.clientToken === 'string' && payload.clientToken.trim()
? payload.clientToken.trim()
: await issueDesktopClientTokenViaShell(password, trustDevice) || await issueDesktopClientToken())
: '';
setPassword('');
setIsTunnelLocked(false);
if (clientToken) {
await applyDesktopClientToken(clientToken);
}
if (enrollPasskey && supportsPasskeys) {
try {
await registerPasskeyForCurrentSession();
@@ -402,7 +556,17 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
setErrorMessage('');
try {
await authenticateWithPasskey(trustDevice);
const payload = await authenticateWithPasskey(trustDevice, {
issueClientToken: shouldIssueDesktopClientToken(),
clientLabel: 'OpenChamber Desktop',
...desktopClientAuthMetadata(),
}) as { clientToken?: unknown } | null;
const clientToken = shouldIssueDesktopClientToken() && typeof payload?.clientToken === 'string' && payload.clientToken.trim()
? payload.clientToken.trim()
: '';
if (clientToken) {
await applyDesktopClientToken(clientToken);
}
setPassword('');
setState('authenticated');
@@ -142,10 +142,8 @@ type ChatViewportProps = {
stickyUserHeader: boolean;
scrollRef: React.RefObject<HTMLDivElement | null>;
messageListRef: React.RefObject<MessageListHandle | null>;
turnStart: number;
pendingRevealWork: boolean;
renderedMessages: SessionMessageRecord[];
hasMoreAboveTurns: boolean;
isLoadingOlder: boolean;
sessionIsWorking: boolean;
streamingMessageId: string | null;
@@ -158,7 +156,6 @@ type ChatViewportProps = {
} | null;
handleMessageContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers;
handleLoadOlder: () => void;
handleHistoryScroll: () => void;
scrollToBottom: () => void;
sessionQuestions: QuestionRequest[];
@@ -173,10 +170,8 @@ const ChatViewport = React.memo(({
stickyUserHeader,
scrollRef,
messageListRef,
turnStart,
pendingRevealWork,
renderedMessages,
hasMoreAboveTurns,
isLoadingOlder,
sessionIsWorking,
streamingMessageId,
@@ -184,7 +179,6 @@ const ChatViewport = React.memo(({
retryOverlay,
handleMessageContentChange,
getAnimationHandlers,
handleLoadOlder,
handleHistoryScroll,
scrollToBottom,
sessionQuestions,
@@ -230,7 +224,6 @@ const ChatViewport = React.memo(({
<MessageList
ref={messageListRef}
sessionKey={currentSessionId}
turnStart={turnStart}
disableStaging={pendingRevealWork}
messages={renderedMessages}
sessionIsWorking={sessionIsWorking}
@@ -239,9 +232,7 @@ const ChatViewport = React.memo(({
retryOverlay={retryOverlay}
onMessageContentChange={handleMessageContentChange}
getAnimationHandlers={getAnimationHandlers}
hasMoreAbove={hasMoreAboveTurns}
isLoadingOlder={isLoadingOlder}
onLoadOlder={handleLoadOlder}
scrollToBottom={scrollToBottom}
scrollRef={scrollRef}
/>
@@ -274,10 +265,8 @@ const ChatViewport = React.memo(({
&& prev.stickyUserHeader === next.stickyUserHeader
&& prev.scrollRef === next.scrollRef
&& prev.messageListRef === next.messageListRef
&& prev.turnStart === next.turnStart
&& prev.pendingRevealWork === next.pendingRevealWork
&& prev.renderedMessages === next.renderedMessages
&& prev.hasMoreAboveTurns === next.hasMoreAboveTurns
&& prev.isLoadingOlder === next.isLoadingOlder
&& prev.sessionIsWorking === next.sessionIsWorking
&& prev.streamingMessageId === next.streamingMessageId
@@ -285,7 +274,6 @@ const ChatViewport = React.memo(({
&& prev.retryOverlay === next.retryOverlay
&& prev.handleMessageContentChange === next.handleMessageContentChange
&& prev.getAnimationHandlers === next.getAnimationHandlers
&& prev.handleLoadOlder === next.handleLoadOlder
&& prev.handleHistoryScroll === next.handleHistoryScroll
&& prev.scrollToBottom === next.scrollToBottom
&& prev.sessionQuestions === next.sessionQuestions
@@ -645,8 +633,6 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
isPinned,
showScrollButton,
});
const { loadEarlier } = timelineController;
const resumeToLatestInstant = React.useCallback(() => {
goToBottom('instant');
}, [goToBottom]);
@@ -662,10 +648,6 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
handleMessageContentChange('permission');
}, [handleMessageContentChange, sessionPermissions, sessionQuestions]);
const handleLoadOlder = React.useCallback(() => {
void loadEarlier({ userInitiated: true });
}, [loadEarlier]);
const navigation = useChatTurnNavigation({
sessionId: currentSessionId,
turnIds: timelineController.turnIds,
@@ -957,10 +939,8 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
stickyUserHeader={stickyUserHeader}
scrollRef={scrollRef}
messageListRef={messageListRef}
turnStart={timelineController.turnStart}
pendingRevealWork={timelineController.pendingRevealWork}
renderedMessages={timelineController.renderedMessages}
hasMoreAboveTurns={timelineController.historySignals.hasMoreAboveTurns}
isLoadingOlder={timelineController.isLoadingOlder}
sessionIsWorking={sessionIsWorking}
streamingMessageId={streamingMessageId}
@@ -968,7 +948,6 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
retryOverlay={retryOverlay}
handleMessageContentChange={handleMessageContentChange}
getAnimationHandlers={getAnimationHandlers}
handleLoadOlder={handleLoadOlder}
handleHistoryScroll={timelineController.handleHistoryScroll}
scrollToBottom={resumeToLatestInstant}
sessionQuestions={sessionQuestions}
+44 -31
View File
@@ -31,12 +31,13 @@ import { PendingChangesBar } from './PendingChangesBar';
import { useChatSurfaceMode } from './useChatSurfaceMode';
import { MobileAgentButton } from './MobileAgentButton';
import { MobileModelButton } from './MobileModelButton';
import { MobileSessionStatusBar } from './MobileSessionStatusBar';
import { MobileSessionStatusBar, MobileSessionPanelTrigger } from './MobileSessionStatusBar';
import { useCurrentSessionActivity } from '@/hooks/useSessionActivity';
import { toast } from '@/components/ui';
import { Button } from '@/components/ui/button';
// useMessageStore removed — messages now come from sync system
import { isTauriShell, isVSCodeRuntime } from '@/lib/desktop';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { isIMECompositionEvent } from '@/lib/ime';
import { StopIcon } from '@/components/icons/StopIcon';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
@@ -56,7 +57,7 @@ import { DraftPresetChips } from './DraftPresetChips';
import { useChatSearchDirectory } from '@/hooks/useChatSearchDirectory';
import { opencodeClient } from '@/lib/opencode/client';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta';
import { useGitBranches, useGitStore, useIsGitRepo } from '@/stores/useGitStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useSkillsStore } from '@/stores/useSkillsStore';
@@ -1030,11 +1031,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const isExpandedInput = useUIStore((state) => state.isExpandedInput);
const setExpandedInput = useUIStore((state) => state.setExpandedInput);
const setTimelineDialogOpen = useUIStore((state) => state.setTimelineDialogOpen);
const { git: runtimeGit, vscode: vscodeApi } = useRuntimeAPIs();
const cycleAgentShortcutOverride = useUIStore((state) => state.shortcutOverrides.cycle_agent);
const cycleAgentShortcut = React.useMemo(() => (
getEffectiveShortcutCombo('cycle_agent', cycleAgentShortcutOverride ? { cycle_agent: cycleAgentShortcutOverride } : undefined)
), [cycleAgentShortcutOverride]);
const { git: runtimeGit } = useRuntimeAPIs();
const { currentTheme } = useThemeSystem();
const chatSearchDirectory = useChatSearchDirectory();
const isGitRepo = useIsGitRepo(currentDirectory);
@@ -1869,14 +1870,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
else if (commandName === 'compact' && currentSessionId) {
try {
await sessionActions.waitForConnectionOrThrow();
const { opencodeClient } = await import('@/lib/opencode/client');
const sdk = opencodeClient.getSdkClient();
const configState = useConfigStore.getState();
await sdk.session.summarize({
sessionID: currentSessionId,
modelID: configState.currentModelId || '',
providerID: configState.currentProviderId || '',
});
const compactDirectory = useSessionUIStore.getState().getDirectoryForSession(currentSessionId) || currentDirectory || undefined;
await opencodeClient.summarizeSession(currentSessionId, currentProviderId, currentModelId, compactDirectory);
} catch (error) {
toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.compactFailed'));
}
@@ -2722,7 +2717,17 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
} else {
setShowFileMention(false);
}
}, [inputMode, setCommandQuery, setMentionQuery, setShowCommandAutocomplete, setShowFileMention, setShowSkillAutocomplete, setSkillQuery]);
}, [
inputMode,
setCommandQuery,
setMentionQuery,
setShowCommandAutocomplete,
setShowFileMention,
setShowSkillAutocomplete,
setShowSnippetAutocomplete,
setSkillQuery,
setSnippetQuery,
]);
const insertTextAtSelection = React.useCallback((text: string) => {
if (!text) {
@@ -3469,7 +3474,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const blob = new Blob([byteArray], { type: result.mime || 'application/octet-stream' });
file = new File([blob], fileName, { type: result.mime || 'application/octet-stream' });
} else {
const response = await fetch(`/api/fs/raw?path=${encodeURIComponent(normalizedPath)}`);
const response = await runtimeFetch('/api/fs/raw', { query: { path: normalizedPath } });
if (!response.ok) {
throw new Error(`Failed to read dropped file (${response.status})`);
}
@@ -3523,8 +3528,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const handleVSCodePickFiles = React.useCallback(async () => {
try {
const response = await fetch('/api/vscode/pick-files');
const data = await response.json();
const data = (await vscodeApi?.pickFiles?.()) as {
files?: Array<{ name: string; mimeType?: string; dataUrl?: string }>;
skipped?: Array<{ name?: string; reason?: string }>;
} | undefined;
const picked = Array.isArray(data?.files) ? data.files : [];
const skipped = Array.isArray(data?.skipped) ? data.skipped : [];
@@ -3563,7 +3570,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
console.error('VS Code file pick failed', error);
toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.vscodePickFailed'));
}
}, [attachFiles, t]);
}, [attachFiles, t, vscodeApi]);
const handlePickLocalFiles = React.useCallback(() => {
if (isVSCodeRuntime()) {
@@ -3823,30 +3830,32 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
iconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' } | null;
iconBackground?: string | null;
}) => {
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 = getProjectIconColor(project.color);
const fallbackIcon = projectIconName ? (
<Icon name={projectIconName} className="h-3.5 w-3.5 shrink-0" style={iconColor ? { color: iconColor } : undefined} />
) : (
<Icon name="folder" className="h-3.5 w-3.5 shrink-0 text-muted-foreground/80" style={iconColor ? { color: iconColor } : undefined}/>
);
return (
<span className="inline-flex min-w-0 items-center gap-1.5">
{imageUrl ? (
{project.iconImage ? (
<span
className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center overflow-hidden rounded-[3px]"
style={project.iconBackground ? { backgroundColor: project.iconBackground } : undefined}
>
<img src={imageUrl} alt="" className="h-full w-full object-contain" draggable={false} />
<ProjectIconImage
project={{ id: project.id, iconImage: project.iconImage ?? null }}
options={{
themeVariant: currentTheme.metadata.variant,
iconColor: currentTheme.colors.surface.foreground,
}}
className="h-full w-full object-contain"
fallback={fallbackIcon}
/>
</span>
) : projectIconName ? (
<Icon name={projectIconName} className="h-3.5 w-3.5 shrink-0" style={iconColor ? { color: iconColor } : undefined} />
) : (
<Icon name="folder" className="h-3.5 w-3.5 shrink-0 text-muted-foreground/80" style={iconColor ? { color: iconColor } : undefined}/>
)}
) : fallbackIcon}
<span className="truncate">{getProjectDisplayLabel(project)}</span>
</span>
);
@@ -4426,6 +4435,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
<>
<div className="flex w-full items-center justify-between gap-x-1.5">
<div className="flex items-center gap-x-1.5">
<MobileSessionPanelTrigger
footerIconButtonClass={footerIconButtonClass}
iconSizeClass={iconSizeClass}
/>
<ComposerAttachmentControls
isVSCode={isVSCode}
footerIconButtonClass={footerIconButtonClass}
@@ -4530,7 +4543,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
)}
</div>
{/* Mobile Session Status Bar - above input */}
{/* Mobile session panel: slide-up overlay toggled by MobileSessionPanelTrigger. */}
{isMobile && <MobileSessionStatusBar />}
</div>
</div>
@@ -6,7 +6,7 @@ import { toast } from '@/components/ui';
import { cn } from '@/lib/utils';
import { openExternalUrl } from '@/lib/url';
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
import { useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
import { Icon } from "@/components/icon/Icon";
import { useI18n } from '@/lib/i18n';
@@ -19,7 +19,8 @@ export const FileAttachmentButton = memo(() => {
const fileInputRef = useRef<HTMLInputElement>(null);
const addAttachedFile = useInputStore((state) => state.addAttachedFile);
const isMobile = useUIStore((state) => state.isMobile);
const isVSCodeRuntime = useIsVSCodeRuntime();
const runtimeApis = useRuntimeAPIs();
const isVSCodeRuntime = runtimeApis.runtime.isVSCode;
const buttonSizeClass = isMobile ? 'h-9 w-9' : 'h-7 w-7';
const iconSizeClass = isMobile ? 'h-5 w-5' : 'h-[18px] w-[18px]';
@@ -47,8 +48,10 @@ export const FileAttachmentButton = memo(() => {
const handleVSCodePick = async () => {
try {
const response = await fetch('/api/vscode/pick-files');
const data = await response.json();
const data = (await runtimeApis.vscode?.pickFiles?.()) as {
files?: Array<{ name: string; mimeType?: string; dataUrl?: string }>;
skipped?: Array<{ name?: string; reason?: string }>;
} | undefined;
const picked = Array.isArray(data?.files) ? data.files : [];
const skipped = Array.isArray(data?.skipped) ? data.skipped : [];
@@ -449,7 +452,7 @@ export const ActiveEditorFileSuggestion = memo(() => {
const attachedFiles = useInputStore((s) => s.attachedFiles)
const addVSCodeFileAttachment = useInputStore((s) => s.addVSCodeFileAttachment)
const addVSCodeSelectionAttachment = useInputStore((s) => s.addVSCodeSelectionAttachment)
const isVSCodeRuntime = useIsVSCodeRuntime();
const isVSCodeRuntime = useRuntimeAPIs().runtime.isVSCode;
if (!isVSCodeRuntime || !activeEditorFile) return null;
@@ -16,6 +16,7 @@ import { toast } from '@/components/ui';
import { Icon } from "@/components/icon/Icon";
import { copyTextToClipboard } from '@/lib/clipboard';
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { getExternalFaviconUrl, isExternalHttpUrl, isLoopbackHttpUrl, openExternalUrl } from '@/lib/url';
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
@@ -1341,7 +1342,7 @@ const fileReferenceExists = (resolvedPath: string): Promise<boolean> => {
const request = new Promise<boolean>((resolve) => {
const run = () => {
activeFileReferenceStatCount += 1;
void fetch(`/api/fs/stat?path=${encodeURIComponent(normalizedPath)}`, {
void runtimeFetch(`/api/fs/stat?path=${encodeURIComponent(normalizedPath)}`, {
method: 'GET',
cache: 'no-store',
})
@@ -391,7 +391,6 @@ const getNormalizedMessageForDisplay = (message: ChatMessageEntry): ChatMessageE
interface MessageListProps {
sessionKey: string;
turnStart: number;
disableStaging?: boolean;
messages: ChatMessageEntry[];
sessionIsWorking?: boolean;
@@ -405,9 +404,7 @@ interface MessageListProps {
} | null;
onMessageContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers;
hasMoreAbove: boolean;
isLoadingOlder: boolean;
onLoadOlder: () => void;
scrollToBottom?: () => void;
scrollRef?: React.RefObject<HTMLDivElement | null>;
}
@@ -1101,7 +1098,6 @@ StreamingTailContent.displayName = 'StreamingTailContent';
const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
sessionKey,
turnStart,
disableStaging = false,
messages,
sessionIsWorking = false,
@@ -1110,9 +1106,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
retryOverlay = null,
onMessageContentChange,
getAnimationHandlers,
hasMoreAbove,
isLoadingOlder,
onLoadOlder,
scrollToBottom,
scrollRef,
}, ref) => {
@@ -1128,7 +1122,6 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
animatedIds: Set<string>;
}>({ sessionKey: undefined, previousOrder: [], animatedIds: new Set() });
const stableGetAnimationHandlers = useStableEvent(getAnimationHandlers);
const stableOnLoadOlder = useStableEvent(onLoadOlder);
const stableScrollToBottom = useStableEvent(() => {
scrollToBottom?.();
});
@@ -1675,24 +1668,6 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
return (
<div>
{(turnStart > 0 || hasMoreAbove) && (
<div className="flex justify-center py-3">
{isLoadingOlder ? (
<span className="text-xs uppercase tracking-wide text-muted-foreground/80">
Loading
</span>
) : (
<button
type="button"
onClick={stableOnLoadOlder}
className="text-xs uppercase tracking-wide text-muted-foreground/80 hover:text-foreground"
>
Load older messages
</button>
)}
</div>
)}
<FadeInDisabledProvider disabled={disableFadeIn}>
<div className="relative w-full">
<StaticHistoryList
File diff suppressed because it is too large Load Diff
@@ -3,6 +3,7 @@ import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useGitStore, useIsGitRepo } from '@/stores/useGitStore';
import { useUIStore } from '@/stores/useUIStore';
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
import { useMobileAppActions } from '@/apps/mobileAppContext';
import { sessionEvents } from '@/lib/sessionEvents';
import { normalizePath } from '@/components/session/sidebar/utils';
import { Icon } from "@/components/icon/Icon";
@@ -29,6 +30,7 @@ export const PendingChangesBar: React.FC = React.memo(() => {
);
const ensureStatus = useGitStore((s) => s.ensureStatus);
const fetchStatus = useGitStore((s) => s.fetchStatus);
const mobileActions = useMobileAppActions();
// Close popover when clicking outside
React.useEffect(() => {
@@ -90,6 +92,16 @@ export const PendingChangesBar: React.FC = React.memo(() => {
? file.path
: (currentDirectory.endsWith('/') ? currentDirectory : currentDirectory + '/') + file.path;
// Dedicated mobile root: open the per-file diff inside the mobile Changes surface.
if (mobileActions) {
mobileActions.openChanges({
diffPath: file.relativePath,
staged: file.hasStagedChanges && !file.hasWorkingChanges,
});
setIsExpanded(false);
return;
}
const editor = runtime?.editor;
if (editor) {
void editor.openFile(absolutePath);
@@ -0,0 +1,41 @@
import { describe, expect, test } from 'bun:test';
import { shouldAutoLoadEarlierForUnderfilledPinnedViewport } from './useChatTimelineController';
const baseInput = {
sessionId: 'ses_1',
isPinned: true,
canLoadEarlier: true,
isLoadingOlder: false,
pendingRevealWork: false,
scrollHeight: 799,
clientHeight: 800,
};
describe('shouldAutoLoadEarlierForUnderfilledPinnedViewport', () => {
test('loads when pinned content does not fill the viewport', () => {
expect(shouldAutoLoadEarlierForUnderfilledPinnedViewport(baseInput)).toBe(true);
});
test('does not load when content already overflows', () => {
expect(shouldAutoLoadEarlierForUnderfilledPinnedViewport({
...baseInput,
scrollHeight: 802,
})).toBe(false);
});
test('does not load while user is away from bottom or history work is active', () => {
expect(shouldAutoLoadEarlierForUnderfilledPinnedViewport({
...baseInput,
isPinned: false,
})).toBe(false);
expect(shouldAutoLoadEarlierForUnderfilledPinnedViewport({
...baseInput,
isLoadingOlder: true,
})).toBe(false);
expect(shouldAutoLoadEarlierForUnderfilledPinnedViewport({
...baseInput,
pendingRevealWork: true,
})).toBe(false);
});
});
@@ -97,6 +97,21 @@ const rememberTurnModel = (key: string, value: { messages: ChatMessageEntry[]; m
turnModelCache.set(key, value)
}
export const shouldAutoLoadEarlierForUnderfilledPinnedViewport = (input: {
sessionId: string | null;
isPinned: boolean;
canLoadEarlier: boolean;
isLoadingOlder: boolean;
pendingRevealWork: boolean;
scrollHeight: number;
clientHeight: number;
}): boolean => {
if (!input.sessionId) return false;
if (!input.isPinned || !input.canLoadEarlier) return false;
if (input.isLoadingOlder || input.pendingRevealWork) return false;
return input.scrollHeight <= input.clientHeight + 1;
};
export const useChatTimelineController = ({
sessionId,
messages,
@@ -524,26 +539,32 @@ export const useChatTimelineController = ({
void loadEarlier({ userInitiated: true });
}, [loadEarlier, scrollRef]);
const loadEarlierIfPinnedViewportUnderfilled = React.useCallback(() => {
if (historyInteractionRef.current) return;
const container = scrollRef.current;
if (!container) return;
if (!shouldAutoLoadEarlierForUnderfilledPinnedViewport({
sessionId: sessionIdRef.current,
isPinned: isPinnedRef.current,
canLoadEarlier: historySignalsRef.current.canLoadEarlier,
isLoadingOlder: isLoadingOlderRef.current,
pendingRevealWork: pendingRevealWorkRef.current,
scrollHeight: container.scrollHeight,
clientHeight: container.clientHeight,
})) {
return;
}
void loadEarlier();
}, [loadEarlier, scrollRef]);
React.useEffect(() => {
if (!sessionId || isLoadingOlder || pendingRevealWork) {
return;
}
if (!isPinned || !historySignals.canLoadEarlier) {
return;
}
if (typeof window === 'undefined') {
return;
}
const frame = window.requestAnimationFrame(() => {
const container = scrollRef.current;
if (!container) return;
if (!isPinnedRef.current) return;
if (!historySignalsRef.current.canLoadEarlier) return;
if (isLoadingOlderRef.current || pendingRevealWorkRef.current) return;
if (container.scrollHeight > container.clientHeight + 1) return;
void loadEarlier();
loadEarlierIfPinnedViewportUnderfilled();
});
return () => window.cancelAnimationFrame(frame);
@@ -551,13 +572,49 @@ export const useChatTimelineController = ({
historySignals.canLoadEarlier,
isLoadingOlder,
isPinned,
loadEarlier,
loadEarlierIfPinnedViewportUnderfilled,
pendingRevealWork,
renderedMessages.length,
scrollRef,
sessionId,
]);
React.useEffect(() => {
if (typeof window === 'undefined' || typeof ResizeObserver === 'undefined') {
return;
}
const container = scrollRef.current;
if (!container) {
return;
}
let frame: number | null = null;
const scheduleCheck = () => {
if (frame !== null) {
return;
}
frame = window.requestAnimationFrame(() => {
frame = null;
loadEarlierIfPinnedViewportUnderfilled();
});
};
const observer = new ResizeObserver(scheduleCheck);
observer.observe(container);
const content = container.firstElementChild;
if (content instanceof Element) {
observer.observe(content);
}
scheduleCheck();
return () => {
if (frame !== null) {
window.cancelAnimationFrame(frame);
}
observer.disconnect();
};
}, [loadEarlierIfPinnedViewportUnderfilled, scrollRef, sessionId]);
const scrollToTurn = React.useCallback(async (
turnId: string,
options?: { behavior?: ScrollBehavior },
@@ -31,6 +31,7 @@ import { TextSelectionMenu } from './TextSelectionMenu';
import { copyTextToClipboard } from '@/lib/clipboard';
import { useChatSurfaceMode } from '@/components/chat/useChatSurfaceMode';
import { isVSCodeRuntime } from '@/lib/desktop';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { toPng } from 'html-to-image';
import { toast } from '@/components/ui';
import { Icon } from "@/components/icon/Icon";
@@ -1034,6 +1035,7 @@ const AssistantMessageBody = React.memo(({
const collapsibleThinkingBlocks = useUIStore((state) => state.collapsibleThinkingBlocks);
const groupReasoningBlocks = useUIStore((state) => state.groupReasoningBlocks);
const showSplitAssistantMessageActions = useUIStore((state) => state.showSplitAssistantMessageActions);
const vscodeApi = useRuntimeAPIs().vscode;
const isSortedRenderMode = chatRenderMode === 'sorted';
const collapsedPreviewCount = 7;
const isLastAssistantInTurn = turnGroupingContext?.isLastAssistantInTurn ?? false;
@@ -1319,17 +1321,10 @@ const AssistantMessageBody = React.memo(({
const fileName = `message-${messageId}.png`;
if (isVSCodeRuntime()) {
const response = await fetch('/api/vscode/save-image', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileName, dataUrl }),
});
if (!response.ok) {
const payload = await vscodeApi?.saveImage?.({ fileName, dataUrl }) as { saved?: boolean; canceled?: boolean; error?: string } | undefined;
if (!payload) {
throw new Error('Failed to save image in VS Code');
}
const payload = await response.json() as { saved?: boolean; canceled?: boolean; error?: string };
if (payload.saved !== true) {
if (payload.canceled) {
return;
@@ -1355,7 +1350,7 @@ const AssistantMessageBody = React.memo(({
}
}
},
[messageId, t]
[messageId, t, vscodeApi]
);
const activityPartsForTurn = React.useMemo(() => {
@@ -27,6 +27,7 @@ import { VirtualizedCodeBlock, type CodeLine } from './parts/VirtualizedCodeBloc
import { JsonTreeView } from '@/components/ui/JsonTreeView';
import { Icon } from "@/components/icon/Icon";
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
interface ToolOutputDialogProps {
popup: ToolPopupContent;
@@ -739,7 +740,7 @@ const MermaidPreviewDialog: React.FC<{
if (!normalizedPath) {
sourcePromise = Promise.reject(new Error('Invalid local file path for Mermaid preview.'));
} else {
sourcePromise = fetch(`/api/fs/raw?path=${encodeURIComponent(normalizedPath)}`)
sourcePromise = runtimeFetch('/api/fs/raw', { query: { path: normalizedPath } })
.then((response) => {
if (!response.ok) {
return Promise.reject(new Error(`Failed to read diagram file (${response.status})`));
@@ -9,29 +9,27 @@ import {
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
import { toast } from '@/components/ui';
import { isElectronShell, isTauriShell, isDesktopShell } from '@/lib/desktop';
import { Icon } from "@/components/icon/Icon";
import { isTauriShell, isDesktopShell } from '@/lib/desktop';
import { useUIStore } from '@/stores/useUIStore';
import { useI18n } from '@/lib/i18n';
import {
desktopHostProbe,
desktopHostsGet,
desktopHostsSet,
desktopLocalClientTokenGet,
desktopOpenNewWindowAtUrl,
getDesktopHostApiUrl,
locationMatchesHost,
normalizeHostUrl,
redactSensitiveUrl,
resolveDesktopHostUrl,
type DesktopHost,
type HostProbeResult,
} from '@/lib/desktopHosts';
import { getRuntimeApiBaseUrl, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch';
import {
desktopSshConnect,
desktopSshDisconnect,
@@ -44,11 +42,18 @@ const LOCAL_HOST_ID = 'local';
const SSH_CONNECT_TIMEOUT_MS = 90_000;
const SSH_CONNECT_CANCELLED_ERROR = 'SSH connection cancelled';
const runtimeKeyForHost = (host: DesktopHost): string => {
if (host.id === LOCAL_HOST_ID) return 'local';
return `host:${host.id}`;
};
type HostStatus = {
status: HostProbeResult['status'];
latencyMs: number;
};
type HostDisplayStatus = HostProbeResult['status'] | 'checking' | null;
const toNavigationUrl = (rawUrl: string): string => {
const normalized = normalizeHostUrl(rawUrl);
if (!normalized) {
@@ -71,37 +76,55 @@ const getLocalOrigin = (): string => {
return window.__OPENCHAMBER_LOCAL_ORIGIN__ || window.location.origin;
};
const makeId = (): string => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `host-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const getLocalClientToken = async (): Promise<string> => {
if (!isElectronShell()) return '';
return desktopLocalClientTokenGet().catch(() => '');
};
const statusDotClass = (status: HostProbeResult['status'] | null): string => {
const statusDotClass = (status: HostDisplayStatus): string => {
if (status === 'ok') return 'bg-status-success';
if (status === 'auth') return 'bg-status-warning';
if (status === 'update-recommended') return 'bg-status-warning';
if (status === 'incompatible') return 'bg-status-error';
if (status === 'wrong-service') return 'bg-status-error';
if (status === 'unreachable') return 'bg-status-error';
if (status === 'checking') return 'bg-status-info';
return 'bg-muted-foreground/40';
};
const statusLabelKey = (status: HostProbeResult['status'] | null):
const isBlockedHostStatus = (status: HostProbeResult['status'] | null): boolean => {
return status === 'unreachable' || status === 'wrong-service' || status === 'incompatible';
};
const isBlockedDisplayStatus = (status: HostDisplayStatus): boolean => {
return status === 'unreachable' || status === 'wrong-service' || status === 'incompatible';
};
const statusLabelKey = (status: HostDisplayStatus):
| 'desktopHostSwitcher.status.connected'
| 'desktopHostSwitcher.status.authRequired'
| 'desktopHostSwitcher.status.checking'
| 'desktopHostSwitcher.status.updateRecommended'
| 'desktopHostSwitcher.status.incompatible'
| 'desktopHostSwitcher.status.wrongService'
| 'desktopHostSwitcher.status.unreachable'
| 'desktopHostSwitcher.status.unknown' => {
if (status === 'ok') return 'desktopHostSwitcher.status.connected';
if (status === 'auth') return 'desktopHostSwitcher.status.authRequired';
if (status === 'checking') return 'desktopHostSwitcher.status.checking';
if (status === 'update-recommended') return 'desktopHostSwitcher.status.updateRecommended';
if (status === 'incompatible') return 'desktopHostSwitcher.status.incompatible';
if (status === 'wrong-service') return 'desktopHostSwitcher.status.wrongService';
if (status === 'unreachable') return 'desktopHostSwitcher.status.unreachable';
return 'desktopHostSwitcher.status.unknown';
};
const statusIcon = (status: HostProbeResult['status'] | null) => {
const statusIcon = (status: HostDisplayStatus) => {
if (status === 'checking') return <Icon name="loader-4" className="h-4 w-4 animate-spin" />;
if (status === 'ok') return <Icon name="check" className="h-4 w-4" />;
if (status === 'auth') return <Icon name="shield-keyhole" className="h-4 w-4" />;
if (status === 'update-recommended') return <Icon name="shield-keyhole" className="h-4 w-4" />;
if (status === 'incompatible') return <Icon name="cloud-off" className="h-4 w-4" />;
if (status === 'wrong-service') return <Icon name="cloud-off" className="h-4 w-4" />;
if (status === 'unreachable') return <Icon name="cloud-off" className="h-4 w-4" />;
return <Icon name="earth" className="h-4 w-4" />;
@@ -204,18 +227,35 @@ const waitForSshReady = async (
throw new Error('Timed out waiting for SSH connection');
};
const buildLocalHost = (): DesktopHost => ({
const buildLocalHost = (localOrigin?: string | null): DesktopHost => ({
id: LOCAL_HOST_ID,
label: 'Local',
url: getLocalOrigin(),
url: localOrigin || getLocalOrigin(),
});
const resolveCurrentHost = (hosts: DesktopHost[]) => {
const currentHref = typeof window === 'undefined' ? '' : window.location.href;
const localOrigin = getLocalOrigin();
const localOrigin = hosts.find((host) => host.id === LOCAL_HOST_ID)?.url || getLocalOrigin();
const runtimeApiBaseUrl = getRuntimeApiBaseUrl();
const normalizedLocal = normalizeHostUrl(localOrigin) || localOrigin;
const normalizedCurrent = normalizeHostUrl(currentHref) || currentHref;
if (runtimeApiBaseUrl && locationMatchesHost(runtimeApiBaseUrl, localOrigin)) {
return { id: LOCAL_HOST_ID, label: 'Local', url: normalizedLocal };
}
const runtimeMatch = hosts.find((h) => {
return runtimeApiBaseUrl ? locationMatchesHost(runtimeApiBaseUrl, getDesktopHostApiUrl(h)) : false;
});
if (runtimeMatch) {
return {
id: runtimeMatch.id,
label: runtimeMatch.label,
url: normalizeHostUrl(getDesktopHostApiUrl(runtimeMatch)) || getDesktopHostApiUrl(runtimeMatch),
};
}
if (currentHref && locationMatchesHost(currentHref, localOrigin)) {
return { id: LOCAL_HOST_ID, label: 'Local', url: normalizedLocal };
}
@@ -228,6 +268,10 @@ const resolveCurrentHost = (hosts: DesktopHost[]) => {
return { id: match.id, label: match.label, url: normalizeHostUrl(match.url) || match.url };
}
if (currentHref.startsWith('openchamber-ui://')) {
return { id: LOCAL_HOST_ID, label: 'Local', url: normalizedLocal };
}
return {
id: 'custom',
label: redactSensitiveUrl(normalizedCurrent || 'Instance'),
@@ -255,6 +299,7 @@ export function DesktopHostSwitcherDialog({
const [configHosts, setConfigHosts] = React.useState<DesktopHost[]>([]);
const [defaultHostId, setDefaultHostId] = React.useState<string | null>(null);
const [statusById, setStatusById] = React.useState<Record<string, HostStatus>>({});
const [probingHostIds, setProbingHostIds] = React.useState<Record<string, true>>({});
const [isLoading, setIsLoading] = React.useState(false);
const [isProbing, setIsProbing] = React.useState(false);
const [isSaving, setIsSaving] = React.useState(false);
@@ -277,26 +322,32 @@ export function DesktopHostSwitcherDialog({
error: null,
});
const [error, setError] = React.useState<string>('');
const [localOrigin, setLocalOrigin] = React.useState<string>(() => getLocalOrigin());
const [editingId, setEditingId] = React.useState<string | null>(null);
const [editLabel, setEditLabel] = React.useState('');
const [editUrl, setEditUrl] = React.useState('');
const [newLabel, setNewLabel] = React.useState('');
const [newUrl, setNewUrl] = React.useState('');
const [isAddFormOpen, setIsAddFormOpen] = React.useState(!embedded);
const [runtimeEndpointEpoch, setRuntimeEndpointEpoch] = React.useState(0);
const sshSwitchTokenRef = React.useRef(0);
const allHosts = React.useMemo(() => {
const local = buildLocalHost();
const local = buildLocalHost(localOrigin);
const normalizedRemote = configHosts.map((h) => ({
...h,
url: normalizeHostUrl(h.url) || h.url,
}));
return [local, ...normalizedRemote];
}, [configHosts]);
}, [configHosts, localOrigin]);
const current = React.useMemo(() => resolveCurrentHost(allHosts), [allHosts]);
React.useEffect(() => {
return subscribeRuntimeEndpointChanged(() => setRuntimeEndpointEpoch((epoch) => epoch + 1));
}, []);
const current = React.useMemo(() => {
void runtimeEndpointEpoch;
return resolveCurrentHost(allHosts);
}, [allHosts, runtimeEndpointEpoch]);
const currentDefaultLabel = React.useMemo(() => {
const id = defaultHostId || LOCAL_HOST_ID;
return allHosts.find((h) => h.id === id)?.label || t('desktopHostSwitcher.instance.local');
@@ -334,6 +385,9 @@ export function DesktopHostSwitcherDialog({
desktopSshInstancesGet().catch(() => ({ instances: [] })),
getSshStatusById(),
]);
if (cfg.localOrigin) {
setLocalOrigin(cfg.localOrigin);
}
const nextSshHostIds: Record<string, true> = {};
for (const instance of sshCfg.instances) {
nextSshHostIds[instance.id] = true;
@@ -356,14 +410,21 @@ export function DesktopHostSwitcherDialog({
const probeAll = React.useCallback(async (hosts: DesktopHost[]) => {
if (!isTauriShell()) return;
setIsProbing(true);
const nextProbingHostIds: Record<string, true> = {};
for (const host of hosts) {
nextProbingHostIds[host.id] = true;
}
setProbingHostIds(nextProbingHostIds);
try {
const localClientToken = await getLocalClientToken();
const results = await Promise.all(
hosts.map(async (h) => {
const url = normalizeHostUrl(h.url);
const url = normalizeHostUrl(isElectronShell() ? getDesktopHostApiUrl(h) : h.url);
if (!url) {
return [h.id, { status: 'unreachable' as const, latencyMs: 0 } satisfies HostStatus] as const;
}
const res = await desktopHostProbe(url).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
const clientToken = h.id === LOCAL_HOST_ID ? localClientToken : (h.clientToken || '');
const res = await desktopHostProbe(url, { clientToken: clientToken || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
return [h.id, { status: res.status, latencyMs: res.latencyMs } satisfies HostStatus] as const;
})
);
@@ -373,6 +434,7 @@ export function DesktopHostSwitcherDialog({
}
setStatusById(next);
} finally {
setProbingHostIds({});
setIsProbing(false);
}
}, []);
@@ -382,16 +444,13 @@ export function DesktopHostSwitcherDialog({
setEditingId(null);
setEditLabel('');
setEditUrl('');
setNewLabel('');
setNewUrl('');
setIsAddFormOpen(!embedded);
setSwitchingHostId(null);
setSshSwitchModal({ open: false, hostId: null, hostLabel: '', phase: 'idle', detail: null, error: null });
setError('');
return;
}
void refresh();
}, [embedded, open, refresh]);
}, [open, refresh]);
React.useEffect(() => {
if (!open) return;
@@ -425,9 +484,32 @@ export function DesktopHostSwitcherDialog({
}, [open]);
const handleSwitch = React.useCallback(async (host: DesktopHost) => {
const origin = host.id === LOCAL_HOST_ID ? getLocalOrigin() : (normalizeHostUrl(host.url) || '');
const origin = host.id === LOCAL_HOST_ID ? localOrigin : (normalizeHostUrl(host.url) || '');
const apiOrigin = host.id === LOCAL_HOST_ID ? localOrigin : (normalizeHostUrl(getDesktopHostApiUrl(host)) || '');
if (!origin) return;
if (isElectronShell()) {
if (!apiOrigin) return;
setSwitchingHostId(host.id);
const clientToken = host.id === LOCAL_HOST_ID ? await getLocalClientToken() : (host.clientToken || '');
const probe = await desktopHostProbe(apiOrigin, { clientToken: clientToken || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
setStatusById((prev) => ({
...prev,
[host.id]: { status: probe.status, latencyMs: probe.latencyMs },
}));
if (isBlockedHostStatus(probe.status)) {
toast.error(t('desktopHostSwitcher.toast.instanceUnreachable', { host: redactSensitiveUrl(host.label) }));
setSwitchingHostId(null);
return;
}
switchRuntimeEndpoint({ apiBaseUrl: apiOrigin, clientToken: clientToken || null, runtimeKey: runtimeKeyForHost(host) });
onHostSwitched?.();
setSwitchingHostId(null);
return;
}
const isSshHost = Boolean(sshHostIds[host.id]);
if (host.id !== LOCAL_HOST_ID && isSshHost && isTauriShell()) {
@@ -516,13 +598,13 @@ export function DesktopHostSwitcherDialog({
if (host.id !== LOCAL_HOST_ID && isTauriShell()) {
setSwitchingHostId(host.id);
const probe = await desktopHostProbe(origin).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
const probe = await desktopHostProbe(origin, { clientToken: host.clientToken || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
setStatusById((prev) => ({
...prev,
[host.id]: { status: probe.status, latencyMs: probe.latencyMs },
}));
if (probe.status === 'unreachable' || probe.status === 'wrong-service') {
if (isBlockedHostStatus(probe.status)) {
toast.error(t('desktopHostSwitcher.toast.instanceUnreachable', { host: redactSensitiveUrl(host.label) }));
setSwitchingHostId(null);
return;
@@ -537,14 +619,7 @@ export function DesktopHostSwitcherDialog({
} catch {
window.location.href = target;
}
}, [onHostSwitched, sshHostIds, sshStatusesById, t]);
const beginEdit = React.useCallback((host: DesktopHost) => {
setEditingId(host.id);
setEditLabel(host.label);
setEditUrl(host.url);
setError('');
}, []);
}, [localOrigin, onHostSwitched, sshHostIds, sshStatusesById, t]);
const cancelEdit = React.useCallback(() => {
setEditingId(null);
@@ -563,60 +638,39 @@ export function DesktopHostSwitcherDialog({
return;
}
const url = normalizeHostUrl(editUrl);
if (!url) {
const resolved = resolveDesktopHostUrl(editUrl);
if (!resolved) {
setError(t('desktopHostSwitcher.error.invalidUrl'));
return;
}
const url = resolved.persistedUrl;
const label = (editLabel || redactSensitiveUrl(url)).trim();
const nextHosts = configHosts.map((h) => (h.id === editingId ? { ...h, label, url } : h));
await persist(nextHosts, defaultHostId);
cancelEdit();
if (resolved.redeemUrl) {
window.location.assign(resolved.redeemUrl);
}
}, [cancelEdit, configHosts, defaultHostId, editLabel, editUrl, editingId, persist, t]);
const addHost = React.useCallback(async () => {
const url = normalizeHostUrl(newUrl);
if (!url) {
setError(t('desktopHostSwitcher.error.invalidUrl'));
return;
}
const label = (newLabel || redactSensitiveUrl(url)).trim();
const id = makeId();
const nextHosts = [{ id, label, url }, ...configHosts];
await persist(nextHosts, defaultHostId);
setNewLabel('');
setNewUrl('');
if (embedded) {
setIsAddFormOpen(false);
}
}, [configHosts, defaultHostId, embedded, newLabel, newUrl, persist, t]);
const deleteHost = React.useCallback(async (id: string) => {
if (id === LOCAL_HOST_ID) return;
const nextHosts = configHosts.filter((h) => h.id !== id);
const nextDefault = defaultHostId === id ? LOCAL_HOST_ID : defaultHostId;
await persist(nextHosts, nextDefault);
}, [configHosts, defaultHostId, persist]);
const setDefault = React.useCallback(async (id: string) => {
const next = id === LOCAL_HOST_ID ? LOCAL_HOST_ID : id;
await persist(configHosts, next);
}, [configHosts, persist]);
const openInNewWindow = React.useCallback((host: DesktopHost) => {
const origin = host.id === LOCAL_HOST_ID ? getLocalOrigin() : (normalizeHostUrl(host.url) || '');
const origin = host.id === LOCAL_HOST_ID ? localOrigin : getDesktopHostApiUrl(host);
if (!origin) return;
const target = toNavigationUrl(origin);
desktopOpenNewWindowAtUrl(target).catch((err: unknown) => {
desktopOpenNewWindowAtUrl(target, { clientToken: host.clientToken || null }).catch((err: unknown) => {
toast.error(t('desktopHostSwitcher.error.failedToOpenNewWindow'), {
description: err instanceof Error ? err.message : String(err),
});
});
}, [t]);
}, [localOrigin, t]);
const switchToLocal = React.useCallback(() => {
const switchToLocal = React.useCallback(async () => {
sshSwitchTokenRef.current += 1;
setSwitchingHostId(null);
setSshSwitchModal((prev) => ({
@@ -627,10 +681,16 @@ export function DesktopHostSwitcherDialog({
detail: null,
phase: 'idle',
}));
const localTarget = toNavigationUrl(getLocalOrigin());
const localTarget = toNavigationUrl(localOrigin);
if (isElectronShell()) {
const clientToken = await getLocalClientToken();
switchRuntimeEndpoint({ apiBaseUrl: localOrigin, clientToken: clientToken || null, runtimeKey: 'local' });
onHostSwitched?.();
return;
}
onHostSwitched?.();
window.location.assign(localTarget);
}, [onHostSwitched]);
}, [localOrigin, onHostSwitched]);
const cancelSshSwitch = React.useCallback(async () => {
const hostId = sshSwitchModal.hostId || switchingHostId;
@@ -754,16 +814,6 @@ export function DesktopHostSwitcherDialog({
</div>
)}
{tauriAvailable && (
<div className="flex-shrink-0 flex items-center justify-between gap-2 px-2.5 py-1.5">
<span className="typography-micro text-muted-foreground">{t('desktopHostSwitcher.ssh.needInstancesHint')}</span>
<Button type="button" variant="ghost" size="sm" onClick={openRemoteInstancesSettings}>
<Icon name="settings-3" className="h-4 w-4" />
{t('desktopHostSwitcher.actions.remoteSsh')}
</Button>
</div>
)}
{!tauriAvailable && (
<div className="flex-shrink-0 rounded-lg border border-border/50 bg-muted/20 p-3">
<div className="typography-meta text-muted-foreground">
@@ -784,9 +834,10 @@ export function DesktopHostSwitcherDialog({
const isDefault = (defaultHostId || LOCAL_HOST_ID) === host.id;
const status = statusById[host.id] || null;
const sshStatus = sshStatusesById[host.id] || null;
const statusKind = isSsh ? sshPhaseToHostStatus(sshStatus?.phase) : (status?.status ?? null);
const isChecking = !isSsh && Boolean(probingHostIds[host.id]);
const statusKind: HostDisplayStatus = isSsh ? sshPhaseToHostStatus(sshStatus?.phase) : (isChecking ? 'checking' : (status?.status ?? null));
const isEditing = editingId === host.id;
const effectiveUrl = isLocal ? getLocalOrigin() : (normalizeHostUrl(host.url) || host.url);
const effectiveUrl = isLocal ? localOrigin : (normalizeHostUrl(host.url) || host.url);
const displayLabel = host.id === LOCAL_HOST_ID
? t('desktopHostSwitcher.instance.local')
: redactSensitiveUrl(host.label);
@@ -811,24 +862,26 @@ export function DesktopHostSwitcherDialog({
aria-label={t('desktopHostSwitcher.actions.switchToAria', { instance: displayLabel })}
>
<span className={cn('h-2 w-2 rounded-full flex-shrink-0', statusDotClass(statusKind))} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 min-w-0">
<span className={cn('typography-ui-label truncate', isActive ? 'text-foreground' : 'text-foreground')}>
{displayLabel}
</span>
{isSsh && (
<span className="typography-micro px-1 rounded leading-none pb-px text-[var(--status-info)] bg-[var(--status-info)]/10">
SSH
<div className="flex-1 min-w-0 space-y-0.5">
<div className="flex min-w-0 items-center gap-2">
<div className="flex min-w-0 max-w-[45%] items-center gap-1.5">
<span className="typography-ui-label truncate text-foreground">
{displayLabel}
</span>
)}
{isActive && (
<span className="typography-micro text-muted-foreground">{t('desktopHostSwitcher.header.current')}</span>
)}
<span className="inline-flex items-center gap-1 typography-micro text-muted-foreground">
{statusIcon(statusKind)}
<span>
{isSsh ? t(sshPhaseLabelKey(sshStatus?.phase)) : t(statusLabelKey(status?.status ?? null))}
{!isSsh && status?.status === 'ok' && typeof status.latencyMs === 'number'
{isSsh && (
<span className="typography-micro flex-shrink-0 px-1 rounded leading-none pb-px text-[var(--status-info)] bg-[var(--status-info)]/10">
SSH
</span>
)}
{isActive && (
<span className="typography-micro flex-shrink-0 text-muted-foreground">{t('desktopHostSwitcher.header.current')}</span>
)}
</div>
<span className="inline-flex min-w-0 flex-1 items-center gap-1 typography-micro text-muted-foreground">
<span className="flex-shrink-0">{statusIcon(statusKind)}</span>
<span className="truncate">
{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)) })
: ''}
</span>
@@ -841,52 +894,6 @@ export function DesktopHostSwitcherDialog({
</button>
<div className="flex items-center gap-2 flex-shrink-0">
{!isLocal && !isSsh && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="h-8 w-8 rounded-md inline-flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-interactive-hover transition-colors"
aria-label={t('desktopHostSwitcher.actions.instanceActionsAria')}
disabled={isSaving}
onClick={(e) => e.stopPropagation()}
>
<Icon name="more-2" className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit min-w-28">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
beginEdit(host);
}}
disabled={isSaving}
>
<Icon name="pencil" className="h-4 w-4 mr-1" />
{t('desktopHostSwitcher.actions.edit')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
void deleteHost(host.id);
}}
className="text-destructive focus:text-destructive"
disabled={isSaving}
>
<Icon name="delete-bin" className="h-4 w-4 mr-1" />
{t('desktopHostSwitcher.actions.delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
{isLocal && (
<div
className="h-8 w-8 opacity-0 pointer-events-none"
aria-hidden="true"
/>
)}
{isSsh && !isLocal && (
(sshStatus?.phase === 'idle' || !sshStatus?.phase) ? (
<Button
@@ -923,7 +930,7 @@ export function DesktopHostSwitcherDialog({
)}
onClick={() => void setDefault(host.id)}
aria-label={isDefault ? t('desktopHostSwitcher.actions.defaultInstanceAria') : t('desktopHostSwitcher.actions.setAsDefaultAria')}
disabled={isSaving || (!isDefault && (statusKind === 'unreachable' || statusKind === 'wrong-service'))}
disabled={isSaving || (!isDefault && isBlockedDisplayStatus(statusKind))}
>
{isDefault ? <Icon name="star-fill" className="h-4 w-4" /> : <Icon name="star" className="h-4 w-4" />}
</button>
@@ -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')}
>
<Icon name="window" className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>
{(statusKind === 'unreachable' || statusKind === 'wrong-service')
{isBlockedDisplayStatus(statusKind)
? t('desktopHostSwitcher.state.instanceUnreachable')
: t('desktopHostSwitcher.actions.openInNewWindow')}
</TooltipContent>
@@ -1000,68 +1007,16 @@ export function DesktopHostSwitcherDialog({
</div>
)}
{embedded && !isAddFormOpen ? (
<div className="flex-shrink-0 border-t border-[var(--interactive-border)]">
<button
type="button"
className="w-full flex items-center gap-2 px-2 py-2 text-left text-muted-foreground hover:text-foreground hover:bg-interactive-hover/30 transition-colors"
onClick={() => setIsAddFormOpen(true)}
disabled={!tauriAvailable || isSaving}
>
<Icon name="add" className="h-4 w-4" />
<span className="typography-ui-label">{t('desktopHostSwitcher.actions.addInstance')}</span>
</button>
</div>
) : (
<div className={cn(
'flex-shrink-0',
embedded
? 'border-t border-[var(--interactive-border)] px-2 py-2'
: 'rounded-md border border-[var(--interactive-border)] bg-[var(--surface-elevated)] p-2.5'
)}>
<div className="flex items-center justify-between gap-2">
<div className="typography-ui-label font-medium text-foreground">{t('desktopHostSwitcher.add.title')}</div>
<div className="flex items-center gap-2">
{embedded && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setIsAddFormOpen(false)}
disabled={isSaving}
>
{t('desktopHostSwitcher.actions.cancel')}
</Button>
)}
<Button
type="button"
size="sm"
onClick={() => void addHost()}
disabled={!tauriAvailable || isSaving || !newUrl.trim()}
>
{isSaving ? <Icon name="loader-4" className="h-4 w-4 animate-spin" /> : null}
{t('desktopHostSwitcher.actions.add')}
</Button>
</div>
</div>
<div className="mt-2 grid grid-cols-1 gap-2 sm:grid-cols-2">
<Input
value={newLabel}
onChange={(e) => setNewLabel(e.target.value)}
onKeyDown={stopDropdownTypeahead}
placeholder={t('desktopHostSwitcher.field.labelOptionalPlaceholder')}
disabled={!tauriAvailable || isSaving}
/>
<Input
value={newUrl}
onChange={(e) => setNewUrl(e.target.value)}
onKeyDown={stopDropdownTypeahead}
placeholder={t('desktopHostSwitcher.field.urlPlaceholder')}
disabled={!tauriAvailable || isSaving}
/>
</div>
</div>
)}
<div className="flex-shrink-0 border-t border-[var(--interactive-border)]">
<button
type="button"
className="w-full flex items-center gap-2 px-2 py-2 text-left text-muted-foreground hover:text-foreground hover:bg-interactive-hover/30 transition-colors"
onClick={openRemoteInstancesSettings}
>
<Icon name="add" className="h-4 w-4" />
<span className="typography-ui-label">{t('desktopHostSwitcher.actions.addInstance')}</span>
</button>
</div>
{error && (
<div className="flex-shrink-0 typography-meta text-status-error">{error}</div>
@@ -1102,7 +1057,7 @@ export function DesktopHostSwitcherDialog({
type="button"
size="sm"
variant="outline"
onClick={switchToLocal}
onClick={() => void switchToLocal()}
>
{t('desktopHostSwitcher.actions.switchToLocal')}
</Button>
@@ -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<HostProbeResult['status'] | null>(null);
const [localOrigin, setLocalOrigin] = React.useState<string>(() => 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
@@ -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<string, unknown>): 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<PreviewPaneProps> = ({ 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<PreviewProxyState>({ status: 'idle' });
const [urlAuthReadyKey, setUrlAuthReadyKey] = React.useState('');
const iframeRef = React.useRef<HTMLIFrameElement | null>(null);
const nextConsoleEventIdRef = React.useRef(1);
const [bridgeReady, setBridgeReady] = React.useState(false);
@@ -480,6 +511,7 @@ const PreviewPane: React.FC<PreviewPaneProps> = ({ 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<PreviewPaneProps> = ({ 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<PreviewPaneProps> = ({ 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<PreviewPaneProps> = ({ 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<PreviewPaneProps> = ({ 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<PreviewPaneProps> = ({ 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<PreviewPaneProps> = ({ rawUrl, onNavigate }) => {
void (async () => {
const probe = async (): Promise<Response | null> => {
try {
return await fetch(proxySrc, {
return await runtimeFetch(proxySrc, {
method: 'GET',
credentials: 'include',
cache: 'no-store',
@@ -882,7 +942,7 @@ const PreviewPane: React.FC<PreviewPaneProps> = ({ 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<PreviewPaneProps> = ({ 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<PreviewPaneProps> = ({ 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<PreviewPaneProps> = ({ rawUrl, onNavigate }) => {
} catch {
// Cross-origin frames are expected for non-loopback/direct previews.
}
}, [isLoopback, proxyState]);
}, [isLoopback, proxySrc, proxyState]);
return (
<div className="absolute inset-0 flex flex-col">
@@ -1195,6 +1256,7 @@ const IframeBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dire
const [isInspecting, setIsInspecting] = React.useState(false);
const [hoverTarget, setHoverTarget] = React.useState<PreviewElementMetadata | null>(null);
const [proxyState, setProxyState] = React.useState<PreviewProxyState>({ 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<DesktopBrowserPaneProps> = ({ 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<DesktopBrowserPaneProps> = ({ 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<DesktopBrowserPaneProps> = ({ 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<DesktopBrowserPaneProps> = ({ 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 : '');
+187 -24
View File
@@ -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<React.SetStateAction<boolean>>;
refreshCurrentInstanceLabel: () => Promise<void>;
@@ -346,6 +351,10 @@ type DesktopServicesMenuProps = {
showDevShutdown: boolean;
isDevShutdownInFlight: boolean;
onDevShutdown: () => Promise<void>;
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({
</div>
{isDesktopApp && desktopServicesTab === 'instance' ? (
<DesktopHostSwitcherDialog
embedded
open={isDesktopServicesOpen && desktopServicesTab === 'instance'}
onOpenChange={() => {}}
onHostSwitched={() => setIsDesktopServicesOpen(false)}
/>
<div>
{!currentInstanceIsLocal ? (
<div className="border-b border-[var(--interactive-border)] px-4 py-2.5">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="typography-ui-label font-medium text-foreground">{t('header.services.remoteUpdate.title')}</div>
<div className="typography-micro text-muted-foreground">
{remoteUpdateInfo?.available
? t('header.services.remoteUpdate.available', { version: remoteUpdateInfo.version || '' })
: remoteUpdateChecking
? t('header.services.remoteUpdate.checking')
: remoteUpdateError || t('header.services.remoteUpdate.upToDate')}
</div>
</div>
{remoteUpdateInfo?.available ? (
<button
type="button"
className="shrink-0 rounded-md bg-[var(--primary-base)] px-3 py-1.5 typography-ui-label font-medium text-[var(--primary-foreground)] hover:opacity-90"
onClick={onOpenRemoteUpdate}
>
{t('header.services.remoteUpdate.actions.open')}
</button>
) : null}
</div>
</div>
) : null}
<DesktopHostSwitcherDialog
embedded
open={isDesktopServicesOpen && desktopServicesTab === 'instance'}
onOpenChange={() => {}}
onHostSwitched={() => setIsDesktopServicesOpen(false)}
/>
</div>
) : null}
{desktopServicesTab === 'mcp' ? (
@@ -889,6 +930,11 @@ export const Header: React.FC<HeaderProps> = ({
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<UpdateInfo | null>(null);
const [remoteUpdateChecking, setRemoteUpdateChecking] = React.useState(false);
const [remoteUpdateError, setRemoteUpdateError] = React.useState<string | null>(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<HeaderProps> = ({
}
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<HeaderProps> = ({
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<HeaderProps> = ({
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<HeaderProps> = ({
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<HeaderProps> = ({
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<HeaderProps> = ({
}
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<HeaderProps> = ({
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<HeaderProps> = ({
isDesktopApp={isDesktopApp}
currentInstanceLabel={currentInstanceLabel}
compactCurrentInstanceLabel={compactCurrentInstanceLabel}
currentInstanceIsLocal={currentInstanceIsLocal}
isDesktopServicesOpen={isDesktopServicesOpen}
setIsDesktopServicesOpen={setIsDesktopServicesOpen}
refreshCurrentInstanceLabel={refreshCurrentInstanceLabel}
@@ -1953,6 +2098,10 @@ export const Header: React.FC<HeaderProps> = ({
showDevShutdown={showDevShutdown}
isDevShutdownInFlight={isDevShutdownInFlight}
onDevShutdown={handleDevShutdown}
remoteUpdateInfo={remoteUpdateInfo}
remoteUpdateChecking={remoteUpdateChecking}
remoteUpdateError={remoteUpdateError}
onOpenRemoteUpdate={openRemoteInstanceUpdate}
/>
<HeaderIconActionButton
title={t('header.actions.terminalPanelWithShortcut', { shortcut: shortcutLabel('toggle_terminal') })}
@@ -2533,12 +2682,26 @@ export const Header: React.FC<HeaderProps> = ({
);
return (
<header
ref={headerRef}
className={headerClassName}
style={{ ['--padding-scale' as string]: '1' } as React.CSSProperties}
>
{isMobile ? renderMobile() : renderDesktop()}
</header>
<>
<header
ref={headerRef}
className={headerClassName}
style={{ ['--padding-scale' as string]: '1' } as React.CSSProperties}
>
{isMobile ? renderMobile() : renderDesktop()}
</header>
<UpdateDialog
open={remoteUpdateDialogOpen}
onOpenChange={setRemoteUpdateDialogOpen}
info={remoteUpdateInfo}
downloading={false}
downloaded={false}
progress={null}
error={remoteUpdateError}
onDownload={() => {}}
onRestart={() => {}}
runtimeType="web"
/>
</>
);
};
@@ -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 })));
@@ -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<ProjectEditDialogProps> = ({
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<ProjectEditDialogProps> = ({
);
})}
</div>
{effectiveHasImageIcon && iconPreviewUrl && (
{effectiveHasImageIcon && showImagePreview && (
<div className="flex items-center gap-2 pt-1">
<span className="typography-meta text-muted-foreground">{t('projectEditDialog.field.preview')}</span>
<span className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-border/60 bg-[var(--surface-elevated)] p-1">
@@ -360,13 +349,25 @@ export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
className="inline-flex h-4 w-4 items-center justify-center overflow-hidden rounded-[2px]"
style={iconBackground ? { backgroundColor: iconBackground } : undefined}
>
<img
src={iconPreviewUrl}
alt=""
className="h-full w-full object-contain"
draggable={false}
onError={() => setPreviewImageFailed(true)}
/>
{hasPendingUploadImageIcon && pendingUploadIconPreviewUrl ? (
<img
src={pendingUploadIconPreviewUrl}
alt=""
className="h-full w-full object-contain"
draggable={false}
onError={() => setPreviewImageFailed(true)}
/>
) : (
<ProjectIconImage
project={{ id: projectId, iconImage: currentIconImage }}
options={{
themeVariant: currentTheme.metadata.variant,
iconColor: currentTheme.colors.surface.foreground,
}}
className="h-full w-full object-contain"
onError={() => setPreviewImageFailed(true)}
/>
)}
</span>
</span>
</div>
@@ -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'));
@@ -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<MultiRunLauncherProps> = ({
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 ? (
<Icon name={projectIconName} className="h-3.5 w-3.5 shrink-0" style={iconColor ? { color: iconColor } : undefined} />
) : (
<Icon name="folder" className="h-3.5 w-3.5 shrink-0 text-muted-foreground/80" style={iconColor ? { color: iconColor } : undefined}/>
);
return (
<span className="inline-flex min-w-0 items-center gap-1.5">
{imageUrl ? (
{project.iconImage ? (
<span
className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center overflow-hidden rounded-[3px]"
style={project.iconBackground ? { backgroundColor: project.iconBackground } : undefined}
>
<img src={imageUrl} alt="" className="h-full w-full object-contain" draggable={false} />
<ProjectIconImage
project={{ id: project.id, iconImage: project.iconImage ?? null }}
options={{
themeVariant: currentTheme.metadata.variant,
iconColor: currentTheme.colors.surface.foreground,
}}
className="h-full w-full object-contain"
fallback={fallbackIcon}
/>
</span>
) : projectIconName ? (
<Icon name={projectIconName} className="h-3.5 w-3.5 shrink-0" style={iconColor ? { color: iconColor } : undefined} />
) : (
<Icon name="folder" className="h-3.5 w-3.5 shrink-0 text-muted-foreground/80" style={iconColor ? { color: iconColor } : undefined}/>
)}
) : fallbackIcon}
<span className="truncate">{displayLabel}</span>
</span>
);
@@ -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<boolean> => {
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);
}
@@ -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({
</div>
{/* Host info if available */}
{hostUrl && (variant === 'remote-unreachable' || variant === 'remote-wrong-service') && (
{hostUrl && (variant === 'remote-unreachable' || variant === 'remote-wrong-service' || variant === 'remote-incompatible') && (
<div className="rounded-lg border border-border bg-background/50 p-3">
<div className="text-xs text-muted-foreground mb-1">{t('onboarding.remoteConnection.field.serverAddress')}</div>
<div className="font-mono text-sm text-foreground truncate">{redactSensitiveUrl(hostUrl)}</div>
@@ -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<boolean> => {
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);
}
@@ -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]);
@@ -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<HostProbeResult | null>(null);
const [error, setError] = useState('');
const normalizedUrl = normalizeHostUrl(url);
const resolvedUrl = resolveDesktopHostUrl(url);
const normalizedUrl = resolvedUrl?.persistedUrl ?? null;
const handleUrlChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
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<string, unknown>) => Promise<unknown> } } }).__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({
</div>
)}
{probeResult && isUpdateRecommended && (
<div
className="rounded-lg border p-3 text-sm"
style={{
borderColor: 'var(--status-warning)',
color: 'var(--status-warning)',
}}
>
{probeMessageKey ? t(probeMessageKey as Parameters<typeof t>[0]) : null}
</div>
)}
{/* Blocking errors */}
{probeResult && isBlocking && (
<div
@@ -60,6 +60,15 @@ describe('getDesktopRecoveryConfig', () => {
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)
// ---------------------------------------------------------------------------
@@ -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',
@@ -17,6 +17,10 @@ const EXPECTED_ROUTING: Record<RecoveryVariant, Record<RecoveryPrimaryAction, Re
'use-local': 'switch-default-to-local',
'use-remote': 'remote-form',
},
'remote-incompatible': {
'use-local': 'switch-default-to-local',
'use-remote': 'remote-form',
},
'remote-wrong-service': {
'use-local': 'switch-default-to-local',
'use-remote': 'remote-form',
@@ -20,6 +20,7 @@ export function resolveRecoveryNextStep(
case 'local-unavailable':
return { kind: 'local-setup' };
case 'remote-unreachable':
case 'remote-incompatible':
case 'remote-wrong-service':
case 'remote-missing':
case 'missing-default-host':
@@ -21,6 +21,7 @@ import {
type ResponseStylePreset,
} from '@/lib/responseStyle';
import type { DesktopSettings } from '@/lib/desktop';
import { runtimeFetch } from '@/lib/runtime-fetch';
const AGENTS_MD_PATH = '~/.config/opencode/AGENTS.md';
@@ -69,7 +70,7 @@ const RESPONSE_STYLE_OPTION_LABEL_KEYS: Record<ResponseStylePreset, I18nKey> = {
};
const saveBehaviorSetting = async (settings: Partial<DesktopSettings>, 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',
@@ -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.'));
@@ -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<void> => {
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 = (
@@ -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 }),
@@ -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',
@@ -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<DeviceFlowCompleteResponse>;
}
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',
@@ -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' },
});
@@ -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<OpenChamberPageProps> = ({ 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 (
<div className="space-y-6">
@@ -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<MobileKeyboardMode>[] = [
},
];
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<OpenChamberVisualSettingsProps>
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<OpenChamberVisualSettingsProps>
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<MobileLayoutPreference>(() => 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<OpenChamberVisualSettingsProps>
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<OpenChamberVisualSettingsProps>
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<OpenChamberVisualSettingsProps>
</div>
</div>
{showMobileLayoutSetting && (
<div className="flex min-w-0 flex-col gap-1.5 py-1.5">
<span className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.visual.section.mobileLayout')}</span>
<div className="flex flex-wrap items-center gap-1">
{MOBILE_LAYOUT_OPTIONS.map((option) => (
<Button
key={option.value}
variant="chip"
size="xs"
aria-pressed={mobileLayoutPreference === option.value}
className="!font-normal"
onClick={() => handleMobileLayoutPreferenceChange(option.value)}
>
{tUnsafe(option.labelKey)}
</Button>
))}
</div>
</div>
)}
<div className="grid grid-cols-1 gap-2 py-1.5 md:grid-cols-[14rem_auto] md:gap-x-8 md:gap-y-2">
<div className="flex min-w-0 items-center gap-2">
<span className="typography-ui-label text-foreground shrink-0">{t('settings.openchamber.visual.field.lightTheme')}</span>
@@ -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' },
});
@@ -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 : []);
@@ -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({
@@ -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 = () => {
);
})}
</div>
{effectiveHasImageIcon && iconPreviewUrl && (
{effectiveHasImageIcon && showImagePreview && (
<div className="mt-2 flex items-center gap-2">
<span className="typography-meta text-muted-foreground">{t('settings.projects.page.field.preview')}</span>
<span className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-border/60 bg-[var(--surface-elevated)] p-1">
@@ -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}
>
<img
src={iconPreviewUrl}
alt=""
className="h-full w-full object-contain"
draggable={false}
onError={() => setPreviewImageFailed(true)}
/>
{hasPendingUploadImageIcon && pendingUploadIconPreviewUrl ? (
<img
src={pendingUploadIconPreviewUrl}
alt=""
className="h-full w-full object-contain"
draggable={false}
onError={() => setPreviewImageFailed(true)}
/>
) : selectedProject ? (
<ProjectIconImage
project={selectedProject}
options={{
themeVariant: currentTheme.metadata.variant,
iconColor: currentTheme.colors.surface.foreground,
}}
className="h-full w-full object-contain"
onError={() => setPreviewImageFailed(true)}
/>
) : null}
</span>
</span>
</div>
@@ -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<Set<string>>(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
? (
<span
className="inline-flex h-4 w-4 items-center justify-center overflow-hidden rounded-[2px]"
style={project.iconBackground ? { backgroundColor: project.iconBackground } : undefined}
>
<img
src={imageUrl}
alt=""
className="h-full w-full object-contain"
draggable={false}
onError={() => {
setBrokenIconIds((prev) => {
if (prev.has(imageFailureKey)) {
return prev;
}
const next = new Set(prev);
next.add(imageFailureKey);
return next;
});
}}
/>
</span>
)
: iconName
const fallbackIcon = iconName
? (
<Icon name={iconName} className={cn('h-4 w-4', selected ? 'text-foreground' : 'text-muted-foreground/70')} style={color ? { color } : undefined} />
)
: (
<Icon name="folder" className={cn('h-4 w-4', selected ? 'text-foreground' : 'text-muted-foreground/70')} style={color ? { color } : undefined} />
);
const icon = project.iconImage
? (
<span
className="inline-flex h-4 w-4 items-center justify-center overflow-hidden rounded-[2px]"
style={project.iconBackground ? { backgroundColor: project.iconBackground } : undefined}
>
<ProjectIconImage
project={project}
options={{
themeVariant: currentTheme.metadata.variant,
iconColor: currentTheme.colors.surface.foreground,
}}
className="h-full w-full object-contain"
fallback={fallbackIcon}
/>
</span>
)
: fallbackIcon;
return (
<SettingsSidebarItem
@@ -21,6 +21,8 @@ import { copyTextToClipboard } from '@/lib/clipboard';
import { openExternalUrl } from '@/lib/url';
import type { ModelMetadata } from '@/types';
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { opencodeClient } from '@/lib/opencode/client';
const COMPACT_NUMBER_FORMATTER = new Intl.NumberFormat('en-US', {
notation: 'compact',
@@ -180,18 +182,12 @@ export const ProvidersPage: React.FC = () => {
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<string, unknown> = isRecord(result.data) ? result.data : {};
const nestedData = payloadRecord.data;
const dataRecord: Record<string, unknown> = 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'));
@@ -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<ProvidersSidebarProps> = ({ 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' },
});
@@ -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<DesktopHost[]>([]);
const [directDefaultHostId, setDirectDefaultHostId] = React.useState<string | null>('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<string | null>(null);
const [directAddDialogOpen, setDirectAddDialogOpen] = React.useState(false);
const [directImportDialogOpen, setDirectImportDialogOpen] = React.useState(false);
const [directEditingId, setDirectEditingId] = React.useState<string | null>(null);
const [directEditLabel, setDirectEditLabel] = React.useState('');
const [directEditUrl, setDirectEditUrl] = React.useState('');
const [directEditToken, setDirectEditToken] = React.useState('');
const [remoteClients, setRemoteClients] = React.useState<RemoteClientRecord[]>([]);
const [remoteClientsLoading, setRemoteClientsLoading] = React.useState(false);
const [remoteClientLabel, setRemoteClientLabel] = React.useState('');
const [createdRemoteClientToken, setCreatedRemoteClientToken] = React.useState<string | null>(null);
const [remoteClientError, setRemoteClientError] = React.useState<string | null>(null);
const [pairingUrl, setPairingUrl] = React.useState<string | null>(null);
const [pairingQrDataUrl, setPairingQrDataUrl] = React.useState<string | null>(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 (
<SettingsPageLayout>
<div className="mb-8">
<div className="mb-1 px-1 space-y-0.5">
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.title')}</h3>
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.description')}</p>
{clientAuth ? (
<div className="mb-8">
<div className="mb-1 px-1 space-y-0.5">
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.clientAuth.title')}</h3>
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.clientAuth.description')}</p>
</div>
<section className="px-2 pb-2 pt-0 space-y-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Input className="h-8" value={remoteClientLabel} onChange={(event) => setRemoteClientLabel(event.target.value)} placeholder={t('settings.remoteInstances.clientAuth.field.labelPlaceholder')} />
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={() => void createRemoteClient()}>
{t('settings.remoteInstances.clientAuth.actions.create')}
</Button>
<Button type="button" size="xs" className="!font-normal" onClick={() => void createPairingLink()}>
{t('settings.remoteInstances.clientAuth.actions.pair')}
</Button>
</div>
{pairingUrl ? (
<div className="flex flex-col gap-3 rounded-md border border-[var(--interactive-border)] p-2 sm:flex-row">
{pairingQrDataUrl ? <img src={pairingQrDataUrl} alt={t('settings.remoteInstances.clientAuth.qrAlt')} className="size-48 self-start" /> : null}
<div className="min-w-0 flex-1 space-y-2">
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.clientAuth.pairingUrl')}</p>
<code className="block select-all break-all typography-code text-foreground">{pairingUrl}</code>
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={() => void copyTextToClipboard(pairingUrl)}>
<Icon name="file-copy" className="h-3.5 w-3.5" />
{t('settings.common.actions.copyAll')}
</Button>
</div>
</div>
) : null}
{createdRemoteClientToken ? (
<div className="space-y-1 rounded-md border border-[var(--interactive-border)] p-2">
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.clientAuth.createdToken')}</p>
<code className="block select-all break-all typography-code text-foreground">{createdRemoteClientToken}</code>
</div>
) : null}
<div className="space-y-1">
{revokedClientCount > 0 ? (
<div className="flex justify-end">
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => void purgeRevokedRemoteClients()}>
{t('settings.remoteInstances.clientAuth.actions.clearRevoked')}
</Button>
</div>
) : null}
{remoteClientsLoading ? (
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.clientAuth.state.loading')}</p>
) : remoteClients.length === 0 ? (
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.clientAuth.state.empty')}</p>
) : remoteClients.map((client) => {
const isLocalDesktopClient = client.clientKind === 'desktop-local';
return (
<div key={client.id} className="flex items-center justify-between gap-3 py-1.5">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<p className="typography-ui-label text-foreground truncate">{client.label}</p>
{isLocalDesktopClient ? (
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
{t('settings.remoteInstances.clientAuth.state.thisDevice')}
</span>
) : null}
</div>
<p className="typography-micro text-muted-foreground truncate">{client.revokedAt ? t('settings.remoteInstances.clientAuth.state.revoked') : client.lastUsedAt ? t('settings.remoteInstances.clientAuth.lastUsed', { date: client.lastUsedAt }) : t('settings.remoteInstances.clientAuth.neverUsed')}</p>
</div>
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => void revokeRemoteClient(client)} disabled={Boolean(client.revokedAt)}>
{t('settings.remoteInstances.clientAuth.actions.revoke')}
</Button>
</div>
);
})}
</div>
{remoteClientError ? <p className="typography-meta text-[var(--status-error)]">{remoteClientError}</p> : null}
</section>
</div>
<section className="px-2 pb-2 pt-0 space-y-3">
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.empty.selectInstance')}</p>
</section>
</div>
) : null}
<div className="mb-8 border-t border-[var(--surface-subtle)] pt-8">
{showInstanceManagement ? <div className="mb-8 border-t border-[var(--surface-subtle)] pt-8">
<div className="mb-1 px-1 space-y-0.5">
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.direct.title')}</h3>
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.direct.description')}</p>
</div>
<section className="px-2 pb-2 pt-0 space-y-4">
<div className="flex items-center justify-between gap-2">
<p className="typography-meta text-muted-foreground/70">{t('settings.remoteInstances.direct.note')}</p>
<div className="flex shrink-0 items-center gap-2">
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={() => setDirectImportDialogOpen(true)} disabled={directSaving}>
{t('settings.remoteInstances.direct.import.action')}
</Button>
<Button type="button" size="xs" className="!font-normal" onClick={() => setDirectAddDialogOpen(true)} disabled={directSaving}>
<Icon name="add" className="h-3.5 w-3.5" />
{t('settings.remoteInstances.direct.actions.add')}
</Button>
</div>
</div>
<div className="space-y-1">
{directLoading ? (
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.direct.state.loading')}</p>
) : directHosts.length === 0 ? (
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.direct.state.empty')}</p>
) : directHosts.map((host) => (
<div key={host.id} className="py-1.5">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<p className="typography-ui-label text-foreground truncate">{redactSensitiveUrl(host.label)}</p>
{directDefaultHostId === host.id ? <span className="typography-micro text-muted-foreground">{t('desktopHostSwitcher.header.default')}</span> : null}
</div>
<p className="typography-micro text-muted-foreground font-mono truncate">{redactSensitiveUrl(host.apiUrl || host.url)}</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => void setDefaultDirectHost(host.id)} disabled={directSaving || directDefaultHostId === host.id} aria-label={t('desktopHostSwitcher.actions.setAsDefaultAria')}>
{directDefaultHostId === host.id ? <Icon name="star-fill" className="h-3.5 w-3.5" /> : <Icon name="star" className="h-3.5 w-3.5" />}
</Button>
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => beginEditDirectHost(host)} disabled={directSaving}>
<Icon name="pencil" className="h-3.5 w-3.5" />
{t('desktopHostSwitcher.actions.edit')}
</Button>
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => void handleRemoveDirectHost(host.id)} disabled={directSaving}>
<Icon name="delete-bin" className="h-3.5 w-3.5" />
{t('settings.common.actions.delete')}
</Button>
</div>
</div>
</div>
))}
</div>
{directError ? <p className="typography-meta text-[var(--status-error)]">{directError}</p> : null}
</section>
</div> : null}
{showInstanceManagement ? <Dialog open={directAddDialogOpen} onOpenChange={setDirectAddDialogOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{t('settings.remoteInstances.direct.actions.add')}</DialogTitle>
<DialogDescription>{t('settings.remoteInstances.direct.description')}</DialogDescription>
</DialogHeader>
<form className="space-y-3" onSubmit={(event) => { event.preventDefault(); void handleAddDirectHost(); }}>
<Input className="h-8" value={directLabel} onChange={(event) => setDirectLabel(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.labelPlaceholder')} disabled={directSaving} />
<Input className="h-8" value={directUrl} onChange={(event) => setDirectUrl(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.urlPlaceholder')} disabled={directSaving} autoFocus />
<Input className="h-8" value={directToken} onChange={(event) => setDirectToken(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.tokenPlaceholder')} type="password" disabled={directSaving} />
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={() => setDirectAddDialogOpen(false)} disabled={directSaving}>{t('settings.common.actions.cancel')}</Button>
<Button type="submit" size="xs" className="!font-normal" disabled={directSaving || !directUrl.trim()}>{t('settings.remoteInstances.direct.actions.add')}</Button>
</div>
</form>
</DialogContent>
</Dialog> : null}
{showInstanceManagement ? <Dialog open={Boolean(directEditingId)} onOpenChange={(open) => { if (!open) setDirectEditingId(null); }}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{t('desktopHostSwitcher.actions.edit')}</DialogTitle>
<DialogDescription>{t('settings.remoteInstances.direct.description')}</DialogDescription>
</DialogHeader>
<form className="space-y-3" onSubmit={(event) => { event.preventDefault(); void saveDirectHostEdit(); }}>
<Input className="h-8" value={directEditLabel} onChange={(event) => setDirectEditLabel(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.labelPlaceholder')} disabled={directSaving} />
<Input className="h-8" value={directEditUrl} onChange={(event) => setDirectEditUrl(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.urlPlaceholder')} disabled={directSaving} autoFocus />
<Input className="h-8" value={directEditToken} onChange={(event) => setDirectEditToken(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.tokenPlaceholder')} type="password" disabled={directSaving} />
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={() => setDirectEditingId(null)} disabled={directSaving}>{t('settings.common.actions.cancel')}</Button>
<Button type="submit" size="xs" className="!font-normal" disabled={directSaving}>{t('settings.common.actions.saveChanges')}</Button>
</div>
</form>
</DialogContent>
</Dialog> : null}
{showInstanceManagement ? <Dialog open={directImportDialogOpen} onOpenChange={setDirectImportDialogOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{t('settings.remoteInstances.direct.import.action')}</DialogTitle>
<DialogDescription>{t('settings.remoteInstances.direct.import.description')}</DialogDescription>
</DialogHeader>
<form className="space-y-3" onSubmit={(event) => { event.preventDefault(); void importDirectConnectLink(); }}>
<Input className="h-8" value={directConnectLink} onChange={(event) => setDirectConnectLink(event.target.value)} placeholder={t('settings.remoteInstances.direct.import.placeholder')} disabled={directSaving} autoFocus />
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={() => setDirectImportDialogOpen(false)} disabled={directSaving}>{t('settings.common.actions.cancel')}</Button>
<Button type="submit" size="xs" className="!font-normal" disabled={directSaving || !directConnectLink.trim()}>{t('settings.remoteInstances.direct.import.action')}</Button>
</div>
</form>
</DialogContent>
</Dialog> : null}
{showInstanceManagement ? <div className="mb-8 border-t border-[var(--surface-subtle)] pt-8">
<div className="mb-1 px-1 space-y-0.5">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.sidebar.title')}</h3>
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.sidebar.total', { count: instances.length })}</p>
</div>
<Button type="button" size="xs" className="!font-normal" onClick={() => setSshAddDialogOpen(true)}>
<Icon name="add" className="h-3.5 w-3.5" />
{t('settings.remoteInstances.sidebar.actions.addSshInstance')}
</Button>
</div>
</div>
<section className="px-2 pb-2 pt-0 space-y-1">
{isLoading ? (
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.import.loading')}</p>
) : instances.length === 0 ? (
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.import.noneFound')}</p>
) : 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 (
<div key={instance.id} className="flex items-center justify-between gap-3 py-1.5">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<span className={`h-2 w-2 rounded-full ${phaseDotClass(phase)}`} />
<p className="typography-ui-label text-foreground truncate">{title}</p>
</div>
<p className="typography-micro text-muted-foreground truncate">
{t(phaseLabelKey(phase))}{instanceStatus?.localUrl ? ` · ${instanceStatus.localUrl}` : ''}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => {
const op = ready ? disconnect(instance.id) : connect(instance.id);
void op.catch((err) => toast.error(ready ? t('settings.remoteInstances.sidebar.toast.disconnectFailed') : t('settings.remoteInstances.sidebar.toast.connectFailed'), {
description: err instanceof Error ? err.message : String(err),
}));
}}>
{ready ? <Icon name="stop" className="h-3.5 w-3.5" /> : <Icon name="plug-2" className="h-3.5 w-3.5" />}
{ready ? t('settings.remoteInstances.sidebar.actions.disconnect') : t('settings.remoteInstances.sidebar.actions.connect')}
</Button>
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => setSelectedId(instance.id)}>
<Icon name="pencil" className="h-3.5 w-3.5" />
{t('desktopHostSwitcher.actions.edit')}
</Button>
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => {
const ok = window.confirm(t('settings.remoteInstances.page.confirm.removeInstance'));
if (!ok) return;
void removeInstance(instance.id).catch((err) => toast.error(t('settings.remoteInstances.page.toast.removeInstanceFailed'), {
description: err instanceof Error ? err.message : String(err),
}));
}}>
<Icon name="delete-bin" className="h-3.5 w-3.5" />
{t('settings.common.actions.delete')}
</Button>
</div>
</div>
);
})}
</section>
</div> : null}
{showInstanceManagement ? <Dialog open={sshAddDialogOpen} onOpenChange={setSshAddDialogOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{t('settings.remoteInstances.sidebar.actions.addSshInstance')}</DialogTitle>
<DialogDescription>{t('settings.remoteInstances.page.section.instanceDescription')}</DialogDescription>
</DialogHeader>
<form className="space-y-3" onSubmit={(event) => { event.preventDefault(); void createSshInstanceFromDialog(); }}>
<Input className="h-8" value={sshNameDraft} onChange={(event) => setSshNameDraft(event.target.value)} placeholder={t('settings.remoteInstances.page.field.nicknamePlaceholder')} disabled={isSaving} />
<Input className="h-8" value={sshCommandDraft} onChange={(event) => setSshCommandDraft(event.target.value)} placeholder={t('settings.remoteInstances.page.field.sshCommandPlaceholder')} disabled={isSaving} autoFocus />
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={() => setSshAddDialogOpen(false)} disabled={isSaving}>{t('settings.common.actions.cancel')}</Button>
<Button type="submit" size="xs" className="!font-normal" disabled={isSaving || !sshCommandDraft.trim()}>{t('settings.common.actions.create')}</Button>
</div>
</form>
</DialogContent>
</Dialog> : null}
{showInstanceManagement ? <div className="mb-8 border-t border-[var(--surface-subtle)] pt-8">
<div className="mb-1 px-1 space-y-0.5">
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.import.sectionTitle')}</h3>
</div>
@@ -694,15 +1229,15 @@ export const RemoteInstancesPage: React.FC = () => {
) : importCandidates.length === 0 ? (
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.import.noneFound')}</p>
) : (
<div className="space-y-2">
<div>
{importCandidates.map((candidate) => (
<div key={`${candidate.source}:${candidate.host}`} className="flex items-center justify-between gap-3 rounded-md border border-[var(--interactive-border)] px-3 py-2">
<div key={`${candidate.source}:${candidate.host}`} className="flex items-center justify-between gap-3 border-b border-[var(--surface-subtle)] py-3 last:border-b-0">
<div className="min-w-0">
<div className="typography-ui-label text-foreground truncate">
<div className="typography-ui-label font-medium text-foreground truncate">
{candidate.host}
{candidate.pattern ? ` ${t('settings.remoteInstances.page.import.patternSuffix')}` : ''}
</div>
<div className="typography-micro text-muted-foreground">{candidate.source} config</div>
<div className="typography-meta text-muted-foreground truncate">{candidate.sshCommand}</div>
</div>
<Button
type="button"
@@ -711,14 +1246,14 @@ export const RemoteInstancesPage: React.FC = () => {
className="!font-normal"
onClick={() => void handleImportCandidate(candidate.host, candidate.pattern)}
>
{t('settings.remoteInstances.page.actions.create')}
{t('settings.common.actions.import')}
</Button>
</div>
))}
</div>
)}
</section>
</div>
</div> : null}
<Dialog
open={Boolean(patternHost)}
@@ -767,7 +1302,8 @@ export const RemoteInstancesPage: React.FC = () => {
const instanceTitle = draft.nickname?.trim() || draft.sshParsed?.destination || draft.id;
return (
<SettingsPageLayout>
<Dialog open={Boolean(draft)} onOpenChange={(open) => { if (!open) setSelectedId(null); }}>
<DialogContent className="sm:max-w-4xl max-h-[90vh] overflow-auto">
<div className="mb-6 px-1">
<h2 className="typography-ui-header font-semibold text-foreground truncate">{instanceTitle}</h2>
<div className="mt-1 flex flex-wrap items-center gap-2 typography-meta text-muted-foreground">
@@ -1466,46 +2002,7 @@ export const RemoteInstancesPage: React.FC = () => {
</section>
</div>
<div className="mb-8 border-t border-[var(--surface-subtle)] pt-8">
<div className="mb-1 px-1 space-y-0.5">
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.import.sectionTitle')}</h3>
</div>
<section className="px-2 pb-2 pt-0">
{isImportsLoading ? (
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.import.loading')}</p>
) : importCandidates.length === 0 ? (
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.import.noneAvailable')}</p>
) : (
<div>
{importCandidates.slice(0, 8).map((candidate, index) => (
<div
key={`${candidate.source}:${candidate.host}`}
className={`flex items-center justify-between gap-2 px-1 py-2 ${index > 0 ? 'border-t border-[var(--surface-subtle)]' : ''}`}
>
<div className="min-w-0">
<div className="typography-ui-label text-foreground truncate">
{candidate.host}
{candidate.pattern ? ' (pattern)' : ''}
</div>
<div className="typography-micro text-muted-foreground truncate">{candidate.sshCommand}</div>
</div>
<Button
type="button"
variant="outline"
size="xs"
className="!font-normal"
onClick={() => void handleImportCandidate(candidate.host, candidate.pattern)}
>
{t('settings.common.actions.import')}
</Button>
</div>
))}
</div>
)}
</section>
</div>
<div className="sticky bottom-0 z-10 -mx-3 sm:-mx-6 bg-[var(--surface-background)] border-t border-[var(--interactive-border)] px-3 sm:px-6 py-3">
<div className="mt-8 border-t border-[var(--interactive-border)] pt-3">
<div className="flex items-center gap-2">
<Button type="button" size="xs" className="!font-normal" onClick={() => void handleSave()} disabled={!hasChanges || isSaving}>
{t('settings.common.actions.saveChanges')}
@@ -1617,6 +2114,7 @@ export const RemoteInstancesPage: React.FC = () => {
</form>
</DialogContent>
</Dialog>
</SettingsPageLayout>
</DialogContent>
</Dialog>
);
};
@@ -20,6 +20,8 @@ const makeId = (): string => {
return `ssh-${Date.now()}-${Math.random().toString(16).slice(2)}`;
};
const DIRECT_INSTANCES_ID = '__direct_instances__';
const randomPort = (): number => {
return Math.floor(20000 + Math.random() * 30000);
};
@@ -76,6 +78,9 @@ export const RemoteInstancesSidebar: React.FC<RemoteInstancesSidebarProps> = ({
React.useEffect(() => {
if (isLoading) return;
if (selectedId === DIRECT_INSTANCES_ID) {
return;
}
if (instances.length === 0) {
if (selectedId !== null) {
setSelectedId(null);
@@ -130,7 +135,7 @@ export const RemoteInstancesSidebar: React.FC<RemoteInstancesSidebarProps> = ({
}, [connect, t, upsertInstance]);
return (
<SettingsSidebarLayout
<SettingsSidebarLayout
variant="background"
header={
<div className="border-b px-3 pt-4 pb-3">
@@ -151,6 +156,16 @@ export const RemoteInstancesSidebar: React.FC<RemoteInstancesSidebarProps> = ({
</div>
}
>
<SettingsSidebarItem
title={t('settings.remoteInstances.direct.sidebarTitle')}
metadata={t('settings.remoteInstances.direct.sidebarDescription')}
selected={selectedId === DIRECT_INSTANCES_ID || (!selectedId && instances.length === 0)}
onSelect={() => {
setSelectedId(DIRECT_INSTANCES_ID);
onItemSelect?.();
}}
icon={<Icon name="global" className="h-4 w-4 text-muted-foreground" />}
/>
{instances.map((instance) => {
const status = statusesById[instance.id];
const selected = instance.id === selectedId;
@@ -1,5 +1,6 @@
import React from 'react';
import { toast } from '@/components/ui';
import { runtimeFetch } from '@/lib/runtime-fetch';
import {
Dialog,
@@ -60,7 +61,7 @@ const loadSettings = async (): Promise<DesktopSettings | null> => {
return (result?.settings || {}) as DesktopSettings;
}
const response = await fetch('/api/config/settings', {
const response = await runtimeFetch('/api/config/settings', {
method: 'GET',
headers: { Accept: 'application/json' },
});
@@ -1,4 +1,5 @@
import React from 'react';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -50,7 +51,7 @@ const loadSettings = async (): Promise<DesktopSettings | null> => {
return (result?.settings || {}) as DesktopSettings;
}
const response = await fetch('/api/config/settings', {
const response = await runtimeFetch('/api/config/settings', {
method: 'GET',
headers: { Accept: 'application/json' },
});
@@ -16,6 +16,7 @@ import { useFileSystemAccess } from '@/hooks/useFileSystemAccess';
import { cn } from '@/lib/utils';
import { toast } from '@/components/ui';
import { IdentityDropdown } from '@/components/views/git/GitHeader';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { useDeviceInfo } from '@/lib/device';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { Icon } from "@/components/icon/Icon";
@@ -120,7 +121,7 @@ const focusPathInput = (input: HTMLInputElement | null): void => {
const resolveFreshFilesystemHome = async (): Promise<string | null> => {
try {
const response = await fetch('/api/fs/home', {
const response = await runtimeFetch('/api/fs/home', {
method: 'GET',
headers: { Accept: 'application/json' },
});
@@ -17,6 +17,7 @@ import { useFileSystemAccess } from '@/hooks/useFileSystemAccess';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Icon } from "@/components/icon/Icon";
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
interface DirectoryItem {
name: string;
@@ -281,7 +282,7 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
try {
let pinned: string[] = [];
const response = await fetch('/api/config/settings', {
const response = await runtimeFetch('/api/config/settings', {
method: 'GET',
headers: { Accept: 'application/json' },
});
@@ -366,7 +366,7 @@ export function GitHubIssuePickerDialog({
const sessionTitle = `#${issue.number} ${issue.title}`.trim();
const sessionId = await (async () => {
const { sessionId, sessionDirectory } = await (async () => {
if (createInWorktree) {
const preferred = `issue-${issue.number}-${generateBranchSlug()}`;
const created = await createWorktreeSessionForNewBranch(
@@ -376,14 +376,14 @@ export function GitHubIssuePickerDialog({
if (!created?.id) {
throw new Error('Failed to create worktree session');
}
return created.id;
return { sessionId: created.id, sessionDirectory: created.path };
}
const session = await sessionActions.createSession(sessionTitle, projectDirectory, null);
if (!session?.id) {
throw new Error('Failed to create session');
}
return session.id;
return { sessionId: session.id, sessionDirectory: session.directory ?? projectDirectory };
})();
// Ensure worktree-based sessions also get the issue title.
@@ -468,6 +468,7 @@ export function GitHubIssuePickerDialog({
{ text: instructionsText, synthetic: true },
{ text: contextText, synthetic: true },
],
directory: sessionDirectory,
}).catch((e) => {
const message = e instanceof Error ? e.message : String(e);
toast.error(t('session.githubIssuePicker.toast.sendContextFailed'), {
@@ -510,6 +510,7 @@ export function NewWorktreeDialog({
const sendLinkedContextMessage = React.useCallback(async (args: {
sessionId: string;
directory: string;
issue: GitHubIssue | null;
pr: GitHubPullRequestSummary | null;
includeDiff: boolean;
@@ -576,6 +577,7 @@ export function NewWorktreeDialog({
{ text: instructionsText, synthetic: true },
{ text: contextText, synthetic: true },
],
directory: args.directory,
});
toast.success(t('session.newWorktree.toast.sessionFromIssue'));
@@ -612,6 +614,7 @@ export function NewWorktreeDialog({
{ text: instructionsText, synthetic: true },
{ text: contextText, synthetic: true },
],
directory: args.directory,
});
toast.success(t('session.newWorktree.toast.sessionFromPr'));
@@ -935,6 +938,7 @@ export function NewWorktreeDialog({
onWorktreeCreated?.(metadata.path, { sessionId: createdSessionId });
void sendLinkedContextMessage({
sessionId: createdSessionId,
directory: metadata.path,
issue: linkedIssue,
pr: linkedPrState,
includeDiff: includePrDiff,
@@ -44,6 +44,7 @@ import { createWorktreeSessionForNewBranch } from '@/lib/worktreeSessionCreator'
import { cn } from '@/lib/utils';
import { renderMagicPrompt } from '@/lib/magicPrompts';
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { TodoSendDialog, type TodoSendExecution } from './TodoSendDialog';
const TODO_PANEL_MIN_ITEMS = 5;
@@ -514,7 +515,7 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
return;
}
sessionId = created.id;
directoryHint = null;
directoryHint = created.path;
} else {
const session = await createSession(undefined, projectRef.path, null);
if (!session?.id) {
@@ -619,7 +620,7 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
path: result.path,
allowOutsideWorkspace: 'true',
});
const response = await fetch(`/api/fs/read?${params.toString()}`, { cache: 'no-store' });
const response = await runtimeFetch(`/api/fs/read?${params.toString()}`, { cache: 'no-store' });
if (!response.ok) {
toast.error(t('rightSidebar.contextNotesTodo.toast.readPlanFileFailed'));
return;
@@ -18,7 +18,7 @@ import { useProjectsStore } from '@/stores/useProjectsStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { refreshGlobalSessions } from '@/stores/useGlobalSessionsStore';
import { subscribeOpenchamberEvents } from '@/lib/openchamberEvents';
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { cn, formatDirectoryName } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
@@ -195,30 +195,32 @@ export function ScheduledTasksDialog() {
const renderProjectLabel = React.useCallback((project: ProjectEntry) => {
const displayLabel = project.label?.trim() || formatDirectoryName(project.path, homeDirectory || undefined);
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 ? (
<Icon name={projectIconName} className="h-3.5 w-3.5 shrink-0" style={iconColor ? { color: iconColor } : undefined} />
) : (
<Icon name="folder" className="h-3.5 w-3.5 shrink-0 text-muted-foreground/80" style={iconColor ? { color: iconColor } : undefined}/>
);
return (
<span className="inline-flex min-w-0 items-center gap-1.5">
{imageUrl ? (
{project.iconImage ? (
<span
className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center overflow-hidden rounded-[3px]"
style={project.iconBackground ? { backgroundColor: project.iconBackground } : undefined}
>
<img src={imageUrl} alt="" className="h-full w-full object-contain" draggable={false} />
<ProjectIconImage
project={{ id: project.id, iconImage: project.iconImage ?? null }}
options={{
themeVariant: currentTheme.metadata.variant,
iconColor: currentTheme.colors.surface.foreground,
}}
className="h-full w-full object-contain"
fallback={fallbackIcon}
/>
</span>
) : projectIconName ? (
<Icon name={projectIconName} className="h-3.5 w-3.5 shrink-0" style={iconColor ? { color: iconColor } : undefined} />
) : (
<Icon name="folder" className="h-3.5 w-3.5 shrink-0 text-muted-foreground/80" style={iconColor ? { color: iconColor } : undefined}/>
)}
) : fallbackIcon}
<span className="truncate">{displayLabel}</span>
</span>
);
@@ -21,7 +21,7 @@ import { buildExportFilename, downloadAsMarkdown, formatSessionAsMarkdown, getEx
import type { ChildSessionExport } from '@/lib/exportSession';
import { buildSessionMessageRecordsSnapshot, useDirectoryStore, useGlobalSessionStatus, useSession, useSessionPermissions } from '@/sync/sync-context';
import { useSync } from '@/sync/use-sync';
import { useViewportStore } from '@/sync/viewport-store';
import { useViewportStore, viewportSessionKey } from '@/sync/viewport-store';
import { DraggableSessionRow } from './sessionFolderDnd';
import type { SessionNode, SessionSummaryMeta } from './types';
import { formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText, resolveSessionDiffStats } from './utils';
@@ -29,6 +29,8 @@ import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
import { useSessionUnseenCount } from '@/sync/notification-store';
import { useSessionMultiSelectStore } from '@/stores/useSessionMultiSelectStore';
import { useI18n } from '@/lib/i18n';
import { getRuntimeBearerTokenSync } from '@/lib/runtime-auth';
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
import { parseMultiRunSessionTitle } from '@/lib/multirun/title';
import { MultiRunFusionDialog } from '@/components/multirun/MultiRunFusionDialog';
import { FusionIcon } from '@/components/icons/FusionIcon';
@@ -326,7 +328,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
const menuInstanceKey = `${renderContext}:${archivedBucket ? 'archived' : 'active'}:${session.id}`;
const isZombie = useViewportStore(
React.useCallback((state) => Boolean(state.sessionMemoryState.get(session.id)?.isZombie), [session.id]),
React.useCallback((state) => Boolean(state.sessionMemoryState.get(viewportSessionKey(session.id))?.isZombie), [session.id]),
);
const sessionStatus = useGlobalSessionStatus(session.id);
const sessionPermissions = useSessionPermissions(session.id, sessionDirectory ?? undefined);
@@ -447,6 +449,8 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
void invokeDesktop('desktop_open_session_mini_chat_window', {
sessionId: session.id,
directory: sessionDirectory,
apiBaseUrl: getRuntimeApiBaseUrl(),
clientToken: getRuntimeBearerTokenSync(),
}).catch((error) => {
console.warn('[session-sidebar] failed to open mini chat window', error);
});
@@ -10,7 +10,7 @@ import {
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
import { Icon } from "@/components/icon/Icon";
import { cn } from '@/lib/utils';
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { useI18n } from '@/lib/i18n';
@@ -86,23 +86,12 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
isDragging,
} = useSortable({ id });
const [imageFailed, setImageFailed] = React.useState(false);
const suppressNextToggleRef = React.useRef(false);
const menuInstanceKey = `project:${id}`;
const isMenuOpen = openSidebarMenuKey === menuInstanceKey;
React.useEffect(() => {
setImageFailed(false);
}, [id, projectIconImage?.updatedAt]);
const projectIconName = projectIcon ? PROJECT_ICON_MAP[projectIcon] : null;
const iconColor = projectColor ? (PROJECT_COLOR_MAP[projectColor] ?? null) : null;
const imageUrl = !imageFailed
? getProjectIconImageUrl({ id, iconImage: projectIconImage }, {
themeVariant: currentTheme.metadata.variant,
iconColor: currentTheme.colors.surface.foreground,
})
: null;
const handleMenuOpenChange = React.useCallback((open: boolean) => {
setOpenSidebarMenuKey(open ? menuInstanceKey : null);
@@ -179,7 +168,7 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
)}>
{isCollapsed ? <Icon name="arrow-right-s" className="h-3.5 w-3.5" /> : <Icon name="arrow-down-s" className="h-3.5 w-3.5" />}
</span>
{imageUrl ? (
{projectIconImage ? (
<span
className={cn(
'h-3.5 w-3.5 items-center justify-center overflow-hidden rounded-[3px]',
@@ -187,12 +176,18 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
)}
style={projectIconBackground ? { backgroundColor: projectIconBackground } : undefined}
>
<img
src={imageUrl}
alt=""
<ProjectIconImage
project={{ id, iconImage: projectIconImage }}
options={{
themeVariant: currentTheme.metadata.variant,
iconColor: currentTheme.colors.surface.foreground,
}}
className="h-full w-full object-contain"
draggable={false}
onError={() => setImageFailed(true)}
fallback={projectIconName ? (
<Icon name={projectIconName} className="h-3.5 w-3.5" style={iconColor ? { color: iconColor } : undefined} />
) : (
<Icon name="folder" className="h-3.5 w-3.5 text-muted-foreground/80" style={iconColor ? { color: iconColor } : undefined} />
)}
/>
</span>
) : projectIconName ? (
@@ -10,6 +10,7 @@ import { toast } from '@/components/ui';
import { Icon } from "@/components/icon/Icon";
import { useI18n } from '@/lib/i18n';
import { getDesktopAppVersion } from '@/lib/desktopNative';
import { runtimeFetch } from '@/lib/runtime-fetch';
interface AboutDialogProps {
open: boolean;
@@ -64,7 +65,7 @@ export const AboutDialog: React.FC<AboutDialogProps> = ({
const fetchVersion = async () => {
try {
const response = await fetch('/api/system/info');
const response = await runtimeFetch('/api/system/info');
if (response.ok) {
const data = await response.json();
if (typeof data.openchamberVersion === 'string' && data.openchamberVersion.trim()) {
@@ -88,7 +89,7 @@ export const AboutDialog: React.FC<AboutDialogProps> = ({
let cancelled = false;
const fetchOpenCodeVersion = async () => {
try {
const response = await fetch('/api/opencode/upgrade-status', {
const response = await runtimeFetch('/api/opencode/upgrade-status', {
headers: { Accept: 'application/json' },
});
if (!response.ok) return;
@@ -12,6 +12,7 @@ import type { UpdateInfo, UpdateProgress } from '@/lib/desktop';
import { copyTextToClipboard } from '@/lib/clipboard';
import { openExternalUrl } from '@/lib/url';
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
type WebUpdateState = 'idle' | 'updating' | 'restarting' | 'reconnecting' | 'error';
@@ -120,7 +121,7 @@ const WEB_UPDATE_MAX_WAIT_MS = 10 * 60 * 1000;
async function installWebUpdate(): Promise<InstallWebUpdateResult> {
try {
const response = await fetch('/api/openchamber/update-install', {
const response = await runtimeFetch('/api/openchamber/update-install', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
@@ -142,7 +143,7 @@ async function installWebUpdate(): Promise<InstallWebUpdateResult> {
async function isServerReachable(): Promise<boolean> {
try {
const response = await fetch('/health', {
const response = await runtimeFetch('/health', {
method: 'GET',
headers: { Accept: 'application/json' },
});
@@ -159,7 +160,7 @@ async function waitForUpdateApplied(
): Promise<boolean> {
for (let i = 0; i < maxAttempts; i++) {
try {
const response = await fetch('/api/openchamber/update-check', {
const response = await runtimeFetch('/api/openchamber/update-check', {
method: 'GET',
headers: { Accept: 'application/json' },
});
@@ -4,6 +4,7 @@ import { toast } from '@/components/ui/toast';
import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
import { useUIStore } from '@/stores/useUIStore';
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { getSafeStorage } from '@/stores/utils/safeStorage';
import {
resolveOpenCodeUpdateVersion,
@@ -51,7 +52,7 @@ export const OpenCodeUpdateToast: React.FC = () => {
});
try {
const response = await fetch('/api/opencode/upgrade', {
const response = await runtimeFetch('/api/opencode/upgrade', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -134,7 +135,7 @@ export const OpenCodeUpdateToast: React.FC = () => {
const checkForUpdate = async (attempt: number) => {
try {
const response = await fetch('/api/opencode/upgrade-status', { headers: { Accept: 'application/json' } });
const response = await runtimeFetch('/api/opencode/upgrade-status', { headers: { Accept: 'application/json' } });
if (!response.ok) throw new Error(response.statusText || 'OpenCode upgrade status check failed');
const status = await response.json().catch(() => null) as OpenCodeUpgradeStatusLike | null;
const version = resolveOpenCodeUpgradeStatusVersion(status);
+36 -6
View File
@@ -1,4 +1,5 @@
import React from 'react';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { toast } from '@/components/ui';
import { copyTextToClipboard } from '@/lib/clipboard';
@@ -35,6 +36,9 @@ import { useFileSearchStore } from '@/stores/useFileSearchStore';
import { useDeviceInfo } from '@/lib/device';
import { cn, getModifierLabel, getRevealLabelKey, hasModifier } from '@/lib/utils';
import { getLanguageFromExtension, getImageMimeType, isImageFile } from '@/lib/toolHelpers';
import { getRuntimeUrlResolver } from '@/lib/runtime-url';
import { refreshRuntimeUrlAuthToken } from '@/lib/runtime-auth';
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { EditorView } from '@codemirror/view';
import type { Extension } from '@codemirror/state';
@@ -784,6 +788,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const [fileLoading, setFileLoading] = React.useState(false);
const [fileError, setFileError] = React.useState<string | null>(null);
const [desktopImageSrc, setDesktopImageSrc] = React.useState<string>('');
const [imageAssetAuthReadyKey, setImageAssetAuthReadyKey] = React.useState('');
const [loadedFilePath, setLoadedFilePath] = React.useState<string | null>(null);
@@ -1402,7 +1407,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
if (options?.optional) {
params.set('optional', 'true');
}
const response = await fetch(`/api/fs/read?${params.toString()}`, {
const response = await runtimeFetch(`/api/fs/read?${params.toString()}`, {
// Avoid conditional requests (304 + empty body).
cache: options?.optional ? 'no-store' : 'default',
});
@@ -2573,6 +2578,31 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
[lightTheme.metadata.id, darkTheme.metadata.id],
);
const imageAssetAuthKey = selectedFile?.path && isSelectedImage && !runtime.isDesktop && !isSelectedSvg
? `${selectedFile.path}|${selectedFileReadOptions.allowOutsideWorkspace ? 'outside' : 'workspace'}`
: '';
React.useEffect(() => {
if (!imageAssetAuthKey) {
setImageAssetAuthReadyKey('');
return;
}
let cancelled = false;
setImageAssetAuthReadyKey('');
void refreshRuntimeUrlAuthToken(getRuntimeApiBaseUrl())
.then((token) => {
if (!cancelled && token) setImageAssetAuthReadyKey(imageAssetAuthKey);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [imageAssetAuthKey]);
const isImageAssetAuthLoading = Boolean(imageAssetAuthKey && imageAssetAuthReadyKey !== imageAssetAuthKey);
const imageSrc = selectedFile?.path && isSelectedImage
? (runtime.isDesktop
? (isSelectedSvg
@@ -2580,10 +2610,10 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
: desktopImageSrc)
: (isSelectedSvg
? `data:${getImageMimeType(selectedFile.path)};utf8,${encodeURIComponent(fileContent)}`
: `/api/fs/raw?${new URLSearchParams({
: imageAssetAuthReadyKey === imageAssetAuthKey ? getRuntimeUrlResolver().authenticatedAsset('/api/fs/raw', {
path: selectedFile.path,
...(selectedFileReadOptions.allowOutsideWorkspace ? { allowOutsideWorkspace: 'true' } : {}),
}).toString()}`))
allowOutsideWorkspace: selectedFileReadOptions.allowOutsideWorkspace ? 'true' : undefined,
}) : ''))
: '';
React.useEffect(() => {
@@ -3205,7 +3235,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
<ScrollableOverlay outerClassName="h-full min-w-0" className="h-full min-w-0">
{!selectedFile ? (
<div className="p-3 typography-ui text-muted-foreground">{t('filesView.editor.pickFileFromTree')}</div>
) : fileLoading ? (
) : (fileLoading || isImageAssetAuthLoading) ? (
suppressFileLoadingIndicator
? <div className="p-3" />
: (
@@ -3530,7 +3560,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
{renderFloatingFileControls({ exitFullscreenOnly: true })}
</div>
<ScrollableOverlay outerClassName="h-full min-w-0" className="h-full min-w-0">
{fileLoading ? (
{(fileLoading || isImageAssetAuthLoading) ? (
suppressFileLoadingIndicator
? <div className="p-4" />
: (
@@ -41,9 +41,13 @@ interface PierreDiffViewerProps {
layout?: 'fill' | 'inline';
}
// CSS injected into Pierre's Shadow DOM for WebKit scroll optimization
// Note: avoid will-change and contain:paint as they break resize behavior
const WEBKIT_SCROLL_FIX_CSS = `
/**
* Base CSS injected into Pierre's Shadow DOM. Pins font-family/size to the
* app tokens (so Files view and Diff view render at the same scale on mobile)
* and enables touch-friendly line interactions. Re-exported so plain
* <PierreFile> consumers (e.g. `MobileFilesSurface`) can inject the same.
*/
export const PIERRE_RUNTIME_BASE_CSS = `
:host {
font-family: var(--font-mono);
font-size: var(--text-code);
@@ -65,6 +69,13 @@ const WEBKIT_SCROLL_FIX_CSS = `
pre[data-interactive-line-numbers] [data-line-number] {
touch-action: manipulation;
}
`;
// CSS injected into Pierre's Shadow DOM for WebKit scroll optimization +
// diff-specific separator height. Note: avoid will-change and contain:paint
// as they break resize behavior.
const WEBKIT_SCROLL_FIX_CSS = `
${PIERRE_RUNTIME_BASE_CSS}
[data-diff-header],
[data-diff] {
@@ -1,4 +1,5 @@
import React from 'react';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { CodeMirrorEditor } from '@/components/ui/CodeMirrorEditor';
import { PreviewToggleButton } from './PreviewToggleButton';
import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
@@ -371,7 +372,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null }) => {
return result?.content ?? '';
}
const response = await fetch(`/api/fs/read?path=${encodeURIComponent(path)}&optional=true`, {
const response = await runtimeFetch(`/api/fs/read?path=${encodeURIComponent(path)}&optional=true`, {
// Avoid conditional requests (304 + empty body).
cache: 'no-store',
});
@@ -475,7 +476,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null }) => {
throw new Error(t('planView.error.writeFailed'));
}
} else {
const response = await fetch('/api/fs/write', {
const response = await runtimeFetch('/api/fs/write', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: resolvedPath, content }),
@@ -544,7 +545,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null }) => {
return;
}
sessionId = created.id;
directoryHint = null;
directoryHint = created.path;
} else {
const sessionResult = await createSession(undefined, currentProjectRef.path, null);
if (!sessionResult?.id) {
@@ -23,7 +23,6 @@ import { SkillsSidebar } from '@/components/sections/skills/SkillsSidebar';
import { SkillsPage } from '@/components/sections/skills/SkillsPage';
import { ProjectsSidebar } from '@/components/sections/projects/ProjectsSidebar';
import { ProjectsPage } from '@/components/sections/projects/ProjectsPage';
import { RemoteInstancesSidebar } from '@/components/sections/remote-instances/RemoteInstancesSidebar';
import { RemoteInstancesPage } from '@/components/sections/remote-instances/RemoteInstancesPage';
import { ProvidersSidebar } from '@/components/sections/providers/ProvidersSidebar';
import { ProvidersPage } from '@/components/sections/providers/ProvidersPage';
@@ -73,6 +72,8 @@ interface SettingsViewProps {
forceMobile?: boolean;
/** Rendered inside a window/dialog (skip traffic light padding) */
isWindowed?: boolean;
/** Restrict top-level settings navigation to a specific product surface. */
visiblePageSlugs?: SettingsPageSlug[];
}
const pageOrder: SettingsPageSlug[] = [
@@ -277,7 +278,7 @@ const SettingsHome: React.FC<{ onOpen: (slug: SettingsPageSlug) => void }> = ({
);
};
export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile, isWindowed }) => {
export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile, isWindowed, visiblePageSlugs }) => {
const { t } = useI18n();
const deviceInfo = useDeviceInfo();
const isMobile = forceMobile ?? deviceInfo.isMobile;
@@ -306,12 +307,14 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
const runtimeCtx = React.useMemo(() => buildRuntimeContext(isDesktopApp), [isDesktopApp]);
const visiblePages = React.useMemo(() => {
const allowedPages = visiblePageSlugs ? new Set<SettingsPageSlug>(visiblePageSlugs) : null;
return SETTINGS_PAGE_METADATA
.filter((page) => page.slug !== 'home')
.filter((page) => !allowedPages || allowedPages.has(page.slug))
.filter((page) => isPageAvailable(page, runtimeCtx))
.filter((page) => !(runtimeCtx.isVSCode && page.slug === 'projects'))
.filter((page) => !(isMobile && page.slug === 'shortcuts'));
}, [runtimeCtx, isMobile]);
}, [runtimeCtx, isMobile, visiblePageSlugs]);
const sortedFilteredPages = React.useMemo(() => {
const rank = new Map<SettingsPageSlug, number>(pageOrder.map((s, i) => [s, i]));
@@ -510,8 +513,6 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
switch (slug) {
case 'projects':
return <ProjectsSidebar onItemSelect={opts.onItemSelect} />;
case 'remote-instances':
return <RemoteInstancesSidebar onItemSelect={opts.onItemSelect} />;
case 'agents':
return <AgentsSidebar onItemSelect={opts.onItemSelect} />;
case 'commands':
@@ -840,21 +841,23 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
{isMobile ? (
<div
className={cn(
'flex items-center gap-2 px-3 py-2 border-b',
'flex h-[var(--oc-header-height,56px)] shrink-0 items-center gap-2 border-b px-3',
'bg-background'
)}
style={{ borderColor: 'var(--interactive-border)' }}
>
<button
type="button"
onClick={showBackButton ? handleBack : onClose}
aria-label={mobileBackButtonLabel}
className="inline-flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg p-2 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<Icon name="arrow-left-s" className="h-5 w-5" />
</button>
{(showBackButton || onClose) ? (
<button
type="button"
onClick={showBackButton ? handleBack : onClose}
aria-label={mobileBackButtonLabel}
className="inline-flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg p-2 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<Icon name="arrow-left-s" className="h-5 w-5" />
</button>
) : null}
<div className="min-w-0 flex-1 typography-ui-label font-medium text-foreground truncate">
<div className="min-w-0 flex-1 px-2 typography-ui-label font-medium text-foreground truncate">
{mobileStage === 'nav'
? t('settings.view.home.title')
: (activePageMeta ? getPageTitle(activePageMeta.slug) : t('settings.view.home.title'))}
@@ -15,6 +15,7 @@ import { Icon } from "@/components/icon/Icon";
import { isIMECompositionEvent } from '@/lib/ime';
import { getWorktreeSetupCommands } from '@/lib/openchamberConfig';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import type { ProjectRef } from '@/lib/openchamberConfig';
import type { CreateMultiRunParams, MultiRunFileAttachment } from '@/types/multirun';
import { useI18n } from '@/lib/i18n';
@@ -68,6 +69,7 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
const commandRef = React.useRef<CommandAutocompleteHandle>(null);
const { currentTheme } = useThemeSystem();
const { runtime } = useRuntimeAPIs();
const currentDirectory = useDirectoryStore((state) => state.currentDirectory ?? null);
const { isGitRepository, isLoading: isLoadingBranches } = useBranchOptions(currentDirectory);
@@ -79,13 +81,7 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
return typeof folder === 'string' && folder.trim().length > 0 ? folder.trim() : null;
}, []);
const isVSCodeRuntime = React.useMemo(() => {
if (typeof window === 'undefined') {
return false;
}
const apis = (window as unknown as { __OPENCHAMBER_RUNTIME_APIS__?: { runtime?: { isVSCode?: boolean } } }).__OPENCHAMBER_RUNTIME_APIS__;
return Boolean(apis?.runtime?.isVSCode);
}, []);
const isVSCodeRuntime = runtime.isVSCode;
// Get project directory for setup commands
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
@@ -8,6 +8,7 @@ import { useAgentGroupsStore } from '@/stores/useAgentGroupsStore';
import { useMultiRunStore } from '@/stores/useMultiRunStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import type { CreateMultiRunParams } from '@/types/multirun';
interface AgentManagerViewProps {
@@ -15,12 +16,8 @@ interface AgentManagerViewProps {
}
export const AgentManagerView: React.FC<AgentManagerViewProps> = ({ className }) => {
const isVSCodeRuntime = Boolean(
(typeof window !== 'undefined'
? (window as unknown as { __OPENCHAMBER_RUNTIME_APIS__?: { runtime?: { isVSCode?: boolean } } })
.__OPENCHAMBER_RUNTIME_APIS__?.runtime?.isVSCode
: false)
);
const { runtime } = useRuntimeAPIs();
const isVSCodeRuntime = runtime.isVSCode;
const [connectionStatus, setConnectionStatus] = React.useState<'connecting' | 'connected' | 'error' | 'disconnected'>(
() =>
(typeof window !== 'undefined'
@@ -49,6 +49,7 @@ interface ChangesPanelProps {
diffStats: Record<string, { insertions: number; deletions: number }> | undefined;
revertingPaths: Set<string>;
isRevertingAll?: boolean;
headerBackgroundClassName?: string;
onVisiblePathsChange?: (paths: string[]) => void;
/** Reverts every changed path across all groups; rendered once for the panel. */
onRevertAll?: (paths: string[]) => Promise<void> | void;
@@ -73,6 +74,7 @@ export const ChangesPanel: React.FC<ChangesPanelProps> = ({
diffStats,
revertingPaths,
isRevertingAll = false,
headerBackgroundClassName = 'bg-sidebar',
onVisiblePathsChange,
onRevertAll,
}) => {
@@ -290,7 +292,8 @@ export const ChangesPanel: React.FC<ChangesPanelProps> = ({
return (
<div
className={cn(
'sticky top-0 z-10 flex items-center gap-2 bg-sidebar py-2',
'sticky top-0 z-10 flex items-center gap-2 py-2',
headerBackgroundClassName,
ROW_PADDING_CLASSNAME,
!isFirst && 'mt-1 border-t border-border/40'
)}
@@ -324,7 +327,7 @@ export const ChangesPanel: React.FC<ChangesPanelProps> = ({
</div>
);
},
[collapsedGroups, toggleGroupCollapsed]
[collapsedGroups, headerBackgroundClassName, toggleGroupCollapsed]
);
const renderDirectory = React.useCallback(