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:
committed by
GitHub
parent
a4314c189b
commit
2031e3b4a8
+32
-3
@@ -32,6 +32,9 @@ import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { disposeTerminalInputTransport } from '@/lib/terminalApi';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
|
||||
import { SyncProvider } from '@/sync/sync-context';
|
||||
import { useSync } from '@/sync/use-sync';
|
||||
import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay';
|
||||
@@ -51,6 +54,7 @@ import { useI18n } from '@/lib/i18n';
|
||||
import { applyMobileKeyboardMode } from '@/lib/mobileKeyboardMode';
|
||||
import { SyncAppEffects } from '@/apps/AppEffects';
|
||||
import { useAppFontEffects } from '@/apps/useAppFontEffects';
|
||||
import { resetStreamingState } from '@/sync/streaming';
|
||||
import { OpenCodeUpdateToast } from '@/components/update/OpenCodeUpdateToast';
|
||||
|
||||
// Lazy-loaded heavy views — loaded on demand to reduce initial bundle size.
|
||||
@@ -215,6 +219,7 @@ function App({ apis }: AppProps) {
|
||||
const [isEmbeddedVisible, setIsEmbeddedVisible] = React.useState(true);
|
||||
const [initRetryExhausted, setInitRetryExhausted] = React.useState(false);
|
||||
const [initRetryEpoch, setInitRetryEpoch] = React.useState(0);
|
||||
const [runtimeEndpointEpoch, setRuntimeEndpointEpoch] = React.useState(0);
|
||||
const [manualInitRetrying, setManualInitRetrying] = React.useState(false);
|
||||
const wideChatLayoutEnabled = useUIStore((state) => state.wideChatLayoutEnabled);
|
||||
const mobileKeyboardMode = useUIStore((state) => state.mobileKeyboardMode);
|
||||
@@ -249,6 +254,30 @@ function App({ apis }: AppProps) {
|
||||
setIsVSCodeRuntime(apis.runtime.isVSCode);
|
||||
}, [apis.runtime.isVSCode]);
|
||||
|
||||
React.useEffect(() => {
|
||||
return subscribeRuntimeEndpointChanged((detail) => {
|
||||
useSessionUIStore.getState().prepareForRuntimeSwitch(detail.previousRuntimeKey);
|
||||
useUIStore.getState().prepareForRuntimeSwitch(detail.previousRuntimeKey);
|
||||
disposeTerminalInputTransport();
|
||||
opencodeClient.reconnectToRuntimeBaseUrl();
|
||||
useConfigStore.setState({
|
||||
providers: [],
|
||||
agents: [],
|
||||
isConnected: false,
|
||||
isInitialized: false,
|
||||
connectionPhase: 'connecting',
|
||||
lastDisconnectReason: null,
|
||||
});
|
||||
useProjectsStore.getState().resetForRuntimeSwitch();
|
||||
useSessionUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey);
|
||||
useUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey);
|
||||
resetStreamingState();
|
||||
setRuntimeEndpointEpoch((epoch) => epoch + 1);
|
||||
setInitRetryExhausted(false);
|
||||
setInitRetryEpoch((epoch) => epoch + 1);
|
||||
});
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
document.documentElement.classList.toggle('wide-chat-layout', wideChatLayoutEnabled);
|
||||
return () => {
|
||||
@@ -337,7 +366,7 @@ function App({ apis }: AppProps) {
|
||||
let cancelled = false;
|
||||
|
||||
const run = async () => {
|
||||
const res = await fetch('/health', { method: 'GET' }).catch(() => null);
|
||||
const res = await runtimeFetch('/health', { method: 'GET' }).catch(() => null);
|
||||
if (!res || !res.ok || cancelled) return;
|
||||
const data = (await res.json().catch(() => null)) as null | {
|
||||
planModeExperimentalEnabled?: unknown;
|
||||
@@ -810,7 +839,7 @@ function App({ apis }: AppProps) {
|
||||
if (embeddedSessionChat) {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<SyncProvider sdk={opencodeClient.getSdkClient()} directory={currentDirectory || ''}>
|
||||
<SyncProvider key={runtimeEndpointEpoch} sdk={opencodeClient.getSdkClient()} directory={currentDirectory || ''}>
|
||||
<RuntimeAPIProvider apis={apis}>
|
||||
<TooltipProvider delayDuration={300} skipDelayDuration={150}>
|
||||
<div className="h-full text-foreground bg-background">
|
||||
@@ -853,7 +882,7 @@ function App({ apis }: AppProps) {
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<SyncProvider sdk={opencodeClient.getSdkClient()} directory={currentDirectory || ''}>
|
||||
<SyncProvider key={runtimeEndpointEpoch} sdk={opencodeClient.getSdkClient()} directory={currentDirectory || ''}>
|
||||
<RuntimeAPIProvider apis={apis}>
|
||||
<FireworksProvider>
|
||||
<VoiceProvider>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useGitStore } from '@/stores/useGitStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { SyncProvider, useSessions } from '@/sync/sync-context';
|
||||
import { useSync } from '@/sync/use-sync';
|
||||
import { SyncRuntimeEffects } from './AppEffects';
|
||||
import { useAppFontEffects } from './useAppFontEffects';
|
||||
import { useMiniChatKeyboardShortcuts } from '@/hooks/useMiniChatKeyboardShortcuts';
|
||||
@@ -65,6 +66,7 @@ const MiniChatBootstrap: React.FC<{ config: MiniChatConfig }> = ({ config }) =>
|
||||
const loadAgents = useConfigStore((state) => state.loadAgents);
|
||||
const providersCount = useConfigStore((state) => state.providers.length);
|
||||
const agentsCount = useConfigStore((state) => state.agents.length);
|
||||
const sync = useSync();
|
||||
|
||||
React.useEffect(() => {
|
||||
void initializeApp();
|
||||
@@ -130,11 +132,14 @@ const MiniChatBootstrap: React.FC<{ config: MiniChatConfig }> = ({ config }) =>
|
||||
return;
|
||||
}
|
||||
const session = sessions.find((entry) => entry.id === config.sessionId);
|
||||
if (!session) return;
|
||||
if (!session) {
|
||||
void sync.ensureSessionRenderable(config.sessionId);
|
||||
return;
|
||||
}
|
||||
const directory = (session as { directory?: string | null }).directory ?? config.directory;
|
||||
setCurrentSession(config.sessionId, directory);
|
||||
sessionBootstrappedRef.current = true;
|
||||
}, [config, currentSessionId, sessions, setCurrentSession]);
|
||||
}, [config, currentSessionId, sessions, setCurrentSession, sync]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (config.mode !== 'draft' || draftOpen || currentSessionId) return;
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
RiFileTextLine,
|
||||
RiGitBranchLine,
|
||||
RiMenuLine,
|
||||
RiMore2Line,
|
||||
RiSettings3Line,
|
||||
} from '@remixicon/react';
|
||||
|
||||
import { ChatView } from '@/components/views/ChatView';
|
||||
import { SettingsView } from '@/components/views/SettingsView';
|
||||
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
|
||||
import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider';
|
||||
import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
|
||||
import { useRouter } from '@/hooks/useRouter';
|
||||
import { useWindowTitle } from '@/hooks/useWindowTitle';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import type { RuntimeAPIs } from '@/lib/api/types';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
|
||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||
import { useGitStatus, useGitStore } from '@/stores/useGitStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { SyncProvider, useSession } from '@/sync/sync-context';
|
||||
|
||||
import { SyncAppEffects } from './AppEffects';
|
||||
import { MobileChangesSurface } from './MobileChangesSurface';
|
||||
import { MobileFilesSurface } from './MobileFilesSurface';
|
||||
import { MobileSessionsSheet } from './MobileSessionsSheet';
|
||||
import { MobileSurfaceShell } from './MobileSurfaceShell';
|
||||
import { DedicatedMobileAppProvider, type MobileAppActions } from './mobileAppContext';
|
||||
import { useAppFontEffects } from './useAppFontEffects';
|
||||
|
||||
const MOBILE_SETTINGS_PAGES = [
|
||||
'appearance',
|
||||
'chat',
|
||||
'notifications',
|
||||
'sessions',
|
||||
'git',
|
||||
'magic-prompts',
|
||||
'behavior',
|
||||
'mcp',
|
||||
'providers',
|
||||
'usage',
|
||||
'voice',
|
||||
] as const;
|
||||
|
||||
type MobileAppProps = {
|
||||
apis: RuntimeAPIs;
|
||||
};
|
||||
|
||||
const normalizePath = (value?: string | null): string =>
|
||||
(value || '').replace(/\\/g, '/').replace(/\/+$/g, '');
|
||||
|
||||
const getProjectLabel = (path: string): string => {
|
||||
const normalized = normalizePath(path);
|
||||
if (!normalized) return '';
|
||||
const segments = normalized.split('/').filter(Boolean);
|
||||
return segments[segments.length - 1]?.replace(/[-_]/g, ' ') || normalized;
|
||||
};
|
||||
|
||||
type OverflowItem = {
|
||||
key: 'files' | 'changes' | 'settings';
|
||||
Icon: typeof RiFileTextLine;
|
||||
label: string;
|
||||
badge?: number;
|
||||
onSelect: () => void;
|
||||
};
|
||||
|
||||
const MobileOverflowMenu: React.FC<{
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
items: OverflowItem[];
|
||||
}> = ({ open, onClose, items }) => {
|
||||
const { t } = useI18n();
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleKey = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', handleKey);
|
||||
return () => document.removeEventListener('keydown', handleKey);
|
||||
}, [onClose, open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true" aria-label={t('mobile.menu.titleAria')}>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 cursor-default bg-[rgb(0_0_0_/_0.25)]"
|
||||
aria-label={t('mobile.surface.closeAria')}
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div
|
||||
className="absolute right-2 top-[calc(var(--oc-safe-area-top,0px)+56px+4px)] w-[min(220px,calc(100vw-1rem))] origin-top-right overflow-hidden rounded-2xl border border-border/40 bg-background shadow-[0_18px_60px_rgb(0_0_0_/_0.35)]"
|
||||
role="menu"
|
||||
style={{ animation: 'mobile-menu-in 160ms cubic-bezier(0.32, 0.72, 0, 1)' }}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset',
|
||||
index > 0 && 'border-t border-border/30',
|
||||
)}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
onClick={() => {
|
||||
item.onSelect();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<item.Icon className="size-5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate typography-ui-label text-foreground">{item.label}</span>
|
||||
{item.badge && item.badge > 0 ? (
|
||||
<span className="inline-flex size-2 shrink-0 rounded-full bg-primary" aria-hidden />
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<style>{`@keyframes mobile-menu-in { from { opacity: 0; transform: translateY(-6px) scale(0.96); } to { opacity: 1; transform: translateY(0) scale(1); } }`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MobileHeader: React.FC<{
|
||||
onOpenSessions: () => void;
|
||||
onOpenMenu: () => void;
|
||||
}> = ({ onOpenSessions, onOpenMenu }) => {
|
||||
const { t } = useI18n();
|
||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const currentSession = useSession(currentSessionId, currentDirectory || undefined);
|
||||
|
||||
const projectLabel = React.useMemo(() => {
|
||||
const directory = normalizePath(currentDirectory);
|
||||
if (!directory) return t('mobile.header.noProject');
|
||||
const project = projects.find((entry) => {
|
||||
const projectPath = normalizePath(entry.path);
|
||||
return directory === projectPath || directory.startsWith(`${projectPath}/`);
|
||||
});
|
||||
return project?.label?.trim() || getProjectLabel(project?.path || directory);
|
||||
}, [currentDirectory, projects, t]);
|
||||
|
||||
const sessionTitle = currentSession?.title?.trim();
|
||||
const primaryLabel = sessionTitle || projectLabel;
|
||||
const secondaryLabel = sessionTitle ? projectLabel : currentSessionId ? t('mobile.sessions.untitled') : '';
|
||||
|
||||
return (
|
||||
<header
|
||||
className="relative z-30 flex shrink-0 items-center gap-1 border-b border-border/30 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80"
|
||||
style={{ paddingTop: 'var(--oc-safe-area-top, 0px)' }}
|
||||
>
|
||||
<div className="flex h-[var(--oc-header-height,56px)] w-full items-center gap-1 px-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('mobile.sessions.openSheetAria')}
|
||||
onClick={onOpenSessions}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<RiMenuLine className="size-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center rounded-full px-2 py-1.5 text-left transition-colors hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('mobile.sessions.openSheetAria')}
|
||||
onClick={onOpenSessions}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 flex-col leading-tight">
|
||||
<span className="block truncate typography-ui-label text-foreground">{primaryLabel}</span>
|
||||
{secondaryLabel ? (
|
||||
<span className="block truncate typography-micro text-muted-foreground">{secondaryLabel}</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('mobile.header.openMenuAria')}
|
||||
onClick={onOpenMenu}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<RiMore2Line className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
const MobileShell: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const [sessionsSheetOpen, setSessionsSheetOpen] = React.useState(false);
|
||||
const [filesOpen, setFilesOpen] = React.useState(false);
|
||||
const [changesOpen, setChangesOpen] = React.useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = React.useState(false);
|
||||
const [overflowOpen, setOverflowOpen] = React.useState(false);
|
||||
// When set, the Changes surface opens directly into the per-file diff for this path.
|
||||
const [pendingChangesDiff, setPendingChangesDiff] = React.useState<{ path: string; staged: boolean } | null>(null);
|
||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||
const gitStatus = useGitStatus(normalizePath(currentDirectory) || null);
|
||||
const dirtyChangeCount = gitStatus?.files?.length ?? 0;
|
||||
|
||||
const mobileActions = React.useMemo<MobileAppActions>(
|
||||
() => ({
|
||||
openChanges: ({ diffPath, staged } = {}) => {
|
||||
setPendingChangesDiff(diffPath ? { path: diffPath, staged: staged === true } : null);
|
||||
setChangesOpen(true);
|
||||
},
|
||||
openFiles: () => setFilesOpen(true),
|
||||
openSettings: () => setSettingsOpen(true),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const closeChanges = React.useCallback(() => {
|
||||
setChangesOpen(false);
|
||||
setPendingChangesDiff(null);
|
||||
}, []);
|
||||
|
||||
const overflowItems: OverflowItem[] = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'files',
|
||||
Icon: RiFileTextLine,
|
||||
label: t('mobile.menu.files'),
|
||||
onSelect: () => setFilesOpen(true),
|
||||
},
|
||||
{
|
||||
key: 'changes',
|
||||
Icon: RiGitBranchLine,
|
||||
label: t('mobile.menu.changes'),
|
||||
badge: dirtyChangeCount,
|
||||
onSelect: () => setChangesOpen(true),
|
||||
},
|
||||
{
|
||||
key: 'settings',
|
||||
Icon: RiSettings3Line,
|
||||
label: t('mobile.menu.settings'),
|
||||
onSelect: () => setSettingsOpen(true),
|
||||
},
|
||||
],
|
||||
[dirtyChangeCount, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<DedicatedMobileAppProvider actions={mobileActions}>
|
||||
<div
|
||||
className="main-content-safe-area flex h-[100dvh] flex-col bg-background text-foreground"
|
||||
data-page-scroll-lock="true"
|
||||
>
|
||||
<MobileHeader
|
||||
onOpenSessions={() => setSessionsSheetOpen(true)}
|
||||
onOpenMenu={() => setOverflowOpen(true)}
|
||||
/>
|
||||
<main className="relative min-h-0 flex-1 overflow-hidden" data-page-scroll-lock="true">
|
||||
<ErrorBoundary>
|
||||
<ChatView />
|
||||
</ErrorBoundary>
|
||||
</main>
|
||||
|
||||
<MobileOverflowMenu
|
||||
open={overflowOpen}
|
||||
onClose={() => setOverflowOpen(false)}
|
||||
items={overflowItems}
|
||||
/>
|
||||
|
||||
{sessionsSheetOpen ? (
|
||||
<MobileSessionsSheet open={sessionsSheetOpen} onOpenChange={setSessionsSheetOpen} />
|
||||
) : null}
|
||||
|
||||
<MobileSurfaceShell
|
||||
open={filesOpen}
|
||||
onClose={() => setFilesOpen(false)}
|
||||
ariaLabel={t('mobile.menu.files')}
|
||||
headerless
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<MobileFilesSurface onClose={() => setFilesOpen(false)} />
|
||||
</ErrorBoundary>
|
||||
</MobileSurfaceShell>
|
||||
|
||||
<MobileSurfaceShell
|
||||
open={changesOpen}
|
||||
onClose={closeChanges}
|
||||
ariaLabel={t('mobile.menu.changes')}
|
||||
headerless
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<MobileChangesSurface
|
||||
onClose={closeChanges}
|
||||
initialDiffPath={pendingChangesDiff?.path ?? null}
|
||||
initialDiffStaged={pendingChangesDiff?.staged === true}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</MobileSurfaceShell>
|
||||
|
||||
<MobileSurfaceShell
|
||||
open={settingsOpen}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
ariaLabel={t('mobile.menu.settings')}
|
||||
headerless
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<SettingsView
|
||||
forceMobile
|
||||
isWindowed
|
||||
visiblePageSlugs={[...MOBILE_SETTINGS_PAGES]}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</MobileSurfaceShell>
|
||||
</div>
|
||||
</DedicatedMobileAppProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export function MobileApp({ apis }: MobileAppProps) {
|
||||
const initializeApp = useConfigStore((state) => state.initializeApp);
|
||||
const isInitialized = useConfigStore((state) => state.isInitialized);
|
||||
const isConnected = useConfigStore((state) => state.isConnected);
|
||||
const providersCount = useConfigStore((state) => state.providers.length);
|
||||
const agentsCount = useConfigStore((state) => state.agents.length);
|
||||
const loadProviders = useConfigStore((state) => state.loadProviders);
|
||||
const loadAgents = useConfigStore((state) => state.loadAgents);
|
||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||
const error = useSessionUIStore((state) => state.error);
|
||||
const clearError = useSessionUIStore((state) => state.clearError);
|
||||
const setIsMobile = useUIStore((state) => state.setIsMobile);
|
||||
const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus);
|
||||
const setPlanModeEnabled = useFeatureFlagsStore((state) => state.setPlanModeEnabled);
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
|
||||
React.useEffect(() => {
|
||||
registerRuntimeAPIs(apis);
|
||||
return () => registerRuntimeAPIs(null);
|
||||
}, [apis]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setIsMobile(true);
|
||||
}, [setIsMobile]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void initializeApp();
|
||||
}, [initializeApp]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isConnected) return;
|
||||
if (providersCount === 0) void loadProviders();
|
||||
if (agentsCount === 0) void loadAgents();
|
||||
}, [agentsCount, isConnected, loadAgents, loadProviders, providersCount]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isConnected) return;
|
||||
opencodeClient.setDirectory(currentDirectory);
|
||||
}, [currentDirectory, isConnected]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void refreshGitHubAuthStatus(apis.github, { force: true });
|
||||
}, [apis.github, refreshGitHubAuthStatus]);
|
||||
|
||||
// Discover all worktrees for every known project so the draft session's
|
||||
// worktree/branch dropdown can list every available branch — not only the
|
||||
// current one. Mirrors ElectronMiniChatApp + desktop SessionSidebar.
|
||||
React.useEffect(() => {
|
||||
if (projects.length === 0) return;
|
||||
let cancelled = false;
|
||||
|
||||
const run = async () => {
|
||||
const worktreesByProject = new Map<string, WorktreeMetadata[]>();
|
||||
const allWorktrees: WorktreeMetadata[] = [];
|
||||
|
||||
await Promise.all(
|
||||
projects.map(async (project) => {
|
||||
const projectPath = project.path.replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
if (!projectPath) return;
|
||||
try {
|
||||
const cachedIsGitRepo = useGitStore.getState().directories.get(projectPath)?.isGitRepo;
|
||||
const isGitRepo =
|
||||
cachedIsGitRepo ?? (await import('@/lib/gitApi').then((m) => m.checkIsGitRepository(projectPath)));
|
||||
if (!isGitRepo) return;
|
||||
const worktrees = await listProjectWorktrees({ id: project.id, path: projectPath });
|
||||
if (cancelled || worktrees.length === 0) return;
|
||||
worktreesByProject.set(projectPath, worktrees);
|
||||
allWorktrees.push(...worktrees);
|
||||
} catch {
|
||||
// Worktree discovery is best-effort; draft selector falls back to the project root.
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
if (cancelled) return;
|
||||
useSessionUIStore.setState({
|
||||
availableWorktrees: allWorktrees,
|
||||
availableWorktreesByProject: worktreesByProject,
|
||||
});
|
||||
};
|
||||
|
||||
void run();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projects]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const run = async () => {
|
||||
const res = await runtimeFetch('/health', { method: 'GET' }).catch(() => null);
|
||||
if (!res || !res.ok || cancelled) return;
|
||||
const data = (await res.json().catch(() => null)) as null | { planModeExperimentalEnabled?: unknown };
|
||||
if (!data || cancelled) return;
|
||||
const raw = data.planModeExperimentalEnabled;
|
||||
setPlanModeEnabled(raw === true || raw === 1 || raw === '1' || raw === 'true');
|
||||
};
|
||||
|
||||
void run();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [setPlanModeEnabled]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!error) return;
|
||||
const timeout = window.setTimeout(() => clearError(), 5000);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [clearError, error]);
|
||||
|
||||
useAppFontEffects();
|
||||
usePushVisibilityBeacon({ enabled: true });
|
||||
useWindowTitle();
|
||||
useRouter();
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<SyncProvider sdk={opencodeClient.getSdkClient()} directory={currentDirectory || ''}>
|
||||
<RuntimeAPIProvider apis={apis}>
|
||||
<TooltipProvider delayDuration={300} skipDelayDuration={150}>
|
||||
<div className="h-full bg-background text-foreground">
|
||||
<SyncAppEffects embeddedBackgroundWorkEnabled={isInitialized} />
|
||||
<MobileShell />
|
||||
<Toaster />
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</RuntimeAPIProvider>
|
||||
</SyncProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,617 @@
|
||||
import React from 'react';
|
||||
import { RiArrowLeftLine, RiCloseLine, RiGitBranchLine, RiLoader4Line } from '@remixicon/react';
|
||||
|
||||
import { toast } from '@/components/ui';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollShadow } from '@/components/ui/ScrollShadow';
|
||||
import { ChangesPanel, type ChangesGroupConfig } from '@/components/views/git/ChangesPanel';
|
||||
import { CommitSection } from '@/components/views/git/CommitSection';
|
||||
import { SyncActions } from '@/components/views/git/SyncActions';
|
||||
import { PierreDiffViewer } from '@/components/views/PierreDiffViewer';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import type { GitStatus } from '@/lib/api/types';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { generateCommitMessage, stageGitFile, stageGitFiles, unstageGitFile, unstageGitFiles } from '@/lib/gitApi';
|
||||
import type { GitRemote } from '@/lib/gitApi';
|
||||
import { getLanguageFromExtension, isImageFile } from '@/lib/toolHelpers';
|
||||
import {
|
||||
useGitStore,
|
||||
useGitStatus,
|
||||
useIsGitRepo,
|
||||
useGitLoadingStatus,
|
||||
} from '@/stores/useGitStore';
|
||||
|
||||
type SyncAction = 'fetch' | 'pull' | 'push' | 'sync' | null;
|
||||
type CommitAction = 'commit' | 'commitAndPush' | null;
|
||||
|
||||
const normalizePath = (value?: string | null): string => (value || '').replace(/\\/g, '/').replace(/\/+$/g, '');
|
||||
|
||||
const isStagedStatusFile = (file: GitStatus['files'][number]): boolean => {
|
||||
const indexStatus = file.index?.trim();
|
||||
return Boolean(indexStatus && indexStatus !== '?');
|
||||
};
|
||||
|
||||
const isUnstagedStatusFile = (file: GitStatus['files'][number]): boolean => {
|
||||
const workingStatus = file.working_dir?.trim();
|
||||
const indexStatus = file.index?.trim();
|
||||
return Boolean(workingStatus || indexStatus === '?');
|
||||
};
|
||||
|
||||
const diffCacheKey = (path: string, staged: boolean): string => staged ? `${path}\u0000staged` : path;
|
||||
|
||||
type MobileChangesSurfaceProps = {
|
||||
/** When provided, the list header gets a close X that calls this; used when the surface is hosted in MobileSurfaceShell. */
|
||||
onClose?: () => void;
|
||||
/**
|
||||
* When set (and non-null), the surface opens directly into the per-file diff view for this
|
||||
* relative path. Updating it (incl. setting it to a different path while open) routes the
|
||||
* surface to that diff. Setting it back to null leaves the user on the current internal route.
|
||||
*/
|
||||
initialDiffPath?: string | null;
|
||||
initialDiffStaged?: boolean;
|
||||
};
|
||||
|
||||
export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onClose, initialDiffPath, initialDiffStaged = false }) => {
|
||||
const { t } = useI18n();
|
||||
const { git } = useRuntimeAPIs();
|
||||
const currentDirectory = normalizePath(useEffectiveDirectory() ?? null);
|
||||
const status = useGitStatus(currentDirectory || null);
|
||||
const isGitRepo = useIsGitRepo(currentDirectory || null);
|
||||
const isLoadingStatus = useGitLoadingStatus(currentDirectory || null);
|
||||
const setActiveDirectory = useGitStore((state) => state.setActiveDirectory);
|
||||
const ensureAll = useGitStore((state) => state.ensureAll);
|
||||
const fetchStatus = useGitStore((state) => state.fetchStatus);
|
||||
const fetchBranches = useGitStore((state) => state.fetchBranches);
|
||||
const prefetchDiffs = useGitStore((state) => state.prefetchDiffs);
|
||||
const getDiff = useGitStore((state) => state.getDiff);
|
||||
const setDiff = useGitStore((state) => state.setDiff);
|
||||
|
||||
const [route, setRoute] = React.useState<{ type: 'list' } | { type: 'diff'; path: string; staged: boolean }>(
|
||||
() => (initialDiffPath ? { type: 'diff', path: initialDiffPath, staged: initialDiffStaged } : { type: 'list' }),
|
||||
);
|
||||
|
||||
// Allow the host (MobileApp) to push us into a specific diff when the surface
|
||||
// is reopened or when an external trigger (e.g. PendingChangesBar tap) requests
|
||||
// a different file mid-session.
|
||||
React.useEffect(() => {
|
||||
if (!initialDiffPath) return;
|
||||
setRoute((current) => (
|
||||
current.type === 'diff' && current.path === initialDiffPath && current.staged === initialDiffStaged
|
||||
? current
|
||||
: { type: 'diff', path: initialDiffPath, staged: initialDiffStaged }
|
||||
));
|
||||
}, [initialDiffPath, initialDiffStaged]);
|
||||
const [syncAction, setSyncAction] = React.useState<SyncAction>(null);
|
||||
const [commitAction, setCommitAction] = React.useState<CommitAction>(null);
|
||||
const [commitMessage, setCommitMessage] = React.useState('');
|
||||
const [revertingPaths, setRevertingPaths] = React.useState<Set<string>>(new Set());
|
||||
const [isRevertingAll, setIsRevertingAll] = React.useState(false);
|
||||
const [isGeneratingMessage, setIsGeneratingMessage] = React.useState(false);
|
||||
const [generatedHighlights, setGeneratedHighlights] = React.useState<string[]>([]);
|
||||
const [visibleChangePaths, setVisibleChangePaths] = React.useState<string[]>([]);
|
||||
const [remotes, setRemotes] = React.useState<GitRemote[]>([]);
|
||||
const [remoteUrl, setRemoteUrl] = React.useState<string | null>(null);
|
||||
const [diffLoadError, setDiffLoadError] = React.useState<string | null>(null);
|
||||
const [diffRetryNonce, setDiffRetryNonce] = React.useState(0);
|
||||
|
||||
const changeEntries = React.useMemo(() => {
|
||||
const files = status?.files ?? [];
|
||||
const unique = new Map<string, (typeof files)[number]>();
|
||||
for (const file of files) {
|
||||
unique.set(file.path, file);
|
||||
}
|
||||
return Array.from(unique.values()).sort((a, b) => a.path.localeCompare(b.path));
|
||||
}, [status?.files]);
|
||||
|
||||
const stagedChangeEntries = React.useMemo(
|
||||
() => changeEntries.filter(isStagedStatusFile),
|
||||
[changeEntries],
|
||||
);
|
||||
|
||||
const unstagedChangeEntries = React.useMemo(
|
||||
() => changeEntries.filter(isUnstagedStatusFile),
|
||||
[changeEntries],
|
||||
);
|
||||
|
||||
const effectiveRemotes = React.useMemo<GitRemote[]>(() => {
|
||||
if (remotes.length > 0) return remotes;
|
||||
const trackingRemote = status?.tracking?.includes('/') ? status.tracking.split('/')[0] : null;
|
||||
if (trackingRemote || remoteUrl) {
|
||||
return [{ name: trackingRemote || 'origin', fetchUrl: remoteUrl ?? '', pushUrl: remoteUrl ?? '' }];
|
||||
}
|
||||
return [];
|
||||
}, [remoteUrl, remotes, status?.tracking]);
|
||||
|
||||
const selectedDiff = useGitStore(React.useCallback((state) => {
|
||||
if (!currentDirectory || route.type !== 'diff') return null;
|
||||
return state.directories.get(currentDirectory)?.diffCache.get(diffCacheKey(route.path, route.staged)) ?? null;
|
||||
}, [currentDirectory, route]));
|
||||
|
||||
const selectedFileEntry = React.useMemo(() => {
|
||||
if (route.type !== 'diff') return null;
|
||||
return changeEntries.find((entry) => entry.path === route.path) ?? null;
|
||||
}, [changeEntries, route]);
|
||||
|
||||
const refreshStatusAndBranches = React.useCallback(async (showErrors = true) => {
|
||||
if (!currentDirectory) return;
|
||||
try {
|
||||
await Promise.all([
|
||||
fetchStatus(currentDirectory, git),
|
||||
fetchBranches(currentDirectory, git),
|
||||
]);
|
||||
} catch (error) {
|
||||
if (showErrors) {
|
||||
toast.error(error instanceof Error ? error.message : t('gitView.toast.refreshRepositoryFailed'));
|
||||
}
|
||||
}
|
||||
}, [currentDirectory, fetchBranches, fetchStatus, git, t]);
|
||||
|
||||
const refreshRemotes = React.useCallback(async () => {
|
||||
if (!currentDirectory) {
|
||||
setRemotes([]);
|
||||
setRemoteUrl(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const [remoteList, url] = await Promise.all([
|
||||
git.getRemotes(currentDirectory).catch(() => []),
|
||||
git.getRemoteUrl ? git.getRemoteUrl(currentDirectory).catch(() => null) : Promise.resolve(null),
|
||||
]);
|
||||
setRemotes(remoteList);
|
||||
setRemoteUrl(url);
|
||||
} catch {
|
||||
setRemotes([]);
|
||||
setRemoteUrl(null);
|
||||
}
|
||||
}, [currentDirectory, git]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!currentDirectory) return;
|
||||
setActiveDirectory(currentDirectory);
|
||||
void ensureAll(currentDirectory, git);
|
||||
}, [currentDirectory, ensureAll, git, setActiveDirectory]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void refreshRemotes();
|
||||
}, [refreshRemotes]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!currentDirectory || changeEntries.length === 0) return;
|
||||
const orderedPaths = Array.from(new Set([
|
||||
...stagedChangeEntries.map((entry) => entry.path),
|
||||
...visibleChangePaths,
|
||||
...changeEntries.slice(0, 20).map((entry) => entry.path),
|
||||
])).filter(Boolean);
|
||||
if (orderedPaths.length === 0) return;
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
void prefetchDiffs(currentDirectory, git, orderedPaths, { maxFiles: 40 });
|
||||
}, 120);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [changeEntries, currentDirectory, git, prefetchDiffs, stagedChangeEntries, visibleChangePaths]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (route.type !== 'diff') {
|
||||
setDiffLoadError(null);
|
||||
return;
|
||||
}
|
||||
const cacheKey = diffCacheKey(route.path, route.staged);
|
||||
if (!currentDirectory || getDiff(currentDirectory, cacheKey)) {
|
||||
setDiffLoadError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setDiffLoadError(null);
|
||||
void git.getGitFileDiff(currentDirectory, { path: route.path, staged: route.staged || undefined })
|
||||
.then((response) => {
|
||||
if (cancelled) return;
|
||||
setDiff(currentDirectory, cacheKey, {
|
||||
original: response.original ?? '',
|
||||
modified: response.modified ?? '',
|
||||
isBinary: response.isBinary,
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) return;
|
||||
setDiffLoadError(error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentDirectory, diffRetryNonce, getDiff, git, route, setDiff]);
|
||||
|
||||
const handleSyncAction = async (action: Exclude<SyncAction, null>, remote?: GitRemote) => {
|
||||
if (!currentDirectory) return;
|
||||
setSyncAction(action);
|
||||
try {
|
||||
const getPullOptions = (pullRemote: GitRemote) => {
|
||||
const trackingPrefix = `${pullRemote.name}/`;
|
||||
const trackedBranch = status?.tracking?.startsWith(trackingPrefix)
|
||||
? status.tracking.slice(trackingPrefix.length)
|
||||
: undefined;
|
||||
return { remote: pullRemote.name, branch: trackedBranch, rebase: true };
|
||||
};
|
||||
|
||||
if (action === 'fetch') {
|
||||
if (!remote) throw new Error(t('mobile.changes.noRemote'));
|
||||
await git.gitFetch(currentDirectory, { remote: remote.name });
|
||||
toast.success(t('gitView.toast.fetchedFromRemote', { name: remote.name }));
|
||||
} else if (action === 'sync') {
|
||||
if (!remote) throw new Error(t('mobile.changes.noRemote'));
|
||||
await git.gitFetch(currentDirectory, { remote: remote.name });
|
||||
const afterFetch = await git.getGitStatus(currentDirectory);
|
||||
if ((afterFetch.behind ?? 0) > 0) {
|
||||
if ((afterFetch.files?.length ?? 0) > 0) {
|
||||
toast.error(t('gitView.toast.commitOrStashBeforeSync'));
|
||||
return;
|
||||
}
|
||||
await git.gitPull(currentDirectory, getPullOptions(remote));
|
||||
}
|
||||
const afterPull = await git.getGitStatus(currentDirectory);
|
||||
if ((afterPull.ahead ?? 0) > 0) {
|
||||
await git.gitPush(currentDirectory);
|
||||
}
|
||||
toast.success(t('gitView.toast.alreadyUpToDate'));
|
||||
}
|
||||
await refreshStatusAndBranches(false);
|
||||
await refreshRemotes();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t('gitView.toast.syncActionFailed', { action: t('gitView.sync.syncChanges') }));
|
||||
} finally {
|
||||
setSyncAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const moveChangePaths = React.useCallback(async (paths: string[], direction: 'stage' | 'unstage') => {
|
||||
if (!currentDirectory || paths.length === 0) return;
|
||||
try {
|
||||
if (direction === 'stage') {
|
||||
if (paths.length > 1) await stageGitFiles(currentDirectory, paths);
|
||||
else await stageGitFile(currentDirectory, paths[0]);
|
||||
} else {
|
||||
if (paths.length > 1) await unstageGitFiles(currentDirectory, paths);
|
||||
else await unstageGitFile(currentDirectory, paths[0]);
|
||||
}
|
||||
await refreshStatusAndBranches(false);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : direction === 'stage'
|
||||
? t('gitView.toast.stageFileFailed')
|
||||
: t('gitView.toast.unstageFileFailed'));
|
||||
}
|
||||
}, [currentDirectory, refreshStatusAndBranches, t]);
|
||||
|
||||
const handleViewChangeDiff = React.useCallback((path: string, staged = false) => {
|
||||
setRoute({ type: 'diff', path, staged });
|
||||
}, []);
|
||||
|
||||
const handleRevertFile = React.useCallback(async (filePath: string) => {
|
||||
if (!currentDirectory) return;
|
||||
setRevertingPaths((previous) => new Set(previous).add(filePath));
|
||||
try {
|
||||
await git.revertGitFile(currentDirectory, filePath);
|
||||
toast.success(t('gitView.toast.revertedFile', { path: filePath }));
|
||||
await refreshStatusAndBranches(false);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t('gitView.toast.revertFailed'));
|
||||
} finally {
|
||||
setRevertingPaths((previous) => {
|
||||
const next = new Set(previous);
|
||||
next.delete(filePath);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [currentDirectory, git, refreshStatusAndBranches, t]);
|
||||
|
||||
const handleRevertAll = React.useCallback(async (paths: string[]) => {
|
||||
if (!currentDirectory || paths.length === 0 || isRevertingAll) return;
|
||||
const uniquePaths = Array.from(new Set(paths));
|
||||
setIsRevertingAll(true);
|
||||
setRevertingPaths(new Set(uniquePaths));
|
||||
try {
|
||||
await Promise.all(uniquePaths.map((filePath) => git.revertGitFile(currentDirectory, filePath)));
|
||||
await refreshStatusAndBranches(false);
|
||||
toast.success(uniquePaths.length === 1
|
||||
? t('gitView.toast.revertedFilesSingle', { count: uniquePaths.length })
|
||||
: t('gitView.toast.revertedFilesPlural', { count: uniquePaths.length }));
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t('gitView.toast.revertFailed'));
|
||||
} finally {
|
||||
setRevertingPaths(new Set());
|
||||
setIsRevertingAll(false);
|
||||
}
|
||||
}, [currentDirectory, git, isRevertingAll, refreshStatusAndBranches, t]);
|
||||
|
||||
const handleInsertHighlights = React.useCallback((highlights: string[]) => {
|
||||
const normalized = highlights.map((text) => text.trim()).filter(Boolean);
|
||||
if (normalized.length === 0) {
|
||||
setGeneratedHighlights([]);
|
||||
return;
|
||||
}
|
||||
setCommitMessage((current) => `${current.trim()}${current.trim() ? '\n\n' : ''}${normalized.join('\n')}`.trim());
|
||||
setGeneratedHighlights([]);
|
||||
}, []);
|
||||
|
||||
const handleGenerateCommitMessage = React.useCallback(async () => {
|
||||
if (!currentDirectory) return;
|
||||
const selectedFilePaths = stagedChangeEntries.map((file) => file.path).sort();
|
||||
if (selectedFilePaths.length === 0) {
|
||||
toast.error(t('gitView.toast.selectFileToDescribe'));
|
||||
return;
|
||||
}
|
||||
setIsGeneratingMessage(true);
|
||||
try {
|
||||
const { message } = await generateCommitMessage(currentDirectory, selectedFilePaths);
|
||||
setCommitMessage(message.subject?.trim() ?? '');
|
||||
setGeneratedHighlights(Array.isArray(message.highlights) ? message.highlights : []);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t('gitView.toast.generateCommitMessageFailed'));
|
||||
} finally {
|
||||
setIsGeneratingMessage(false);
|
||||
}
|
||||
}, [currentDirectory, stagedChangeEntries, t]);
|
||||
|
||||
const handleCommit = async (options: { pushAfter?: boolean } = {}) => {
|
||||
if (!currentDirectory) return;
|
||||
if (!commitMessage.trim()) {
|
||||
toast.error(t('gitView.toast.enterCommitMessage'));
|
||||
return;
|
||||
}
|
||||
const filesToCommit = stagedChangeEntries.map((file) => file.path).sort();
|
||||
if (filesToCommit.length === 0) {
|
||||
toast.error(t('gitView.toast.selectFileToCommit'));
|
||||
return;
|
||||
}
|
||||
|
||||
setCommitAction(options.pushAfter ? 'commitAndPush' : 'commit');
|
||||
try {
|
||||
await git.createGitCommit(currentDirectory, commitMessage.trim(), { files: filesToCommit });
|
||||
toast.success(t('gitView.toast.commitCreated'));
|
||||
setCommitMessage('');
|
||||
setGeneratedHighlights([]);
|
||||
|
||||
if (options.pushAfter) {
|
||||
const trackingRemoteName = status?.tracking?.split('/')[0];
|
||||
const remote = effectiveRemotes.find((entry) => entry.name === trackingRemoteName) ?? effectiveRemotes[0];
|
||||
if (!remote) throw new Error(t('mobile.changes.noRemote'));
|
||||
setSyncAction('sync');
|
||||
const trackingPrefix = `${remote.name}/`;
|
||||
const trackedBranch = status?.tracking?.startsWith(trackingPrefix)
|
||||
? status.tracking.slice(trackingPrefix.length)
|
||||
: undefined;
|
||||
|
||||
await git.gitFetch(currentDirectory, { remote: remote.name });
|
||||
const afterFetch = await git.getGitStatus(currentDirectory);
|
||||
if ((afterFetch.behind ?? 0) > 0) {
|
||||
await git.gitPull(currentDirectory, { remote: remote.name, branch: trackedBranch, rebase: true });
|
||||
}
|
||||
|
||||
const afterPull = await git.getGitStatus(currentDirectory);
|
||||
if ((afterPull.ahead ?? 0) > 0) {
|
||||
await git.gitPush(currentDirectory);
|
||||
}
|
||||
|
||||
await refreshStatusAndBranches(false);
|
||||
await refreshRemotes();
|
||||
} else {
|
||||
await refreshStatusAndBranches(false);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t('gitView.toast.createCommitFailed'));
|
||||
} finally {
|
||||
setCommitAction(null);
|
||||
if (options.pushAfter) setSyncAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const changeGroups = React.useMemo<ChangesGroupConfig[]>(() => {
|
||||
const groups: ChangesGroupConfig[] = [];
|
||||
|
||||
if (stagedChangeEntries.length > 0) {
|
||||
groups.push({
|
||||
id: 'staged',
|
||||
title: t('gitView.changes.stagedTitle'),
|
||||
entries: stagedChangeEntries,
|
||||
actionSymbol: '-',
|
||||
actionAllLabel: t('gitView.changes.unstageAllAria'),
|
||||
getActionLabel: (path: string) => t('gitView.changes.unstageFileAria', { path }),
|
||||
onActionFile: (path: string) => void moveChangePaths([path], 'unstage'),
|
||||
onActionAll: (paths: string[]) => void moveChangePaths(paths, 'unstage'),
|
||||
onViewDiff: (path: string) => handleViewChangeDiff(path, true),
|
||||
onRevertFile: handleRevertFile,
|
||||
showRevertActions: false,
|
||||
accent: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (unstagedChangeEntries.length > 0) {
|
||||
groups.push({
|
||||
id: 'unstaged',
|
||||
title: t('gitView.changes.title'),
|
||||
entries: unstagedChangeEntries,
|
||||
actionSymbol: '+',
|
||||
actionAllLabel: t('gitView.changes.stageAllAria'),
|
||||
getActionLabel: (path: string) => t('gitView.changes.stageFileAria', { path }),
|
||||
onActionFile: (path: string) => void moveChangePaths([path], 'stage'),
|
||||
onActionAll: (paths: string[]) => void moveChangePaths(paths, 'stage'),
|
||||
onViewDiff: (path: string) => handleViewChangeDiff(path, false),
|
||||
onRevertFile: handleRevertFile,
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
}, [handleRevertFile, handleViewChangeDiff, moveChangePaths, stagedChangeEntries, t, unstagedChangeEntries]);
|
||||
|
||||
if (!currentDirectory) {
|
||||
return <MobileChangesState message={t('gitView.empty.selectSessionOrDirectory')} />;
|
||||
}
|
||||
|
||||
if (isLoadingStatus && isGitRepo === null) {
|
||||
return <MobileChangesState loading message={t('gitView.loading.checkingRepository')} />;
|
||||
}
|
||||
|
||||
if (isGitRepo === false) {
|
||||
return <MobileChangesState icon message={t('gitView.empty.notGitRepository')} description={t('gitView.empty.notGitRepositoryDescription')} />;
|
||||
}
|
||||
|
||||
if (route.type === 'diff') {
|
||||
return (
|
||||
<MobileDiffDetail
|
||||
path={route.path}
|
||||
diff={selectedDiff}
|
||||
fileExists={Boolean(selectedFileEntry)}
|
||||
error={diffLoadError}
|
||||
onBack={() => setRoute({ type: 'list' })}
|
||||
onRetry={() => setDiffRetryNonce((value) => value + 1)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-background text-foreground">
|
||||
<header className="flex h-[var(--oc-header-height,56px)] shrink-0 items-center gap-2 px-3 text-foreground">
|
||||
{onClose ? (
|
||||
<button
|
||||
type="button"
|
||||
className="-ml-1 flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('mobile.surface.closeAria')}
|
||||
onClick={onClose}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<RiCloseLine className="size-5" />
|
||||
</button>
|
||||
) : null}
|
||||
<div className="min-w-0 flex-1 px-1">
|
||||
<h2 className="typography-ui-label text-foreground">{t('mobile.nav.changes')}</h2>
|
||||
<p className="truncate typography-micro text-muted-foreground">
|
||||
{status?.current || currentDirectory}
|
||||
</p>
|
||||
</div>
|
||||
<SyncActions
|
||||
syncAction={syncAction}
|
||||
remotes={effectiveRemotes}
|
||||
onFetch={(remote) => void handleSyncAction('fetch', remote)}
|
||||
onSync={(remote) => void handleSyncAction('sync', remote)}
|
||||
disabled={commitAction !== null || isLoadingStatus}
|
||||
aheadCount={status?.ahead ?? 0}
|
||||
behindCount={status?.behind ?? 0}
|
||||
trackingRemoteName={status?.tracking?.split('/')[0]}
|
||||
hasUncommittedChanges={changeEntries.length > 0}
|
||||
/>
|
||||
</header>
|
||||
<ScrollShadow className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
|
||||
{changeEntries.length > 0 ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<ChangesPanel
|
||||
groups={changeGroups}
|
||||
diffStats={status?.diffStats}
|
||||
revertingPaths={revertingPaths}
|
||||
onRevertAll={handleRevertAll}
|
||||
isRevertingAll={isRevertingAll}
|
||||
headerBackgroundClassName="bg-transparent"
|
||||
onVisiblePathsChange={setVisibleChangePaths}
|
||||
/>
|
||||
<CommitSection
|
||||
stagedCount={stagedChangeEntries.length}
|
||||
commitMessage={commitMessage}
|
||||
onCommitMessageChange={setCommitMessage}
|
||||
generatedHighlights={generatedHighlights}
|
||||
onInsertHighlights={handleInsertHighlights}
|
||||
onGenerateMessage={handleGenerateCommitMessage}
|
||||
isGeneratingMessage={isGeneratingMessage}
|
||||
onCommit={() => void handleCommit({ pushAfter: false })}
|
||||
onCommitAndPush={() => void handleCommit({ pushAfter: true })}
|
||||
commitAction={commitAction}
|
||||
gitmojiEnabled={false}
|
||||
onOpenGitmojiPicker={() => {}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<MobileChangesState icon message={t('gitView.empty.cleanTitle')} description={t('mobile.changes.cleanDescription')} />
|
||||
)}
|
||||
</ScrollShadow>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MobileChangesState: React.FC<{
|
||||
message: string;
|
||||
description?: string;
|
||||
loading?: boolean;
|
||||
icon?: boolean;
|
||||
}> = ({ message, description, loading = false, icon = false }) => (
|
||||
<div className="flex h-full items-center justify-center px-6 text-center">
|
||||
<div className="flex max-w-sm flex-col items-center gap-2">
|
||||
{loading ? <RiLoader4Line className="size-5 animate-spin text-muted-foreground" /> : null}
|
||||
{icon ? <RiGitBranchLine className="size-6 text-muted-foreground" /> : null}
|
||||
<p className="typography-ui-label font-semibold text-foreground">{message}</p>
|
||||
{description ? <p className="typography-meta text-muted-foreground">{description}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const MobileDiffDetail: React.FC<{
|
||||
path: string;
|
||||
diff: { original: string; modified: string; isBinary?: boolean } | null;
|
||||
fileExists: boolean;
|
||||
error: string | null;
|
||||
onBack: () => void;
|
||||
onRetry: () => void;
|
||||
}> = ({ path, diff, fileExists, error, onBack, onRetry }) => {
|
||||
const { t } = useI18n();
|
||||
const language = React.useMemo(() => getLanguageFromExtension(path) || 'text', [path]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-background text-foreground">
|
||||
<header className="flex h-[var(--oc-header-height,56px)] shrink-0 items-center gap-3 border-b border-border/50 px-3 text-foreground">
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-9 shrink-0 items-center justify-center rounded-lg text-muted-foreground hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('header.actions.backAria')}
|
||||
onClick={onBack}
|
||||
>
|
||||
<RiArrowLeftLine className="size-5" />
|
||||
</button>
|
||||
<div className="min-w-0 flex-1 px-2">
|
||||
<h2 className="truncate typography-ui-header text-foreground">{path}</h2>
|
||||
</div>
|
||||
</header>
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
{!fileExists ? (
|
||||
<MobileChangesState icon message={t('mobile.changes.diffDetail.missingTitle')} description={t('mobile.changes.diffDetail.missingDescription')} />
|
||||
) : error ? (
|
||||
<div className="flex h-full items-center justify-center px-6 text-center">
|
||||
<div className="flex max-w-sm flex-col items-center gap-3">
|
||||
<p className="typography-ui-label font-semibold text-foreground">{t('mobile.changes.diffDetail.loadFailed')}</p>
|
||||
<p className="typography-meta text-muted-foreground">{error}</p>
|
||||
<Button type="button" size="sm" variant="outline" onClick={onRetry}>{t('diffView.actions.retry')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : !diff ? (
|
||||
<MobileChangesState loading message={t('diffView.state.loadingDiff')} />
|
||||
) : diff.isBinary ? (
|
||||
<MobileChangesState icon message={t('diffView.binary.unavailable')} />
|
||||
) : isImageFile(path) ? (
|
||||
<MobileChangesState icon message={t('mobile.changes.diffDetail.imageUnavailable')} />
|
||||
) : (
|
||||
<ScrollShadow
|
||||
className="h-full overflow-y-auto overflow-x-hidden p-3"
|
||||
data-diff-virtual-root
|
||||
data-diff-virtual-content
|
||||
>
|
||||
<PierreDiffViewer
|
||||
original={diff.original}
|
||||
modified={diff.modified}
|
||||
language={language}
|
||||
fileName={path}
|
||||
renderSideBySide={false}
|
||||
wrapLines={true}
|
||||
layout="inline"
|
||||
/>
|
||||
</ScrollShadow>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,534 @@
|
||||
import React from 'react';
|
||||
import { File as PierreFile } from '@pierre/diffs/react';
|
||||
import {
|
||||
RiArrowLeftLine,
|
||||
RiArrowRightSLine,
|
||||
RiClipboardLine,
|
||||
RiCloseLine,
|
||||
RiFileCopyLine,
|
||||
RiFolder3Fill,
|
||||
RiFolderOpenFill,
|
||||
RiLoader4Line,
|
||||
RiRefreshLine,
|
||||
RiSearchLine,
|
||||
} from '@remixicon/react';
|
||||
|
||||
import { toast } from '@/components/ui';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ScrollShadow } from '@/components/ui/ScrollShadow';
|
||||
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
||||
import { JsonTreeView } from '@/components/ui/JsonTreeView';
|
||||
import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
|
||||
import { PIERRE_RUNTIME_BASE_CSS } from '@/components/views/PierreDiffViewer';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry';
|
||||
import { getDefaultTheme } from '@/lib/theme/themes';
|
||||
import { getImageMimeType, getLanguageFromExtension, isImageFile } from '@/lib/toolHelpers';
|
||||
import type { FileListEntry, FileSearchResult } from '@/lib/api/types';
|
||||
import { getRuntimeUrlResolver } from '@/lib/runtime-url';
|
||||
import { refreshRuntimeUrlAuthToken } from '@/lib/runtime-auth';
|
||||
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type MobileFilesRoute =
|
||||
| { type: 'browser'; directory: string }
|
||||
| { type: 'file'; path: string; returnDirectory: string };
|
||||
|
||||
const MAX_MOBILE_FILE_CHARS = 250_000;
|
||||
|
||||
const normalizePath = (value?: string | null): string => (value || '').replace(/\\/g, '/').replace(/\/+$/g, '');
|
||||
|
||||
const getNameFromPath = (path: string): string => {
|
||||
const normalized = normalizePath(path);
|
||||
if (!normalized || normalized === '/') return normalized || '/';
|
||||
return normalized.split('/').filter(Boolean).at(-1) ?? normalized;
|
||||
};
|
||||
|
||||
const getParentDirectory = (path: string): string | null => {
|
||||
const normalized = normalizePath(path);
|
||||
if (!normalized || normalized === '/') return null;
|
||||
const index = normalized.lastIndexOf('/');
|
||||
if (index <= 0) return normalized.startsWith('/') ? '/' : null;
|
||||
return normalized.slice(0, index);
|
||||
};
|
||||
|
||||
const getRelativePath = (path: string, root: string): string => {
|
||||
const normalizedPath = normalizePath(path);
|
||||
const normalizedRoot = normalizePath(root);
|
||||
if (!normalizedRoot || normalizedPath === normalizedRoot) return getNameFromPath(normalizedPath);
|
||||
if (normalizedPath.startsWith(`${normalizedRoot}/`)) return normalizedPath.slice(normalizedRoot.length + 1);
|
||||
return normalizedPath;
|
||||
};
|
||||
|
||||
const formatFileSize = (size?: number): string => {
|
||||
if (typeof size !== 'number' || !Number.isFinite(size) || size < 0) return '';
|
||||
if (size < 1024) return `${size} B`;
|
||||
const units = ['KB', 'MB', 'GB'];
|
||||
let value = size / 1024;
|
||||
for (const unit of units) {
|
||||
if (value < 1024 || unit === units[units.length - 1]) return `${value.toFixed(value >= 10 ? 0 : 1)} ${unit}`;
|
||||
value /= 1024;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const getImageSrc = (path: string): string => {
|
||||
if (path.toLowerCase().endsWith('.svg')) {
|
||||
return '';
|
||||
}
|
||||
return getRuntimeUrlResolver().authenticatedAsset('/api/fs/raw', { path });
|
||||
};
|
||||
|
||||
const isMarkdownFile = (path: string): boolean => /\.(md|mdx|markdown)$/i.test(path);
|
||||
const isJsonFile = (path: string): boolean => /\.(json|jsonc)$/i.test(path);
|
||||
|
||||
type MobileFilesSurfaceProps = {
|
||||
/** When provided, header gets a close X that calls this; used when the surface is hosted in MobileSurfaceShell. */
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
export const MobileFilesSurface: React.FC<MobileFilesSurfaceProps> = ({ onClose }) => {
|
||||
const { t } = useI18n();
|
||||
const { files } = useRuntimeAPIs();
|
||||
const root = normalizePath(useEffectiveDirectory() ?? null);
|
||||
const [route, setRoute] = React.useState<MobileFilesRoute>(() => ({ type: 'browser', directory: root }));
|
||||
const [entries, setEntries] = React.useState<FileListEntry[]>([]);
|
||||
const [isLoadingDirectory, setIsLoadingDirectory] = React.useState(false);
|
||||
const [directoryError, setDirectoryError] = React.useState<string | null>(null);
|
||||
const [query, setQuery] = React.useState('');
|
||||
const [searchResults, setSearchResults] = React.useState<FileSearchResult[]>([]);
|
||||
const [isSearching, setIsSearching] = React.useState(false);
|
||||
const [fileContent, setFileContent] = React.useState('');
|
||||
const [fileError, setFileError] = React.useState<string | null>(null);
|
||||
const [isLoadingFile, setIsLoadingFile] = React.useState(false);
|
||||
const directoryLoadRequestIdRef = React.useRef(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!root) return;
|
||||
setRoute((current) => {
|
||||
if (current.type === 'browser' && current.directory) return current;
|
||||
return { type: 'browser', directory: root };
|
||||
});
|
||||
}, [root]);
|
||||
|
||||
const currentDirectory = route.type === 'browser' ? route.directory : route.returnDirectory;
|
||||
|
||||
const loadDirectory = React.useCallback(async (directory: string) => {
|
||||
if (!directory) return;
|
||||
const requestId = directoryLoadRequestIdRef.current + 1;
|
||||
directoryLoadRequestIdRef.current = requestId;
|
||||
setIsLoadingDirectory(true);
|
||||
setDirectoryError(null);
|
||||
try {
|
||||
const result = await files.listDirectory(directory);
|
||||
if (directoryLoadRequestIdRef.current !== requestId) return;
|
||||
setEntries(result.entries.slice().sort((a, b) => {
|
||||
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
}));
|
||||
} catch (error) {
|
||||
if (directoryLoadRequestIdRef.current !== requestId) return;
|
||||
setEntries([]);
|
||||
setDirectoryError(error instanceof Error ? error.message : t('mobile.files.error.listFailed'));
|
||||
} finally {
|
||||
if (directoryLoadRequestIdRef.current === requestId) {
|
||||
setIsLoadingDirectory(false);
|
||||
}
|
||||
}
|
||||
}, [files, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (route.type !== 'browser') return;
|
||||
void loadDirectory(route.directory);
|
||||
}, [loadDirectory, route]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (route.type !== 'browser') return;
|
||||
const normalizedQuery = query.trim();
|
||||
if (!normalizedQuery) {
|
||||
setSearchResults([]);
|
||||
setIsSearching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setIsSearching(true);
|
||||
void files.search({ directory: route.directory, query: normalizedQuery, maxResults: 40 })
|
||||
.then((results) => {
|
||||
if (!cancelled) setSearchResults(results);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setSearchResults([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setIsSearching(false);
|
||||
});
|
||||
}, 250);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [files, query, route]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (route.type !== 'file') return;
|
||||
setFileContent('');
|
||||
setFileError(null);
|
||||
|
||||
if (isImageFile(route.path) && !route.path.toLowerCase().endsWith('.svg')) {
|
||||
setIsLoadingFile(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!files.readFile) {
|
||||
setFileError(t('mobile.files.error.readUnavailable'));
|
||||
setIsLoadingFile(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setIsLoadingFile(true);
|
||||
void files.readFile(route.path)
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
setFileContent(result.content.length > MAX_MOBILE_FILE_CHARS
|
||||
? `${result.content.slice(0, MAX_MOBILE_FILE_CHARS)}\n\n${t('mobile.files.file.truncated')}`
|
||||
: result.content);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!cancelled) setFileError(error instanceof Error ? error.message : t('filesView.error.readFileFailed'));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setIsLoadingFile(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [files, route, t]);
|
||||
|
||||
const openDirectory = (directory: string) => {
|
||||
setQuery('');
|
||||
setRoute({ type: 'browser', directory });
|
||||
};
|
||||
|
||||
const openFile = (path: string) => {
|
||||
setRoute({ type: 'file', path, returnDirectory: currentDirectory || root });
|
||||
};
|
||||
|
||||
const handleCopyPath = async (path: string) => {
|
||||
const result = await copyTextToClipboard(path);
|
||||
if (result.ok) toast.success(t('mobile.files.toast.pathCopied'));
|
||||
else toast.error(t('mobile.files.toast.copyFailed'));
|
||||
};
|
||||
|
||||
const handleCopyContent = async () => {
|
||||
const result = await copyTextToClipboard(fileContent);
|
||||
if (result.ok) toast.success(t('mobile.files.toast.contentCopied'));
|
||||
else toast.error(t('mobile.files.toast.copyFailed'));
|
||||
};
|
||||
|
||||
if (!root) {
|
||||
return <MobileFilesState message={t('mobile.files.empty.noDirectory')} />;
|
||||
}
|
||||
|
||||
if (route.type === 'file') {
|
||||
return (
|
||||
<MobileFileDetail
|
||||
path={route.path}
|
||||
content={fileContent}
|
||||
error={fileError}
|
||||
isLoading={isLoadingFile}
|
||||
onBack={() => setRoute({ type: 'browser', directory: route.returnDirectory })}
|
||||
onCopyPath={() => void handleCopyPath(route.path)}
|
||||
onCopyContent={() => void handleCopyContent()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const directoryLabel = route.directory === root ? t('mobile.files.rootDirectory') : getNameFromPath(route.directory);
|
||||
const visibleSearchResults = query.trim() ? searchResults : [];
|
||||
|
||||
// Cap parent navigation at the project root: only allow stepping up while
|
||||
// the parent stays inside (or equal to) the root.
|
||||
const rawParent = getParentDirectory(route.directory);
|
||||
const parentWithinRoot =
|
||||
route.directory !== root && rawParent !== null && (rawParent === root || rawParent.startsWith(`${root}/`));
|
||||
const canGoBack = parentWithinRoot && !query.trim();
|
||||
const parentDirectory = parentWithinRoot ? rawParent : null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-background text-foreground">
|
||||
<header className="flex h-[var(--oc-header-height,56px)] shrink-0 items-center gap-2 px-3 text-foreground">
|
||||
{onClose ? (
|
||||
<button
|
||||
type="button"
|
||||
className="-ml-1 flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('mobile.surface.closeAria')}
|
||||
onClick={onClose}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<RiCloseLine className="size-5" />
|
||||
</button>
|
||||
) : null}
|
||||
{canGoBack && parentDirectory ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('mobile.files.backToParentAria', { name: getNameFromPath(parentDirectory) })}
|
||||
onClick={() => openDirectory(parentDirectory)}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<RiArrowLeftLine className="size-5" />
|
||||
</button>
|
||||
) : null}
|
||||
<div className="min-w-0 flex-1 px-1">
|
||||
<h2 className="truncate typography-ui-label text-foreground">{directoryLabel}</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('mobile.files.refreshAria')}
|
||||
onClick={() => void loadDirectory(route.directory)}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<RiRefreshLine className={cn('size-5', isLoadingDirectory && 'animate-spin')} />
|
||||
</button>
|
||||
</header>
|
||||
<div className="shrink-0 px-4 pb-2 pt-1">
|
||||
<div className="relative">
|
||||
<RiSearchLine className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={t('mobile.files.search.placeholder')}
|
||||
className="h-11 pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollShadow className="min-h-0 flex-1 overflow-y-auto px-4 pb-3">
|
||||
{directoryError ? (
|
||||
<MobileFilesState message={directoryError} />
|
||||
) : query.trim() ? (
|
||||
<MobileSearchResults results={visibleSearchResults} isSearching={isSearching} onOpenFile={openFile} />
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-2xl border border-border/40 bg-[var(--surface-elevated)]">
|
||||
{entries.length === 0 && !isLoadingDirectory ? (
|
||||
<div className="px-4 py-8 text-center typography-body text-muted-foreground">{t('mobile.files.empty.directory')}</div>
|
||||
) : null}
|
||||
{entries.map((entry) => (
|
||||
<MobileFileRow
|
||||
key={entry.path}
|
||||
name={entry.name}
|
||||
path={entry.path}
|
||||
directory={entry.isDirectory}
|
||||
meta={entry.isDirectory ? undefined : formatFileSize(entry.size)}
|
||||
onClick={() => entry.isDirectory ? openDirectory(entry.path) : openFile(entry.path)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollShadow>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MobileFileRow: React.FC<{
|
||||
name: string;
|
||||
path: string;
|
||||
directory: boolean;
|
||||
meta?: string;
|
||||
onClick: () => void;
|
||||
}> = ({ name, path, directory, meta, onClick }) => (
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-h-14 w-full items-center gap-3 border-b border-border/30 px-3 py-2.5 text-left transition-colors last:border-b-0 hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset"
|
||||
onClick={onClick}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
{directory ? (
|
||||
<RiFolder3Fill className="size-5 shrink-0 text-primary/80" />
|
||||
) : (
|
||||
<FileTypeIcon filePath={path} className="size-5 shrink-0" />
|
||||
)}
|
||||
<span className="block min-w-0 flex-1 truncate typography-ui-label text-foreground">{name}</span>
|
||||
{meta ? <span className="shrink-0 typography-micro text-muted-foreground">{meta}</span> : null}
|
||||
{directory ? <RiArrowRightSLine className="size-4 shrink-0 text-muted-foreground/60" /> : null}
|
||||
</button>
|
||||
);
|
||||
|
||||
const MobileSearchResults: React.FC<{
|
||||
results: FileSearchResult[];
|
||||
isSearching: boolean;
|
||||
onOpenFile: (path: string) => void;
|
||||
}> = ({ results, isSearching, onOpenFile }) => {
|
||||
const { t } = useI18n();
|
||||
const root = normalizePath(useEffectiveDirectory() ?? null);
|
||||
if (isSearching) return <MobileFilesState loading message={t('common.loading')} />;
|
||||
if (results.length === 0) return <MobileFilesState message={t('mobile.files.search.empty')} />;
|
||||
return (
|
||||
<div className="overflow-hidden rounded-2xl border border-border/40 bg-[var(--surface-elevated)]">
|
||||
{results.map((result) => (
|
||||
<MobileFileRow
|
||||
key={result.path}
|
||||
name={getNameFromPath(result.path)}
|
||||
path={result.path}
|
||||
directory={false}
|
||||
meta={getRelativePath(result.path, root)}
|
||||
onClick={() => onOpenFile(result.path)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MobileFileDetail: React.FC<{
|
||||
path: string;
|
||||
content: string;
|
||||
error: string | null;
|
||||
isLoading: boolean;
|
||||
onBack: () => void;
|
||||
onCopyPath: () => void;
|
||||
onCopyContent: () => void;
|
||||
}> = ({ path, content, error, isLoading, onBack, onCopyPath, onCopyContent }) => {
|
||||
const { t } = useI18n();
|
||||
const imageAuthKey = isImageFile(path) && !path.toLowerCase().endsWith('.svg') ? path : '';
|
||||
const [imageAuthReadyKey, setImageAuthReadyKey] = React.useState('');
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!imageAuthKey) {
|
||||
setImageAuthReadyKey('');
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setImageAuthReadyKey('');
|
||||
void refreshRuntimeUrlAuthToken(getRuntimeApiBaseUrl())
|
||||
.then((token) => {
|
||||
if (!cancelled && token) setImageAuthReadyKey(imageAuthKey);
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [imageAuthKey]);
|
||||
|
||||
const imageAuthLoading = Boolean(imageAuthKey && imageAuthReadyKey !== imageAuthKey);
|
||||
const imageSrc = imageAuthLoading ? '' : getImageSrc(path);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-background text-foreground">
|
||||
<header className="flex h-[var(--oc-header-height,56px)] shrink-0 items-center gap-3 border-b border-border/50 px-3 text-foreground">
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-9 shrink-0 items-center justify-center rounded-lg text-muted-foreground hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('header.actions.backAria')}
|
||||
onClick={onBack}
|
||||
>
|
||||
<RiArrowLeftLine className="size-5" />
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="truncate typography-ui-header text-foreground">{getNameFromPath(path)}</h2>
|
||||
</div>
|
||||
{!isImageFile(path) ? (
|
||||
<Button type="button" variant="ghost" size="icon" onClick={onCopyContent} aria-label={t('mobile.files.copyContentAria')}>
|
||||
<RiFileCopyLine className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="button" variant="ghost" size="icon" onClick={onCopyPath} aria-label={t('mobile.files.copyPathAria')}>
|
||||
<RiClipboardLine className="size-4" />
|
||||
</Button>
|
||||
</header>
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
{isLoading || imageAuthLoading ? (
|
||||
<MobileFilesState loading message={t('filesView.state.loading')} />
|
||||
) : error ? (
|
||||
<MobileFilesState message={error} />
|
||||
) : isImageFile(path) && imageSrc ? (
|
||||
<ScrollShadow className="h-full overflow-auto p-4">
|
||||
<img src={imageSrc} alt={getNameFromPath(path)} className="mx-auto max-h-full max-w-full rounded-lg object-contain" />
|
||||
</ScrollShadow>
|
||||
) : isImageFile(path) ? (
|
||||
<ScrollShadow className="h-full overflow-auto p-4">
|
||||
<img src={`data:${getImageMimeType(path)};utf8,${encodeURIComponent(content)}`} alt={getNameFromPath(path)} className="mx-auto max-h-full max-w-full rounded-lg object-contain" />
|
||||
</ScrollShadow>
|
||||
) : (
|
||||
<MobileTextFile path={path} content={content} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MobileTextFile: React.FC<{ path: string; content: string }> = ({ path, content }) => {
|
||||
const { currentTheme, availableThemes, lightThemeId, darkThemeId } = useThemeSystem();
|
||||
const lightTheme = React.useMemo(
|
||||
() => availableThemes.find((theme) => theme.metadata.id === lightThemeId) ?? getDefaultTheme(false),
|
||||
[availableThemes, lightThemeId],
|
||||
);
|
||||
const darkTheme = React.useMemo(
|
||||
() => availableThemes.find((theme) => theme.metadata.id === darkThemeId) ?? getDefaultTheme(true),
|
||||
[availableThemes, darkThemeId],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
ensurePierreThemeRegistered(lightTheme);
|
||||
ensurePierreThemeRegistered(darkTheme);
|
||||
}, [darkTheme, lightTheme]);
|
||||
|
||||
const pierreTheme = React.useMemo(
|
||||
() => ({ light: lightTheme.metadata.id, dark: darkTheme.metadata.id }),
|
||||
[darkTheme.metadata.id, lightTheme.metadata.id],
|
||||
);
|
||||
|
||||
if (isMarkdownFile(path)) {
|
||||
return (
|
||||
<ScrollShadow className="h-full overflow-y-auto px-4 py-4">
|
||||
<SimpleMarkdownRenderer content={content} />
|
||||
</ScrollShadow>
|
||||
);
|
||||
}
|
||||
if (isJsonFile(path)) {
|
||||
return <JsonTreeView jsonString={content} className="h-full overflow-auto" />;
|
||||
}
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<ScrollShadow className="min-h-0 flex-1 overflow-auto bg-[var(--syntax-base-background)]">
|
||||
<PierreFile
|
||||
file={{
|
||||
name: getNameFromPath(path),
|
||||
contents: content,
|
||||
lang: getLanguageFromExtension(path) || undefined,
|
||||
}}
|
||||
options={{
|
||||
disableFileHeader: true,
|
||||
overflow: 'wrap',
|
||||
theme: pierreTheme,
|
||||
themeType: currentTheme.metadata.variant === 'dark' ? 'dark' : 'light',
|
||||
unsafeCSS: PIERRE_RUNTIME_BASE_CSS,
|
||||
}}
|
||||
className="block min-h-full w-full"
|
||||
style={{ minHeight: '100%' }}
|
||||
/>
|
||||
</ScrollShadow>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MobileFilesState: React.FC<{ message: string; loading?: boolean }> = ({ message, loading = false }) => (
|
||||
<div className="flex h-full items-center justify-center px-6 text-center">
|
||||
<div className="flex max-w-sm flex-col items-center gap-2">
|
||||
{loading ? <RiLoader4Line className="size-5 animate-spin text-muted-foreground" /> : <RiFolderOpenFill className="size-6 text-muted-foreground" />}
|
||||
<p className="typography-ui-label font-semibold text-foreground">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,250 @@
|
||||
import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { RiArrowLeftLine, RiCloseLine } from '@remixicon/react';
|
||||
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const SURFACE_ROOT_ID = 'mobile-surface-root';
|
||||
const DISMISS_THRESHOLD_PX = 90;
|
||||
const ENTER_DELAY_MS = 16;
|
||||
|
||||
const ensureSurfaceRoot = (): HTMLElement | null => {
|
||||
if (typeof document === 'undefined') return null;
|
||||
let root = document.getElementById(SURFACE_ROOT_ID);
|
||||
if (!root) {
|
||||
root = document.createElement('div');
|
||||
root.id = SURFACE_ROOT_ID;
|
||||
document.body.appendChild(root);
|
||||
}
|
||||
return root;
|
||||
};
|
||||
|
||||
export type MobileSurfaceShellProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title?: React.ReactNode;
|
||||
subtitle?: React.ReactNode;
|
||||
trailing?: React.ReactNode;
|
||||
/** When set, the leading icon becomes a back arrow that calls this. Otherwise it's a close X bound to onClose. */
|
||||
onBack?: () => void;
|
||||
/** If true, disable swipe-down-to-dismiss (e.g. when a nested view should keep gesture for itself). */
|
||||
disableSwipeDismiss?: boolean;
|
||||
/** If true, render only the drag handle and let the child render its own header. */
|
||||
headerless?: boolean;
|
||||
ariaLabel?: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const MobileSurfaceShell: React.FC<MobileSurfaceShellProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
subtitle,
|
||||
trailing,
|
||||
onBack,
|
||||
disableSwipeDismiss = false,
|
||||
headerless = false,
|
||||
ariaLabel,
|
||||
children,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const rootRef = React.useRef<HTMLElement | null>(null);
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
const [entered, setEntered] = React.useState(false);
|
||||
const [dragOffset, setDragOffset] = React.useState(0);
|
||||
const dragStartYRef = React.useRef<number | null>(null);
|
||||
const isDraggingRef = React.useRef(false);
|
||||
const surfaceRef = React.useRef<HTMLElement | null>(null);
|
||||
const previousFocusRef = React.useRef<HTMLElement | null>(null);
|
||||
|
||||
if (typeof document !== 'undefined' && !rootRef.current) {
|
||||
rootRef.current = ensureSurfaceRoot();
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setMounted(true);
|
||||
const id = window.setTimeout(() => setEntered(true), ENTER_DELAY_MS);
|
||||
return () => window.clearTimeout(id);
|
||||
}
|
||||
setEntered(false);
|
||||
const id = window.setTimeout(() => setMounted(false), 220);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [open]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
previousFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
document.body.style.overflow = 'hidden';
|
||||
const focusFirstElement = () => {
|
||||
const surface = surfaceRef.current;
|
||||
if (!surface) return;
|
||||
const focusable = surface.querySelector<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
);
|
||||
(focusable ?? surface).focus({ preventScroll: true });
|
||||
};
|
||||
const focusTimer = window.setTimeout(focusFirstElement, ENTER_DELAY_MS);
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab') return;
|
||||
const surface = surfaceRef.current;
|
||||
if (!surface) return;
|
||||
const focusable = Array.from(surface.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
)).filter((element) => !element.hasAttribute('disabled') && element.getAttribute('aria-hidden') !== 'true');
|
||||
if (focusable.length === 0) {
|
||||
event.preventDefault();
|
||||
surface.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const active = document.activeElement;
|
||||
if (event.shiftKey && active === first) {
|
||||
event.preventDefault();
|
||||
last.focus({ preventScroll: true });
|
||||
} else if (!event.shiftKey && active === last) {
|
||||
event.preventDefault();
|
||||
first.focus({ preventScroll: true });
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
window.clearTimeout(focusTimer);
|
||||
document.body.style.overflow = previousOverflow;
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
previousFocusRef.current?.focus?.({ preventScroll: true });
|
||||
previousFocusRef.current = null;
|
||||
};
|
||||
}, [onClose, open]);
|
||||
|
||||
const handleDragStart = (event: React.TouchEvent<HTMLDivElement>) => {
|
||||
if (disableSwipeDismiss) return;
|
||||
dragStartYRef.current = event.touches[0]?.clientY ?? null;
|
||||
isDraggingRef.current = true;
|
||||
};
|
||||
|
||||
const handleDragMove = (event: React.TouchEvent<HTMLDivElement>) => {
|
||||
if (!isDraggingRef.current || dragStartYRef.current == null) return;
|
||||
const currentY = event.touches[0]?.clientY ?? dragStartYRef.current;
|
||||
const delta = currentY - dragStartYRef.current;
|
||||
setDragOffset(delta > 0 ? delta : 0);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
if (!isDraggingRef.current) return;
|
||||
isDraggingRef.current = false;
|
||||
dragStartYRef.current = null;
|
||||
if (dragOffset >= DISMISS_THRESHOLD_PX) {
|
||||
setDragOffset(0);
|
||||
onClose();
|
||||
} else {
|
||||
setDragOffset(0);
|
||||
}
|
||||
};
|
||||
|
||||
if (!mounted || !rootRef.current) return null;
|
||||
|
||||
const leading = onBack ? (
|
||||
<button
|
||||
type="button"
|
||||
className="-ml-1 flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('header.actions.backAria')}
|
||||
onClick={onBack}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<RiArrowLeftLine className="size-5" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="-ml-1 flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('mobile.surface.closeAria')}
|
||||
onClick={onClose}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<RiCloseLine className="size-5" />
|
||||
</button>
|
||||
);
|
||||
|
||||
const visualTransform = entered
|
||||
? `translateY(${dragOffset}px)`
|
||||
: 'translateY(100%)';
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 flex items-end',
|
||||
'bg-[rgb(0_0_0_/_0.45)]',
|
||||
'transition-opacity duration-200 ease-out',
|
||||
entered ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 cursor-default"
|
||||
aria-label={t('mobile.surface.closeAria')}
|
||||
onClick={onClose}
|
||||
/>
|
||||
<section
|
||||
ref={surfaceRef}
|
||||
className="relative flex h-[100dvh] w-full flex-col overflow-hidden rounded-t-[20px] border-t border-border/40 bg-background text-foreground shadow-[0_-12px_48px_rgb(0_0_0_/_0.35)] will-change-transform"
|
||||
tabIndex={-1}
|
||||
style={{
|
||||
transform: visualTransform,
|
||||
transition: isDraggingRef.current
|
||||
? 'none'
|
||||
: 'transform 220ms cubic-bezier(0.32, 0.72, 0, 1)',
|
||||
paddingTop: 'var(--oc-safe-area-top, 0px)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="shrink-0 select-none"
|
||||
onTouchStart={handleDragStart}
|
||||
onTouchMove={handleDragMove}
|
||||
onTouchEnd={handleDragEnd}
|
||||
onTouchCancel={handleDragEnd}
|
||||
>
|
||||
<div className="flex items-center justify-center pt-2 pb-1">
|
||||
<span className="h-1 w-10 rounded-full bg-[var(--surface-muted)]" aria-hidden />
|
||||
</div>
|
||||
{!headerless ? (
|
||||
<header className="flex h-[var(--oc-header-height,56px)] items-center gap-2 px-3">
|
||||
{leading}
|
||||
<div className="min-w-0 flex-1 px-1">
|
||||
{title ? (
|
||||
typeof title === 'string' ? (
|
||||
<h2 className="truncate typography-ui-label text-foreground">{title}</h2>
|
||||
) : (
|
||||
title
|
||||
)
|
||||
) : null}
|
||||
{subtitle ? (
|
||||
typeof subtitle === 'string' ? (
|
||||
<p className="truncate typography-micro text-muted-foreground">{subtitle}</p>
|
||||
) : (
|
||||
subtitle
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
{trailing ? <div className="flex shrink-0 items-center gap-1.5">{trailing}</div> : null}
|
||||
</header>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-hidden" style={{ paddingBottom: 'var(--oc-safe-area-bottom, 0px)' }}>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
</div>,
|
||||
rootRef.current,
|
||||
);
|
||||
};
|
||||
@@ -13,6 +13,7 @@ import { useRouter } from '@/hooks/useRouter';
|
||||
import { useWindowTitle } from '@/hooks/useWindowTitle';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import type { RuntimeAPIs } from '@/lib/api/types';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
|
||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
@@ -70,7 +71,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
|
||||
let cancelled = false;
|
||||
|
||||
const run = async () => {
|
||||
const res = await fetch('/health', { method: 'GET' }).catch(() => null);
|
||||
const res = await runtimeFetch('/health', { method: 'GET' }).catch(() => null);
|
||||
if (!res || !res.ok || cancelled) return;
|
||||
const data = (await res.json().catch(() => null)) as null | {
|
||||
planModeExperimentalEnabled?: unknown;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import React from 'react';
|
||||
|
||||
export type MobileAppActions = {
|
||||
/** Open the Changes surface as a modal and (optionally) navigate it to a specific diff. */
|
||||
openChanges: (options?: { diffPath?: string | null; staged?: boolean }) => void;
|
||||
/** Open the Files surface as a modal. */
|
||||
openFiles: () => void;
|
||||
/** Open the Settings surface as a modal. */
|
||||
openSettings: () => void;
|
||||
};
|
||||
|
||||
const DedicatedMobileAppContext = React.createContext<MobileAppActions | null>(null);
|
||||
|
||||
export const DedicatedMobileAppProvider: React.FC<{
|
||||
actions: MobileAppActions;
|
||||
children: React.ReactNode;
|
||||
}> = ({ actions, children }) => (
|
||||
<DedicatedMobileAppContext.Provider value={actions}>{children}</DedicatedMobileAppContext.Provider>
|
||||
);
|
||||
|
||||
/**
|
||||
* Returns true when the surrounding tree is the dedicated MobileApp root
|
||||
* (Capacitor or hosted /mobile.html), as opposed to the desktop responsive
|
||||
* mobile path. Use this to suppress UI that exists only to bridge the
|
||||
* desktop sidebar/layout into mobile, since the dedicated mobile root has
|
||||
* its own native-feeling navigation and no sidebars to bridge into.
|
||||
*/
|
||||
export const useIsDedicatedMobileApp = (): boolean => React.useContext(DedicatedMobileAppContext) !== null;
|
||||
|
||||
/**
|
||||
* Returns the dedicated mobile app's surface-opening actions, or null when
|
||||
* not inside the dedicated mobile root. Components living in shared chat /
|
||||
* input code can use this to route navigation to mobile-native surfaces
|
||||
* (e.g. open the Changes diff for a file from PendingChangesBar) instead of
|
||||
* desktop sidebars.
|
||||
*/
|
||||
export const useMobileAppActions = (): MobileAppActions | null => React.useContext(DedicatedMobileAppContext);
|
||||
@@ -0,0 +1,61 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import '@/styles/fonts';
|
||||
import '@/index.css';
|
||||
import '@/lib/debug';
|
||||
import { SessionAuthGate } from '@/components/auth/SessionAuthGate';
|
||||
import { DiffWorkerProvider } from '@/contexts/DiffWorkerProvider';
|
||||
import { ThemeProvider } from '@/components/providers/ThemeProvider';
|
||||
import { ThemeSystemProvider } from '@/contexts/ThemeSystemContext';
|
||||
import type { RuntimeAPIs } from '@/lib/api/types';
|
||||
import { startAppearanceAutoSave } from '@/lib/appearanceAutoSave';
|
||||
import { applyPersistedDirectoryPreferences } from '@/lib/directoryPersistence';
|
||||
import { initializeLocale, I18nProvider } from '@/lib/i18n';
|
||||
import { initializeAppearancePreferences, syncDesktopSettings } from '@/lib/persistence';
|
||||
import { startModelPrefsAutoSave } from '@/lib/modelPrefsAutoSave';
|
||||
import { startTypographyWatcher } from '@/lib/typographyWatcher';
|
||||
import { MobileApp } from './MobileApp';
|
||||
|
||||
const initializeSharedPreferences = () => {
|
||||
initializeLocale();
|
||||
|
||||
void initializeAppearancePreferences().then(() => {
|
||||
void Promise.all([
|
||||
syncDesktopSettings(),
|
||||
applyPersistedDirectoryPreferences(),
|
||||
]).catch((err) => {
|
||||
console.error('[mobile-main] settings init failed:', err);
|
||||
});
|
||||
|
||||
startAppearanceAutoSave();
|
||||
startModelPrefsAutoSave();
|
||||
startTypographyWatcher();
|
||||
}).catch((err) => {
|
||||
console.error('[mobile-main] appearance init failed:', err);
|
||||
});
|
||||
};
|
||||
|
||||
export function renderMobileApp(apis: RuntimeAPIs) {
|
||||
initializeSharedPreferences();
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) {
|
||||
throw new Error('Root element not found');
|
||||
}
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<I18nProvider>
|
||||
<ThemeSystemProvider>
|
||||
<ThemeProvider>
|
||||
<DiffWorkerProvider>
|
||||
<SessionAuthGate>
|
||||
<MobileApp apis={apis} />
|
||||
</SessionAuthGate>
|
||||
</DiffWorkerProvider>
|
||||
</ThemeProvider>
|
||||
</ThemeSystemProvider>
|
||||
</I18nProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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 : '');
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from '@/lib/theme/themes';
|
||||
import { ThemeSystemContext, type ThemeContextValue } from './theme-system-context';
|
||||
import type { VSCodeThemePayload } from '@/lib/theme/vscode/adapter';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
|
||||
type ThemePreferences = {
|
||||
themeMode: ThemeMode;
|
||||
@@ -283,7 +284,7 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
|
||||
|
||||
setCustomThemesLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/config/themes', {
|
||||
const res = await runtimeFetch('/api/config/themes', {
|
||||
method: 'GET',
|
||||
credentials: isLocalDesktopOrigin ? 'omit' : 'include',
|
||||
headers: {
|
||||
|
||||
@@ -6,4 +6,15 @@ export const registerRuntimeAPIs = (apis: RuntimeAPIs | null): void => {
|
||||
registeredRuntimeAPIs = apis;
|
||||
};
|
||||
|
||||
export const getRegisteredRuntimeAPIs = (): RuntimeAPIs | null => registeredRuntimeAPIs;
|
||||
export const getRegisteredRuntimeAPIs = (): RuntimeAPIs | null => {
|
||||
if (registeredRuntimeAPIs) {
|
||||
return registeredRuntimeAPIs;
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (window as typeof window & { __OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs })
|
||||
.__OPENCHAMBER_RUNTIME_APIS__ ?? null;
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
|
||||
import { MessageFreshnessDetector } from '@/lib/messageFreshness';
|
||||
import { createScrollSpy } from '@/components/chat/lib/scroll/scrollSpy';
|
||||
import { useViewportStore, type SessionMemoryState } from '@/sync/viewport-store';
|
||||
import { getViewportSessionMemory, useViewportStore, type SessionMemoryState } from '@/sync/viewport-store';
|
||||
|
||||
export type AutoFollowState = 'following' | 'released';
|
||||
|
||||
@@ -358,7 +358,7 @@ export const useChatAutoFollow = ({
|
||||
}
|
||||
pendingInitialRestoreRef.current = null;
|
||||
|
||||
const saved = useViewportStore.getState().sessionMemoryState.get(sessionId)?.scrollPosition;
|
||||
const saved = getViewportSessionMemory(sessionId)?.scrollPosition;
|
||||
|
||||
if (!saved || isAtBottomSnapshot(saved, isMobile)) {
|
||||
setStateValue('following');
|
||||
@@ -378,7 +378,7 @@ export const useChatAutoFollow = ({
|
||||
setStateValue('released');
|
||||
writeScrollTopInstant(targetTop);
|
||||
|
||||
const memState = useViewportStore.getState().sessionMemoryState.get(sessionId);
|
||||
const memState = getViewportSessionMemory(sessionId);
|
||||
updateViewportAnchor(sessionId, memState?.viewportAnchor ?? 0, {
|
||||
scrollTop: container.scrollTop,
|
||||
scrollHeight: container.scrollHeight,
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
|
||||
interface SayTTSStatusCache {
|
||||
available: boolean;
|
||||
@@ -45,7 +46,7 @@ async function getSayTTSStatus(): Promise<SayTTSStatusCache> {
|
||||
|
||||
sayTTSStatusRequest = (async () => {
|
||||
try {
|
||||
const response = await fetch('/api/tts/say/status');
|
||||
const response = await runtimeFetch('/api/tts/say/status');
|
||||
if (!response.ok) {
|
||||
const unavailableStatus: SayTTSStatusCache = {
|
||||
available: false,
|
||||
@@ -219,7 +220,7 @@ export function useSayTTS(options: UseSayTTSOptions = {}): UseSayTTSReturn {
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
// Fetch audio from server
|
||||
const response = await fetch('/api/tts/say/speak', {
|
||||
const response = await runtimeFetch('/api/tts/say/speak', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
|
||||
interface ServerTTSStatusCache {
|
||||
available: boolean;
|
||||
@@ -45,7 +46,7 @@ async function getServerTTSStatus(): Promise<boolean> {
|
||||
|
||||
serverTTSStatusRequest = (async () => {
|
||||
try {
|
||||
const response = await fetch('/api/tts/status');
|
||||
const response = await runtimeFetch('/api/tts/status');
|
||||
if (!response.ok) {
|
||||
serverTTSStatusCache = { available: false, checkedAt: Date.now() };
|
||||
return false;
|
||||
@@ -262,7 +263,7 @@ export function useServerTTS(options: UseServerTTSOptions = {}): UseServerTTSRet
|
||||
console.log('[useServerTTS] Speaking with voice:', voice, 'options:', options);
|
||||
|
||||
// Fetch audio from server
|
||||
const response = await fetch('/api/tts/speak', {
|
||||
const response = await runtimeFetch('/api/tts/speak', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -152,13 +152,11 @@ export const useSessionAutoCleanup = (enabledOrOptions?: boolean | CleanupOption
|
||||
continue;
|
||||
}
|
||||
|
||||
const scopedSdk = opencodeClient.getScopedSdkClient(directory);
|
||||
|
||||
try {
|
||||
if (sessionRetentionAction === 'archive') {
|
||||
await scopedSdk.session.update({ sessionID: id, directory, time: { archived: Date.now() } });
|
||||
await opencodeClient.updateSession(id, { time: { archived: Date.now() } }, directory);
|
||||
} else {
|
||||
await scopedSdk.session.delete({ sessionID: id, directory });
|
||||
await opencodeClient.deleteSession(id, directory);
|
||||
}
|
||||
completedIds.push(id);
|
||||
} catch {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { isDesktopShell, isWebRuntime } from '@/lib/desktop';
|
||||
import { getRuntimeUrlResolver } from '@/lib/runtime-url';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import type { NotificationPayload } from '@/lib/api/types';
|
||||
|
||||
@@ -33,7 +34,7 @@ export const useWebNotificationStream = (options?: { enabled?: boolean }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const source = new EventSource(NOTIFICATION_STREAM_PATH);
|
||||
const source = new EventSource(getRuntimeUrlResolver().sse(NOTIFICATION_STREAM_PATH));
|
||||
source.onmessage = (event) => {
|
||||
let data: unknown;
|
||||
try {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React from 'react';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { isDesktopShell, isTauriShell } from '@/lib/desktop';
|
||||
import { desktopHostsGet, locationMatchesHost, redactSensitiveUrl } from '@/lib/desktopHosts';
|
||||
import { isDesktopLocalOriginActive, isDesktopShell, isTauriShell } from '@/lib/desktop';
|
||||
import { desktopHostsGet, getDesktopHostApiUrl, locationMatchesHost, redactSensitiveUrl } from '@/lib/desktopHosts';
|
||||
import { setDesktopWindowTitle } from '@/lib/desktopNative';
|
||||
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
|
||||
|
||||
const APP_TITLE = 'OpenChamber';
|
||||
|
||||
@@ -59,10 +60,17 @@ export const useWindowTitle = () => {
|
||||
|
||||
const refreshInstanceLabel = async () => {
|
||||
try {
|
||||
const currentHref = window.location.href;
|
||||
const localOrigin = window.__OPENCHAMBER_LOCAL_ORIGIN__ || window.location.origin;
|
||||
if (isDesktopLocalOriginActive()) {
|
||||
if (!cancelled) {
|
||||
setInstanceLabel(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (locationMatchesHost(currentHref, localOrigin)) {
|
||||
const localOrigin = window.__OPENCHAMBER_LOCAL_ORIGIN__ || window.location.origin;
|
||||
const runtimeApiBaseUrl = getRuntimeApiBaseUrl();
|
||||
|
||||
if (runtimeApiBaseUrl && locationMatchesHost(runtimeApiBaseUrl, localOrigin)) {
|
||||
if (!cancelled) {
|
||||
setInstanceLabel(null);
|
||||
}
|
||||
@@ -70,7 +78,7 @@ export const useWindowTitle = () => {
|
||||
}
|
||||
|
||||
const cfg = await desktopHostsGet();
|
||||
const match = cfg.hosts.find((host) => locationMatchesHost(currentHref, host.url));
|
||||
const match = cfg.hosts.find((host) => runtimeApiBaseUrl ? locationMatchesHost(runtimeApiBaseUrl, getDesktopHostApiUrl(host)) : false);
|
||||
const nextLabel = match?.label?.trim() ? redactSensitiveUrl(match.label.trim()) : 'Instance';
|
||||
if (!cancelled) {
|
||||
setInstanceLabel(nextLabel);
|
||||
|
||||
@@ -716,6 +716,10 @@ export interface NotificationPayload {
|
||||
body?: string;
|
||||
|
||||
tag?: string;
|
||||
kind?: string;
|
||||
sessionId?: string;
|
||||
directory?: string;
|
||||
requireHidden?: boolean;
|
||||
}
|
||||
|
||||
export interface NotificationsAPI {
|
||||
@@ -746,6 +750,9 @@ export interface VSCodeAPI {
|
||||
executeCommand(command: string, ...args: unknown[]): Promise<unknown>;
|
||||
openAgentManager(): Promise<void>;
|
||||
openExternalUrl(url: string): Promise<void>;
|
||||
pickFiles?(): Promise<unknown>;
|
||||
saveImage?(payload: unknown): Promise<unknown>;
|
||||
saveMarkdown?(payload: unknown): Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface PushSubscribePayload {
|
||||
@@ -1075,6 +1082,37 @@ export interface GitHubAPI {
|
||||
repoBranches(owner: string, repo: string): Promise<string[]>;
|
||||
}
|
||||
|
||||
export interface RemoteClientRecord {
|
||||
id: string;
|
||||
label: string;
|
||||
createdAt: string;
|
||||
lastUsedAt: string | null;
|
||||
revokedAt: string | null;
|
||||
expiresAt?: string | null;
|
||||
clientKind?: string | null;
|
||||
}
|
||||
|
||||
export interface RemoteClientCreateResult {
|
||||
client: RemoteClientRecord;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface RemoteClientRevokeResult {
|
||||
revoked: boolean;
|
||||
client?: RemoteClientRecord;
|
||||
}
|
||||
|
||||
export interface RemoteClientPurgeRevokedResult {
|
||||
purged: number;
|
||||
}
|
||||
|
||||
export interface ClientAuthAPI {
|
||||
listClients(): Promise<RemoteClientRecord[]>;
|
||||
createClient(input?: { label?: string }): Promise<RemoteClientCreateResult>;
|
||||
purgeRevokedClients(): Promise<RemoteClientPurgeRevokedResult>;
|
||||
revokeClient(id: string): Promise<RemoteClientRevokeResult>;
|
||||
}
|
||||
|
||||
export interface RuntimeAPIs {
|
||||
runtime: RuntimeDescriptor;
|
||||
terminal: TerminalAPI;
|
||||
@@ -1086,6 +1124,7 @@ export interface RuntimeAPIs {
|
||||
github?: GitHubAPI;
|
||||
push?: PushAPI;
|
||||
diagnostics?: DiagnosticsAPI;
|
||||
clientAuth?: ClientAuthAPI;
|
||||
tools: ToolsAPI;
|
||||
editor?: EditorAPI;
|
||||
vscode?: VSCodeAPI;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
export type ClientConnectionPayload = {
|
||||
v: 1;
|
||||
serverUrl: string;
|
||||
token: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export const buildClientConnectionPayload = (input: {
|
||||
serverUrl: string;
|
||||
token: string;
|
||||
label?: string | null;
|
||||
}): ClientConnectionPayload => ({
|
||||
v: 1,
|
||||
serverUrl: input.serverUrl.trim().replace(/\/+$/, ''),
|
||||
token: input.token.trim(),
|
||||
...(input.label?.trim() ? { label: input.label.trim() } : {}),
|
||||
});
|
||||
|
||||
export const encodeClientConnectionPayload = (payload: ClientConnectionPayload): string => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('v', String(payload.v));
|
||||
params.set('server', payload.serverUrl);
|
||||
params.set('token', payload.token);
|
||||
if (payload.label) params.set('label', payload.label);
|
||||
return `openchamber://connect?${params.toString()}`;
|
||||
};
|
||||
|
||||
export const parseClientConnectionPayload = (value: string): ClientConnectionPayload | null => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
if (url.protocol !== 'openchamber:' || url.hostname !== 'connect') {
|
||||
return null;
|
||||
}
|
||||
const version = url.searchParams.get('v');
|
||||
const serverUrl = url.searchParams.get('server')?.trim() || '';
|
||||
const token = url.searchParams.get('token')?.trim() || '';
|
||||
const label = url.searchParams.get('label')?.trim() || '';
|
||||
|
||||
if (version !== '1' || !serverUrl || !token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedServer = new URL(serverUrl);
|
||||
if (parsedServer.protocol !== 'http:' && parsedServer.protocol !== 'https:') {
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return buildClientConnectionPayload({ serverUrl, token, label });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { FilesAPI } from '@/lib/api/types';
|
||||
import { MAX_OPEN_FILE_LINES, countLinesWithLimit } from '@/lib/fileOpenLimits';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
|
||||
export type ContextFileOpenFailureReason = 'too-large' | 'missing' | 'unreadable';
|
||||
|
||||
@@ -31,7 +32,7 @@ const readFileContent = async (files: FilesAPI, path: string): Promise<string> =
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({ path, allowOutsideWorkspace: 'true', 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: 'no-store',
|
||||
});
|
||||
|
||||
@@ -8,6 +8,8 @@ import { streamDebugEnabled } from '@/stores/utils/streamDebug';
|
||||
import { copyTextToClipboard as copyPlainTextToClipboard } from '@/lib/clipboard';
|
||||
import { getSyncSessions, getSyncMessages, getSyncParts } from '@/sync/sync-refs';
|
||||
import { useStreamingStore } from '@/sync/streaming';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
|
||||
export interface DebugMessageInfo {
|
||||
messageId: string;
|
||||
@@ -218,9 +220,7 @@ export const debugUtils = {
|
||||
}
|
||||
})();
|
||||
|
||||
const runtimeApis = typeof window !== 'undefined'
|
||||
? (window as any).__OPENCHAMBER_RUNTIME_APIS__
|
||||
: null;
|
||||
const runtimeApis = getRegisteredRuntimeAPIs();
|
||||
const isTauriShell = typeof window !== 'undefined' && Boolean((window as any).__TAURI__);
|
||||
|
||||
const safeJson = async (resp: Response) => {
|
||||
@@ -241,7 +241,7 @@ export const debugUtils = {
|
||||
|
||||
const safeFetchJson = async (url: string): Promise<unknown> => {
|
||||
try {
|
||||
const resp = await fetch(url);
|
||||
const resp = await runtimeFetch(url);
|
||||
return resp.ok ? await safeJson(resp) : { status: resp.status };
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : String(error) };
|
||||
@@ -253,20 +253,28 @@ export const debugUtils = {
|
||||
let settingsInfo: unknown = null;
|
||||
let opencodeHealth: unknown = null;
|
||||
|
||||
const pathUrl = currentDirectory
|
||||
? `/api/path?directory=${encodeURIComponent(currentDirectory)}`
|
||||
: '/api/path';
|
||||
pathInfo = await safeFetchJson(pathUrl);
|
||||
try {
|
||||
const pathResult = await opencodeClient.getSdkClient().path.get(
|
||||
currentDirectory ? { directory: currentDirectory } : undefined
|
||||
);
|
||||
pathInfo = pathResult.error ? { error: pathResult.error } : pathResult.data;
|
||||
} catch (error) {
|
||||
pathInfo = { error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
|
||||
const projectUrl = currentDirectory
|
||||
? `/api/project/current?directory=${encodeURIComponent(currentDirectory)}`
|
||||
: '/api/project/current';
|
||||
projectInfo = await safeFetchJson(projectUrl);
|
||||
try {
|
||||
const projectResult = await opencodeClient.getSdkClient().project.current(
|
||||
currentDirectory ? { directory: currentDirectory } : undefined
|
||||
);
|
||||
projectInfo = projectResult.error ? { error: projectResult.error } : projectResult.data;
|
||||
} catch (error) {
|
||||
projectInfo = { error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
|
||||
settingsInfo = await safeFetchJson('/api/config/settings');
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/health');
|
||||
const resp = await runtimeFetch('/api/health');
|
||||
const contentType = resp.headers.get('content-type') || '';
|
||||
const body = await safeText(resp);
|
||||
const isJson = contentType.toLowerCase().includes('application/json');
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { ProjectEntry } from '@/lib/api/types';
|
||||
import type { MobileKeyboardMode } from '@/lib/mobileKeyboardMode';
|
||||
import { getInjectedBootOutcome } from '@/lib/desktopBoot';
|
||||
import type { DraftStarterRef } from '@/lib/draftStarters';
|
||||
import type { MobileKeyboardMode } from '@/lib/mobileKeyboardMode';
|
||||
import { getRuntimeApiBaseUrl, getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
|
||||
export type AssistantNotificationPayload = {
|
||||
title?: string;
|
||||
@@ -307,8 +310,34 @@ export const isDesktopLocalOriginActive = (): boolean => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
if (!isDesktopShell()) return false;
|
||||
|
||||
if (getRuntimeKey() === 'local') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const local = typeof window.__OPENCHAMBER_LOCAL_ORIGIN__ === 'string' ? window.__OPENCHAMBER_LOCAL_ORIGIN__ : '';
|
||||
const localUrl = parseUrl(local);
|
||||
const runtimeApiUrl = parseUrl(getRuntimeApiBaseUrl());
|
||||
|
||||
if (!runtimeApiUrl && localUrl && getInjectedBootOutcome()?.target === 'local') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (localUrl && runtimeApiUrl) {
|
||||
if (localUrl.origin === runtimeApiUrl.origin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const localPort = localUrl.port || (localUrl.protocol === 'https:' ? '443' : '80');
|
||||
const runtimePort = runtimeApiUrl.port || (runtimeApiUrl.protocol === 'https:' ? '443' : '80');
|
||||
|
||||
return (
|
||||
localUrl.protocol === runtimeApiUrl.protocol &&
|
||||
localPort === runtimePort &&
|
||||
isLoopbackHost(localUrl.hostname) &&
|
||||
isLoopbackHost(runtimeApiUrl.hostname)
|
||||
);
|
||||
}
|
||||
|
||||
const currentUrl = parseUrl(window.location.origin);
|
||||
|
||||
if (localUrl && currentUrl) {
|
||||
@@ -357,14 +386,12 @@ export const startDesktopWindowDrag = async (): Promise<boolean> => {
|
||||
};
|
||||
|
||||
export const isVSCodeRuntime = (): boolean => {
|
||||
if (typeof window === "undefined") return false;
|
||||
const apis = (window as { __OPENCHAMBER_RUNTIME_APIS__?: { runtime?: { isVSCode?: boolean } } }).__OPENCHAMBER_RUNTIME_APIS__;
|
||||
const apis = getRegisteredRuntimeAPIs();
|
||||
return apis?.runtime?.isVSCode === true;
|
||||
};
|
||||
|
||||
export const isWebRuntime = (): boolean => {
|
||||
if (typeof window === "undefined") return false;
|
||||
const apis = (window as { __OPENCHAMBER_RUNTIME_APIS__?: { runtime?: { platform?: string } } }).__OPENCHAMBER_RUNTIME_APIS__;
|
||||
const apis = getRegisteredRuntimeAPIs();
|
||||
const platform = apis?.runtime?.platform;
|
||||
if (platform === 'web') {
|
||||
return true;
|
||||
@@ -476,7 +503,7 @@ export const sendAssistantCompletionNotification = async (
|
||||
};
|
||||
|
||||
export const checkForDesktopUpdates = async (): Promise<UpdateInfo | null> => {
|
||||
if (!isTauriShell() || !isDesktopLocalOriginActive()) {
|
||||
if (!isTauriShell()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -493,7 +520,7 @@ export const checkForDesktopUpdates = async (): Promise<UpdateInfo | null> => {
|
||||
export const downloadDesktopUpdate = async (
|
||||
onProgress?: (progress: UpdateProgress) => void
|
||||
): Promise<boolean> => {
|
||||
if (!isTauriShell() || !isDesktopLocalOriginActive()) {
|
||||
if (!isTauriShell()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -553,7 +580,7 @@ export const downloadDesktopUpdate = async (
|
||||
};
|
||||
|
||||
export const restartToApplyUpdate = async (): Promise<boolean> => {
|
||||
if (!isTauriShell() || !isDesktopLocalOriginActive()) {
|
||||
if (!isTauriShell()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,20 @@ describe('resolveDesktopBootView', () => {
|
||||
).toEqual({ screen: 'recovery', variant: 'remote-wrong-service', hostId: 'bad-host', url: 'https://bad.test' });
|
||||
});
|
||||
|
||||
test('returns recovery-remote for incompatible remote', () => {
|
||||
expect(
|
||||
resolveDesktopBootView({
|
||||
isDesktopShell: true,
|
||||
bootOutcome: {
|
||||
target: 'remote',
|
||||
status: 'incompatible',
|
||||
hostId: 'old-host',
|
||||
url: 'https://old.test',
|
||||
},
|
||||
}),
|
||||
).toEqual({ screen: 'recovery', variant: 'remote-incompatible', hostId: 'old-host', url: 'https://old.test' });
|
||||
});
|
||||
|
||||
test('returns recovery view for local unreachable', () => {
|
||||
expect(
|
||||
resolveDesktopBootView({
|
||||
|
||||
@@ -29,6 +29,7 @@ export type DesktopBootOutcome =
|
||||
// Recovery screens - something is wrong
|
||||
| { target: 'local'; status: 'unreachable' }
|
||||
| { target: 'remote'; status: 'unreachable'; hostId: string; url: string }
|
||||
| { target: 'remote'; status: 'incompatible'; hostId: string; url: string }
|
||||
| { target: 'remote'; status: 'wrong-service'; hostId: string; url: string }
|
||||
| { target: 'remote'; status: 'missing'; hostId: string };
|
||||
|
||||
@@ -40,6 +41,7 @@ export type DesktopBootView =
|
||||
| { screen: 'chooser' }
|
||||
| { screen: 'recovery'; variant: 'local-unavailable' }
|
||||
| { screen: 'recovery'; variant: 'remote-unreachable'; hostId: string; url: string }
|
||||
| { screen: 'recovery'; variant: 'remote-incompatible'; hostId: string; url: string }
|
||||
| { screen: 'recovery'; variant: 'remote-wrong-service'; hostId: string; url: string }
|
||||
| { screen: 'recovery'; variant: 'remote-missing'; hostId: string };
|
||||
|
||||
@@ -56,7 +58,7 @@ export type DesktopBootViewInput = {
|
||||
const VALID_TARGETS = ['local', 'remote', null] as const;
|
||||
|
||||
/** Valid status values */
|
||||
const VALID_STATUSES = ['ok', 'not-configured', 'unreachable', 'wrong-service', 'missing'] as const;
|
||||
const VALID_STATUSES = ['ok', 'not-configured', 'unreachable', 'incompatible', 'wrong-service', 'missing'] as const;
|
||||
|
||||
/** Return type for `validateBootOutcome`. */
|
||||
type ValidationResult =
|
||||
@@ -115,12 +117,12 @@ function validateBootOutcome(raw: unknown): ValidationResult {
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 'wrong-service') {
|
||||
if (status === 'incompatible' || status === 'wrong-service') {
|
||||
if (target !== 'remote') return { valid: false };
|
||||
if (typeof record.hostId !== 'string' || typeof record.url !== 'string') {
|
||||
return { valid: false };
|
||||
}
|
||||
return { valid: true, outcome: { target: 'remote', status: 'wrong-service', hostId: record.hostId, url: record.url } };
|
||||
return { valid: true, outcome: { target: 'remote', status, hostId: record.hostId, url: record.url } };
|
||||
}
|
||||
|
||||
if (status === 'missing') {
|
||||
@@ -187,6 +189,8 @@ export function resolveDesktopBootView(
|
||||
if (outcome.target === 'remote') {
|
||||
if (outcome.status === 'unreachable') {
|
||||
return { screen: 'recovery', variant: 'remote-unreachable', hostId: outcome.hostId, url: outcome.url };
|
||||
} else if (outcome.status === 'incompatible') {
|
||||
return { screen: 'recovery', variant: 'remote-incompatible', hostId: outcome.hostId, url: outcome.url };
|
||||
} else if (outcome.status === 'wrong-service') {
|
||||
return { screen: 'recovery', variant: 'remote-wrong-service', hostId: outcome.hostId, url: outcome.url };
|
||||
} else if (outcome.status === 'missing') {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { redactSensitiveUrl, resolveDesktopHostUrl } from './desktopHosts';
|
||||
|
||||
describe('resolveDesktopHostUrl', () => {
|
||||
test('keeps regular host URLs unchanged', () => {
|
||||
expect(resolveDesktopHostUrl('https://example.com/app?x=1')).toEqual({
|
||||
persistedUrl: 'https://example.com/app?x=1',
|
||||
redeemUrl: null,
|
||||
kind: 'normal-host',
|
||||
});
|
||||
});
|
||||
|
||||
test('detects tunnel connect links and stores only origin', () => {
|
||||
expect(resolveDesktopHostUrl('https://example.trycloudflare.com/connect?t=secret-token')).toEqual({
|
||||
persistedUrl: 'https://example.trycloudflare.com',
|
||||
redeemUrl: 'https://example.trycloudflare.com/connect?t=secret-token',
|
||||
kind: 'tunnel-connect-link',
|
||||
});
|
||||
});
|
||||
|
||||
test('detects tunnel connect links with trailing slash', () => {
|
||||
expect(resolveDesktopHostUrl('https://example.trycloudflare.com/connect/?t=secret-token#section')).toEqual({
|
||||
persistedUrl: 'https://example.trycloudflare.com',
|
||||
redeemUrl: 'https://example.trycloudflare.com/connect/?t=secret-token',
|
||||
kind: 'tunnel-connect-link',
|
||||
});
|
||||
});
|
||||
|
||||
test('redacts tunnel tokens from labels', () => {
|
||||
expect(redactSensitiveUrl('https://example.trycloudflare.com/connect?t=secret-token')).toBe(
|
||||
'https://example.trycloudflare.com/connect?t=%5BREDACTED%5D',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -11,13 +11,19 @@ type TauriGlobal = {
|
||||
export type DesktopHost = {
|
||||
id: string;
|
||||
label: string;
|
||||
/** Legacy/UI URL. During migration this may equal apiUrl. */
|
||||
url: string;
|
||||
/** API endpoint used by packaged Electron UI for this instance. */
|
||||
apiUrl?: string;
|
||||
/** Remote client bearer token for packaged-client API access. */
|
||||
clientToken?: string;
|
||||
};
|
||||
|
||||
export type DesktopHostsConfig = {
|
||||
hosts: DesktopHost[];
|
||||
defaultHostId: string | null;
|
||||
initialHostChoiceCompleted: boolean;
|
||||
localOrigin?: string | null;
|
||||
};
|
||||
|
||||
/** Backward-compatible input type — callers may omit `initialHostChoiceCompleted`. */
|
||||
@@ -25,14 +31,21 @@ export type DesktopHostsConfigInput = {
|
||||
hosts: DesktopHost[];
|
||||
defaultHostId: string | null;
|
||||
initialHostChoiceCompleted?: boolean;
|
||||
localClientToken?: string | null;
|
||||
};
|
||||
|
||||
export type HostProbeResult = {
|
||||
status: 'ok' | 'auth' | 'wrong-service' | 'unreachable';
|
||||
status: 'ok' | 'auth' | 'update-recommended' | 'incompatible' | 'wrong-service' | 'unreachable';
|
||||
latencyMs: number;
|
||||
};
|
||||
|
||||
const SENSITIVE_QUERY_KEY = /token|auth|secret|api/i;
|
||||
export type DesktopHostUrlResolution = {
|
||||
persistedUrl: string;
|
||||
redeemUrl: string | null;
|
||||
kind: 'normal-host' | 'tunnel-connect-link';
|
||||
};
|
||||
|
||||
const SENSITIVE_QUERY_KEY = /^(t|.*(?:token|auth|secret|api).*)$/i;
|
||||
|
||||
export const normalizeHostUrl = (raw: string): string | null => {
|
||||
const trimmed = raw.trim();
|
||||
@@ -48,6 +61,31 @@ export const normalizeHostUrl = (raw: string): string | null => {
|
||||
}
|
||||
};
|
||||
|
||||
export const resolveDesktopHostUrl = (raw: string): DesktopHostUrlResolution | null => {
|
||||
const normalized = normalizeHostUrl(raw);
|
||||
if (!normalized) return null;
|
||||
|
||||
try {
|
||||
const url = new URL(normalized);
|
||||
const pathname = url.pathname.replace(/\/+$/, '') || '/';
|
||||
if (pathname === '/connect' && url.searchParams.has('t')) {
|
||||
return {
|
||||
persistedUrl: url.origin,
|
||||
redeemUrl: url.toString(),
|
||||
kind: 'tunnel-connect-link',
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
persistedUrl: normalized,
|
||||
redeemUrl: null,
|
||||
kind: 'normal-host',
|
||||
};
|
||||
};
|
||||
|
||||
export const redactSensitiveUrl = (raw: string): string => {
|
||||
const normalized = normalizeHostUrl(raw);
|
||||
if (!normalized) {
|
||||
@@ -122,8 +160,20 @@ const parseHost = (value: unknown): DesktopHost | null => {
|
||||
const id = readString(value, 'id');
|
||||
const label = readString(value, 'label');
|
||||
const url = readString(value, 'url');
|
||||
const apiUrl = readString(value, 'apiUrl') || readString(value, 'api_url');
|
||||
const clientToken = readString(value, 'clientToken') || readString(value, 'client_token');
|
||||
if (!id || !label || !url) return null;
|
||||
return { id, label, url };
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
url,
|
||||
...(apiUrl ? { apiUrl } : {}),
|
||||
...(clientToken ? { clientToken } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
export const getDesktopHostApiUrl = (host: DesktopHost): string => {
|
||||
return normalizeHostUrl(host.apiUrl || host.url) || host.apiUrl || host.url;
|
||||
};
|
||||
|
||||
const getInvoke = (): TauriInvoke | null => {
|
||||
@@ -155,36 +205,48 @@ export const desktopHostsGet = async (): Promise<DesktopHostsConfig> => {
|
||||
|
||||
const initialHostChoiceCompleted =
|
||||
raw.initialHostChoiceCompleted === true || raw.initial_host_choice_completed === true;
|
||||
const localOrigin = readString(raw, 'localOrigin') || readString(raw, 'local_origin');
|
||||
|
||||
return { hosts, defaultHostId, initialHostChoiceCompleted };
|
||||
return { hosts, defaultHostId, initialHostChoiceCompleted, localOrigin };
|
||||
};
|
||||
|
||||
export const desktopHostsSet = async (config: DesktopHostsConfigInput): Promise<void> => {
|
||||
const invoke = getInvoke();
|
||||
if (!invoke) return;
|
||||
const input: Record<string, unknown> = {
|
||||
hosts: config.hosts,
|
||||
defaultHostId: config.defaultHostId,
|
||||
initialHostChoiceCompleted: config.initialHostChoiceCompleted,
|
||||
};
|
||||
if (config.localClientToken !== undefined) {
|
||||
input.localClientToken = config.localClientToken;
|
||||
}
|
||||
await invoke('desktop_hosts_set', {
|
||||
input: {
|
||||
hosts: config.hosts,
|
||||
defaultHostId: config.defaultHostId,
|
||||
initialHostChoiceCompleted: config.initialHostChoiceCompleted,
|
||||
},
|
||||
input,
|
||||
});
|
||||
};
|
||||
|
||||
export const desktopHostProbe = async (url: string): Promise<HostProbeResult> => {
|
||||
export const desktopLocalClientTokenGet = async (): Promise<string> => {
|
||||
const invoke = getInvoke();
|
||||
if (!invoke) return '';
|
||||
const raw = await invoke('desktop_local_client_token_get').catch(() => null);
|
||||
return typeof raw === 'string' ? raw.trim() : '';
|
||||
};
|
||||
|
||||
export const desktopHostProbe = async (url: string, options?: { clientToken?: string | null }): Promise<HostProbeResult> => {
|
||||
const invoke = getInvoke();
|
||||
if (!invoke) {
|
||||
return { status: 'unreachable', latencyMs: 0 };
|
||||
}
|
||||
|
||||
const raw = await invoke('desktop_host_probe', { url });
|
||||
const raw = await invoke('desktop_host_probe', { url, clientToken: options?.clientToken || undefined });
|
||||
if (!isRecord(raw)) {
|
||||
return { status: 'unreachable', latencyMs: 0 };
|
||||
}
|
||||
|
||||
const rawStatus = raw.status;
|
||||
const status: HostProbeResult['status'] =
|
||||
rawStatus === 'ok' || rawStatus === 'auth' || rawStatus === 'wrong-service' || rawStatus === 'unreachable'
|
||||
rawStatus === 'ok' || rawStatus === 'auth' || rawStatus === 'update-recommended' || rawStatus === 'incompatible' || rawStatus === 'wrong-service' || rawStatus === 'unreachable'
|
||||
? rawStatus
|
||||
: 'unreachable';
|
||||
|
||||
@@ -192,8 +254,8 @@ export const desktopHostProbe = async (url: string): Promise<HostProbeResult> =>
|
||||
return { status, latencyMs };
|
||||
};
|
||||
|
||||
export const desktopOpenNewWindowAtUrl = async (url: string): Promise<void> => {
|
||||
export const desktopOpenNewWindowAtUrl = async (url: string, options?: { clientToken?: string | null }): Promise<void> => {
|
||||
const invoke = getInvoke();
|
||||
if (!invoke) return;
|
||||
await invoke('desktop_new_window_at_url', { url });
|
||||
await invoke('desktop_new_window_at_url', { url, clientToken: options?.clientToken || undefined });
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { OpenChamberProjectAction } from './openchamberConfig';
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
|
||||
type DevServerInfo = {
|
||||
command: string;
|
||||
@@ -76,13 +77,13 @@ export async function detectDevServerCommand(
|
||||
|
||||
async function hasStaticIndexHtml(directory: string): Promise<boolean> {
|
||||
const target = `${directory}/index.html`;
|
||||
const content = await readOptionalTextFile(target);
|
||||
const content = await readOptionalTextFile(target, directory);
|
||||
return typeof content === 'string' && content.trim().length > 0;
|
||||
}
|
||||
|
||||
async function allocatePreviewPort(): Promise<number | null> {
|
||||
try {
|
||||
const response = await fetch('/api/system/free-port', { cache: 'no-store' });
|
||||
const response = await runtimeFetch('/api/system/free-port', { cache: 'no-store' });
|
||||
if (!response.ok) return null;
|
||||
const body = await response.json().catch(() => null) as { port?: unknown } | null;
|
||||
const port = typeof body?.port === 'number' ? body.port : null;
|
||||
@@ -133,7 +134,7 @@ function findDevScript(scripts: Record<string, string>): string | null {
|
||||
* For server-side operations, the server's package-manager.js is used.
|
||||
*/
|
||||
async function detectPackageManager(directory: string): Promise<PackageManager> {
|
||||
const packageJsonContent = await readOptionalTextFile(`${directory}/package.json`);
|
||||
const packageJsonContent = await readOptionalTextFile(`${directory}/package.json`, directory);
|
||||
if (packageJsonContent) {
|
||||
try {
|
||||
const pkg = JSON.parse(packageJsonContent) as { packageManager?: unknown };
|
||||
@@ -156,7 +157,7 @@ async function detectPackageManager(directory: string): Promise<PackageManager>
|
||||
];
|
||||
|
||||
for (const [fileName, packageManager] of lockfiles) {
|
||||
const content = await readOptionalTextFile(`${directory}/${fileName}`);
|
||||
const content = await readOptionalTextFile(`${directory}/${fileName}`, directory);
|
||||
if (typeof content === 'string' && content.trim().length > 0) {
|
||||
return packageManager;
|
||||
}
|
||||
@@ -165,7 +166,21 @@ async function detectPackageManager(directory: string): Promise<PackageManager>
|
||||
return 'npm';
|
||||
}
|
||||
|
||||
async function readOptionalTextFile(path: string): Promise<string | null> {
|
||||
async function readOptionalTextFile(path: string, directory?: string): Promise<string | null> {
|
||||
if (directory?.trim()) {
|
||||
try {
|
||||
const params = new URLSearchParams({ path, directory, optional: 'true' });
|
||||
const response = await runtimeFetch(`/api/fs/read?${params.toString()}`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (response.ok) {
|
||||
return response.text();
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the registered files API for runtimes that do not expose HTTP fs routes.
|
||||
}
|
||||
}
|
||||
|
||||
const runtimeFiles = getRegisteredRuntimeAPIs()?.files;
|
||||
if (runtimeFiles?.readFile) {
|
||||
try {
|
||||
@@ -177,7 +192,7 @@ async function readOptionalTextFile(path: string): Promise<string | null> {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/fs/read?path=${encodeURIComponent(path)}&optional=true`, {
|
||||
const response = await runtimeFetch(`/api/fs/read?path=${encodeURIComponent(path)}&optional=true`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
@@ -192,12 +207,14 @@ async function readOptionalTextFile(path: string): Promise<string | null> {
|
||||
*/
|
||||
export async function readPackageJsonScripts(directory: string): Promise<Record<string, string> | null> {
|
||||
try {
|
||||
const content = await readOptionalTextFile(`${directory}/package.json`);
|
||||
const content = await readOptionalTextFile(`${directory}/package.json`, directory);
|
||||
|
||||
if (content == null) return null;
|
||||
const pkg = JSON.parse(content);
|
||||
|
||||
return pkg.scripts || null;
|
||||
const scripts = (pkg as { scripts?: unknown }).scripts;
|
||||
return scripts && typeof scripts === 'object' && !Array.isArray(scripts)
|
||||
? scripts as Record<string, string>
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { CommandExecResult, FilesAPI, RuntimeAPIs } from '@/lib/api/types';
|
||||
import type { CommandExecResult, FilesAPI } from '@/lib/api/types';
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
|
||||
type ExecResult = { success: boolean; results: CommandExecResult[] };
|
||||
|
||||
@@ -12,8 +14,7 @@ const getBaseUrl = (): string => {
|
||||
};
|
||||
|
||||
function getRuntimeFilesAPI(): FilesAPI | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const apis = (window as typeof window & { __OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs }).__OPENCHAMBER_RUNTIME_APIS__;
|
||||
const apis = getRegisteredRuntimeAPIs();
|
||||
if (apis?.files) {
|
||||
return apis.files;
|
||||
}
|
||||
@@ -26,7 +27,7 @@ export async function execCommands(commands: string[], cwd: string): Promise<Exe
|
||||
return runtimeFiles.execCommands(commands, cwd);
|
||||
}
|
||||
|
||||
const response = await fetch(`${getBaseUrl()}/fs/exec`, {
|
||||
const response = await runtimeFetch(`${getBaseUrl()}/fs/exec`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ commands, cwd, background: false }),
|
||||
|
||||
@@ -141,17 +141,10 @@ export async function saveAsMarkdownDesktop(content: string, filename: string):
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/vscode/save-markdown', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ fileName: filename, content }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await getRegisteredRuntimeAPIs()?.vscode?.saveMarkdown?.({ fileName: filename, content }) as { saved?: boolean; path?: string } | undefined;
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = await response.json() as { saved?: boolean; path?: string };
|
||||
if (payload.saved !== true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
|
||||
|
||||
import type { RuntimeAPIs } from './api/types';
|
||||
import * as gitHttp from './gitApiHttp';
|
||||
import { opencodeClient } from './opencode/client';
|
||||
import { renderMagicPrompt } from './magicPrompts';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useContextStore } from '@/stores/contextStore';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
|
||||
export type {
|
||||
GitStatus,
|
||||
@@ -39,17 +38,8 @@ export type {
|
||||
CommitFileDiffResponse,
|
||||
} from './api/types';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs;
|
||||
}
|
||||
}
|
||||
|
||||
const getRuntimeGit = () => {
|
||||
if (typeof window !== 'undefined' && window.__OPENCHAMBER_RUNTIME_APIS__?.git) {
|
||||
return window.__OPENCHAMBER_RUNTIME_APIS__.git;
|
||||
}
|
||||
return null;
|
||||
return getRegisteredRuntimeAPIs()?.git ?? null;
|
||||
};
|
||||
|
||||
const requestChatForceScrollBottom = (sessionId: string) => {
|
||||
|
||||
@@ -35,33 +35,12 @@ import type {
|
||||
RevertCommitResponse,
|
||||
ResetToCommitResponse,
|
||||
} from './api/types';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__OPENCHAMBER_DESKTOP_SERVER__?: {
|
||||
origin: string;
|
||||
opencodePort: number | null;
|
||||
apiPrefix: string;
|
||||
cliAvailable: boolean;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const resolveBaseOrigin = (): string => {
|
||||
if (typeof window === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
const desktopOrigin = window.__OPENCHAMBER_DESKTOP_SERVER__?.origin;
|
||||
if (desktopOrigin) {
|
||||
return desktopOrigin;
|
||||
}
|
||||
return window.location.origin;
|
||||
};
|
||||
import { runtimeFetch } from './runtime-fetch';
|
||||
import { getRuntimeUrlResolver } from './runtime-url';
|
||||
|
||||
const API_BASE = '/api/git';
|
||||
const GIT_STATUS_CACHE_TTL_MS = 1200;
|
||||
const GIT_REPO_CHECK_CACHE_TTL_MS = 5000;
|
||||
|
||||
const gitStatusCache = new Map<string, { value: GitStatus; expiresAt: number }>();
|
||||
const gitStatusInFlight = new Map<string, Promise<GitStatus>>();
|
||||
const gitRepoCache = new Map<string, { value: boolean; expiresAt: number }>();
|
||||
@@ -74,19 +53,10 @@ function buildUrl(
|
||||
directory: string | null | undefined,
|
||||
params?: Record<string, string | number | boolean | undefined>
|
||||
): string {
|
||||
const url = new URL(path, resolveBaseOrigin());
|
||||
if (directory) {
|
||||
url.searchParams.set('directory', directory);
|
||||
}
|
||||
const query: Record<string, string | number | boolean | undefined> = { ...params };
|
||||
if (directory) query.directory = directory;
|
||||
|
||||
if (params) {
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value === undefined) continue;
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
return getRuntimeUrlResolver().api(path, query);
|
||||
}
|
||||
|
||||
export async function checkIsGitRepository(directory: string): Promise<boolean> {
|
||||
@@ -103,7 +73,7 @@ export async function checkIsGitRepository(directory: string): Promise<boolean>
|
||||
}
|
||||
|
||||
const task = (async () => {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/check`, directory));
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/check`, directory));
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to check git repository: ${response.statusText}`);
|
||||
}
|
||||
@@ -141,7 +111,7 @@ export async function getGitStatus(directory: string, options?: { mode?: 'light'
|
||||
}
|
||||
|
||||
const task = (async () => {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/status`, directory, mode ? { mode } : undefined));
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/status`, directory, mode ? { mode } : undefined));
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get git status: ${response.statusText}`);
|
||||
}
|
||||
@@ -169,7 +139,7 @@ export async function getGitDiff(directory: string, options: GetGitDiffOptions):
|
||||
throw new Error('path is required to fetch git diff');
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
const response = await runtimeFetch(
|
||||
buildUrl(`${API_BASE}/diff`, directory, {
|
||||
path,
|
||||
staged: staged ? 'true' : undefined,
|
||||
@@ -190,7 +160,7 @@ export async function getGitFileDiff(directory: string, options: GetGitFileDiffO
|
||||
throw new Error('path is required to fetch git file diff');
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
const response = await runtimeFetch(
|
||||
buildUrl(`${API_BASE}/file-diff`, directory, {
|
||||
path,
|
||||
staged: staged ? 'true' : undefined,
|
||||
@@ -213,7 +183,7 @@ export async function revertGitFile(
|
||||
throw new Error('path is required to revert git changes');
|
||||
}
|
||||
|
||||
const response = await fetch(buildUrl(`${API_BASE}/revert`, directory), {
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/revert`, directory), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: filePath, scope: options?.scope }),
|
||||
@@ -238,7 +208,7 @@ export async function stageGitFiles(directory: string, filePaths: string[]): Pro
|
||||
throw new Error('path is required to stage git changes');
|
||||
}
|
||||
|
||||
const response = await fetch(buildUrl(`${API_BASE}/stage`, directory), {
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/stage`, directory), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ paths }),
|
||||
@@ -261,7 +231,7 @@ export async function unstageGitFiles(directory: string, filePaths: string[]): P
|
||||
throw new Error('path is required to unstage git changes');
|
||||
}
|
||||
|
||||
const response = await fetch(buildUrl(`${API_BASE}/unstage`, directory), {
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/unstage`, directory), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ paths }),
|
||||
@@ -277,7 +247,7 @@ export async function isLinkedWorktree(directory: string): Promise<boolean> {
|
||||
if (!directory) {
|
||||
return false;
|
||||
}
|
||||
const response = await fetch(buildUrl(`${API_BASE}/worktree-type`, directory));
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/worktree-type`, directory));
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to detect worktree type: ${response.statusText}`);
|
||||
}
|
||||
@@ -286,7 +256,7 @@ export async function isLinkedWorktree(directory: string): Promise<boolean> {
|
||||
}
|
||||
|
||||
export async function getGitBranches(directory: string): Promise<GitBranch> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/branches`, directory));
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/branches`, directory));
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get branches: ${response.statusText}`);
|
||||
}
|
||||
@@ -298,7 +268,7 @@ export async function deleteGitBranch(directory: string, payload: GitDeleteBranc
|
||||
throw new Error('branch is required to delete a branch');
|
||||
}
|
||||
|
||||
const response = await fetch(buildUrl(`${API_BASE}/branches`, directory), {
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/branches`, directory), {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
@@ -317,7 +287,7 @@ export async function deleteRemoteBranch(directory: string, payload: GitDeleteRe
|
||||
throw new Error('branch is required to delete remote branch');
|
||||
}
|
||||
|
||||
const response = await fetch(buildUrl(`${API_BASE}/remote-branches`, directory), {
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/remote-branches`, directory), {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
@@ -337,7 +307,7 @@ export async function removeRemote(directory: string, payload: GitRemoveRemotePa
|
||||
throw new Error('remote is required to remove a remote');
|
||||
}
|
||||
|
||||
const response = await fetch(buildUrl(`${API_BASE}/remotes`, directory), {
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/remotes`, directory), {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ remote }),
|
||||
@@ -371,7 +341,7 @@ export async function generateCommitMessage(
|
||||
body.modelId = options.modelId;
|
||||
}
|
||||
|
||||
const response = await fetch(buildUrl(`${API_BASE}/commit-message`, directory), {
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/commit-message`, directory), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
@@ -438,7 +408,7 @@ export async function generatePullRequestDescription(
|
||||
requestBody.modelId = modelId;
|
||||
}
|
||||
|
||||
const response = await fetch(buildUrl(`${API_BASE}/pr-description`, directory), {
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/pr-description`, directory), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(requestBody),
|
||||
@@ -459,7 +429,7 @@ export async function generatePullRequestDescription(
|
||||
}
|
||||
|
||||
export async function listGitWorktrees(directory: string): Promise<GitWorktreeInfo[]> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/worktrees`, directory));
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/worktrees`, directory));
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to list worktrees');
|
||||
@@ -468,7 +438,7 @@ export async function listGitWorktrees(directory: string): Promise<GitWorktreeIn
|
||||
}
|
||||
|
||||
export async function validateGitWorktree(directory: string, payload: CreateGitWorktreePayload): Promise<GitWorktreeValidationResult> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/worktrees/validate`, directory), {
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/worktrees/validate`, directory), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload ?? {}),
|
||||
@@ -483,7 +453,7 @@ export async function validateGitWorktree(directory: string, payload: CreateGitW
|
||||
}
|
||||
|
||||
export async function getGitWorktreeBootstrapStatus(directory: string): Promise<import('./api/types').GitWorktreeBootstrapStatus> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/worktrees/bootstrap-status`, directory));
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/worktrees/bootstrap-status`, directory));
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to get worktree bootstrap status');
|
||||
@@ -492,7 +462,7 @@ export async function getGitWorktreeBootstrapStatus(directory: string): Promise<
|
||||
}
|
||||
|
||||
export async function previewGitWorktree(directory: string, payload: CreateGitWorktreePayload): Promise<GitWorktreeCreateResult> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/worktrees/preview`, directory), {
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/worktrees/preview`, directory), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload ?? {}),
|
||||
@@ -507,7 +477,7 @@ export async function previewGitWorktree(directory: string, payload: CreateGitWo
|
||||
}
|
||||
|
||||
export async function createGitWorktree(directory: string, payload: CreateGitWorktreePayload): Promise<GitWorktreeCreateResult> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/worktrees`, directory), {
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/worktrees`, directory), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload ?? {}),
|
||||
@@ -522,7 +492,7 @@ export async function createGitWorktree(directory: string, payload: CreateGitWor
|
||||
}
|
||||
|
||||
export async function deleteGitWorktree(directory: string, payload: RemoveGitWorktreePayload): Promise<{ success: boolean }> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/worktrees`, directory), {
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/worktrees`, directory), {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload ?? {}),
|
||||
@@ -541,7 +511,7 @@ export async function createGitCommit(
|
||||
message: string,
|
||||
options: CreateGitCommitOptions = {}
|
||||
): Promise<GitCommitResult> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/commit`, directory), {
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/commit`, directory), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -562,7 +532,7 @@ export async function gitPush(
|
||||
directory: string,
|
||||
options: { remote?: string; branch?: string; options?: string[] | Record<string, unknown> } = {}
|
||||
): Promise<GitPushResult> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/push`, directory), {
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/push`, directory), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(options),
|
||||
@@ -578,7 +548,7 @@ export async function gitPull(
|
||||
directory: string,
|
||||
options: GitPullOptions = {}
|
||||
): Promise<GitPullResult> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/pull`, directory), {
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/pull`, directory), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(options),
|
||||
@@ -594,7 +564,7 @@ export async function gitFetch(
|
||||
directory: string,
|
||||
options: { remote?: string; branch?: string } = {}
|
||||
): Promise<{ success: boolean }> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/fetch`, directory), {
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/fetch`, directory), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(options),
|
||||
@@ -607,7 +577,7 @@ export async function gitFetch(
|
||||
}
|
||||
|
||||
export async function listGitStashes(directory: string): Promise<{ stashes: GitStashEntry[] }> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/stashes`, directory));
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/stashes`, directory));
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to list stashes');
|
||||
@@ -616,7 +586,7 @@ export async function listGitStashes(directory: string): Promise<{ stashes: GitS
|
||||
}
|
||||
|
||||
export async function countGitStashFiles(directory: string, refs: string[]): Promise<{ counts: Record<string, number> }> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/stashes/file-counts`, directory), {
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/stashes/file-counts`, directory), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refs }),
|
||||
@@ -629,7 +599,7 @@ export async function countGitStashFiles(directory: string, refs: string[]): Pro
|
||||
}
|
||||
|
||||
export async function stashGitChanges(directory: string, options: { message?: string } = {}): Promise<{ success: boolean; created: boolean; message: string; output: string }> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/stash`, directory), {
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/stash`, directory), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(options),
|
||||
@@ -642,7 +612,7 @@ export async function stashGitChanges(directory: string, options: { message?: st
|
||||
}
|
||||
|
||||
const postStashRef = async (directory: string, path: string, options: { ref: string }): Promise<{ success: boolean; ref: string }> => {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/${path}`, directory), {
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/${path}`, directory), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(options),
|
||||
@@ -659,7 +629,7 @@ export const popGitStash = (directory: string, options: { ref: string }) => post
|
||||
export const dropGitStash = (directory: string, options: { ref: string }) => postStashRef(directory, 'stash/drop', options);
|
||||
|
||||
export async function checkoutBranch(directory: string, branch: string): Promise<{ success: boolean; branch: string }> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/checkout`, directory), {
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/checkout`, directory), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ branch }),
|
||||
@@ -676,7 +646,7 @@ export async function createBranch(
|
||||
name: string,
|
||||
startPoint?: string
|
||||
): Promise<{ success: boolean; branch: string }> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/branches`, directory), {
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/branches`, directory), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, startPoint }),
|
||||
@@ -693,7 +663,7 @@ export async function renameBranch(
|
||||
oldName: string,
|
||||
newName: string
|
||||
): Promise<{ success: boolean; branch: string }> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/branches/rename`, directory), {
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/branches/rename`, directory), {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ oldName, newName }),
|
||||
@@ -709,7 +679,7 @@ export async function getGitLog(
|
||||
directory: string,
|
||||
options: GitLogOptions = {}
|
||||
): Promise<GitLogResponse> {
|
||||
const response = await fetch(
|
||||
const response = await runtimeFetch(
|
||||
buildUrl(`${API_BASE}/log`, directory, {
|
||||
maxCount: options.maxCount,
|
||||
from: options.from,
|
||||
@@ -729,7 +699,7 @@ export async function getCommitFiles(
|
||||
directory: string,
|
||||
hash: string
|
||||
): Promise<GitCommitFilesResponse> {
|
||||
const response = await fetch(
|
||||
const response = await runtimeFetch(
|
||||
buildUrl(`${API_BASE}/commit-files`, directory, { hash })
|
||||
);
|
||||
if (!response.ok) {
|
||||
@@ -744,7 +714,7 @@ export async function getCommitFileDiff(
|
||||
filePath: string,
|
||||
isBinary: boolean
|
||||
): Promise<CommitFileDiffResponse> {
|
||||
const response = await fetch(
|
||||
const response = await runtimeFetch(
|
||||
buildUrl(`${API_BASE}/commit-file-diff`, directory, {
|
||||
hash,
|
||||
path: filePath,
|
||||
@@ -758,7 +728,7 @@ export async function getCommitFileDiff(
|
||||
}
|
||||
|
||||
export async function getGitIdentities(): Promise<GitIdentityProfile[]> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/identities`, undefined));
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/identities`, undefined));
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get git identities: ${response.statusText}`);
|
||||
}
|
||||
@@ -766,7 +736,7 @@ export async function getGitIdentities(): Promise<GitIdentityProfile[]> {
|
||||
}
|
||||
|
||||
export async function createGitIdentity(profile: GitIdentityProfile): Promise<GitIdentityProfile> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/identities`, undefined), {
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/identities`, undefined), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(profile),
|
||||
@@ -779,7 +749,7 @@ export async function createGitIdentity(profile: GitIdentityProfile): Promise<Gi
|
||||
}
|
||||
|
||||
export async function updateGitIdentity(id: string, updates: GitIdentityProfile): Promise<GitIdentityProfile> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/identities/${id}`, undefined), {
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/identities/${id}`, undefined), {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates),
|
||||
@@ -792,7 +762,7 @@ export async function updateGitIdentity(id: string, updates: GitIdentityProfile)
|
||||
}
|
||||
|
||||
export async function deleteGitIdentity(id: string): Promise<void> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/identities/${id}`, undefined), {
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/identities/${id}`, undefined), {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) {
|
||||
@@ -805,7 +775,7 @@ export async function getCurrentGitIdentity(directory: string): Promise<GitIdent
|
||||
if (!directory) {
|
||||
return null;
|
||||
}
|
||||
const response = await fetch(buildUrl(`${API_BASE}/current-identity`, directory));
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/current-identity`, directory));
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get current git identity: ${response.statusText}`);
|
||||
}
|
||||
@@ -824,7 +794,7 @@ export async function hasLocalIdentity(directory: string): Promise<boolean> {
|
||||
if (!directory) {
|
||||
return false;
|
||||
}
|
||||
const response = await fetch(buildUrl(`${API_BASE}/has-local-identity`, directory));
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/has-local-identity`, directory));
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to check local identity: ${response.statusText}`);
|
||||
}
|
||||
@@ -833,7 +803,7 @@ export async function hasLocalIdentity(directory: string): Promise<boolean> {
|
||||
}
|
||||
|
||||
export async function getGlobalGitIdentity(): Promise<GitIdentitySummary | null> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/global-identity`, undefined));
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/global-identity`, undefined));
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get global git identity: ${response.statusText}`);
|
||||
}
|
||||
@@ -852,7 +822,7 @@ export async function setGitIdentity(
|
||||
directory: string,
|
||||
profileId: string
|
||||
): Promise<{ success: boolean; profile: GitIdentityProfile }> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/set-identity`, directory), {
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/set-identity`, directory), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ profileId }),
|
||||
@@ -865,7 +835,7 @@ export async function setGitIdentity(
|
||||
}
|
||||
|
||||
export async function discoverGitCredentials(): Promise<DiscoveredGitCredential[]> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/discover-credentials`, undefined));
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/discover-credentials`, undefined));
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to discover git credentials: ${response.statusText}`);
|
||||
}
|
||||
@@ -876,7 +846,7 @@ export async function getRemoteUrl(directory: string, remote?: string): Promise<
|
||||
if (!directory) {
|
||||
return null;
|
||||
}
|
||||
const response = await fetch(buildUrl(`${API_BASE}/remote-url`, directory, { remote }));
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/remote-url`, directory, { remote }));
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
@@ -885,7 +855,7 @@ export async function getRemoteUrl(directory: string, remote?: string): Promise<
|
||||
}
|
||||
|
||||
export async function getRemotes(directory: string): Promise<Array<{ name: string; fetchUrl: string; pushUrl: string }>> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/remotes`, directory));
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/remotes`, directory));
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get remotes: ${response.statusText}`);
|
||||
}
|
||||
@@ -896,7 +866,7 @@ export async function rebase(
|
||||
directory: string,
|
||||
options: { onto: string }
|
||||
): Promise<{ success: boolean; conflict?: boolean; conflictFiles?: string[] }> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/rebase`, directory), {
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/rebase`, directory), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(options),
|
||||
@@ -909,7 +879,7 @@ export async function rebase(
|
||||
}
|
||||
|
||||
export async function abortRebase(directory: string): Promise<{ success: boolean }> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/rebase/abort`, directory), {
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/rebase/abort`, directory), {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!response.ok) {
|
||||
@@ -923,7 +893,7 @@ export async function merge(
|
||||
directory: string,
|
||||
options: { branch: string }
|
||||
): Promise<{ success: boolean; conflict?: boolean; conflictFiles?: string[] }> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/merge`, directory), {
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/merge`, directory), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(options),
|
||||
@@ -939,7 +909,7 @@ export async function checkoutCommit(
|
||||
directory: string,
|
||||
hash: string
|
||||
): Promise<CheckoutCommitResponse> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/checkout-commit`, directory), {
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/checkout-commit`, directory), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ hash }),
|
||||
@@ -955,7 +925,7 @@ export async function cherryPick(
|
||||
directory: string,
|
||||
hash: string
|
||||
): Promise<CherryPickResponse> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/cherry-pick`, directory), {
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/cherry-pick`, directory), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ hash }),
|
||||
@@ -971,7 +941,7 @@ export async function revertCommit(
|
||||
directory: string,
|
||||
hash: string
|
||||
): Promise<RevertCommitResponse> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/revert-commit`, directory), {
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/revert-commit`, directory), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ hash }),
|
||||
@@ -989,7 +959,7 @@ export async function resetToCommit(
|
||||
mode: 'soft' | 'mixed' | 'hard',
|
||||
force?: boolean
|
||||
): Promise<ResetToCommitResponse> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/reset-to-commit`, directory), {
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/reset-to-commit`, directory), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ hash, mode, force }),
|
||||
@@ -1002,7 +972,7 @@ export async function resetToCommit(
|
||||
}
|
||||
|
||||
export async function abortMerge(directory: string): Promise<{ success: boolean }> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/merge/abort`, directory), {
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/merge/abort`, directory), {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!response.ok) {
|
||||
@@ -1013,7 +983,7 @@ export async function abortMerge(directory: string): Promise<{ success: boolean
|
||||
}
|
||||
|
||||
export async function continueRebase(directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/rebase/continue`, directory), {
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/rebase/continue`, directory), {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!response.ok) {
|
||||
@@ -1024,7 +994,7 @@ export async function continueRebase(directory: string): Promise<{ success: bool
|
||||
}
|
||||
|
||||
export async function continueMerge(directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/merge/continue`, directory), {
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/merge/continue`, directory), {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!response.ok) {
|
||||
@@ -1048,7 +1018,7 @@ export async function stashPop(directory: string): Promise<{ success: boolean }>
|
||||
}
|
||||
|
||||
export async function getConflictDetails(directory: string): Promise<MergeConflictDetails> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/conflict-details`, directory));
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/conflict-details`, directory));
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get conflict details: ${response.statusText}`);
|
||||
}
|
||||
@@ -1064,7 +1034,7 @@ export async function validateWorktreeDirectory(
|
||||
resolvedWorktreeRoot: string | null;
|
||||
resolvedCwd: string | null;
|
||||
}> {
|
||||
const response = await fetch(`${API_BASE}/validate-directory`, {
|
||||
const response = await runtimeFetch(`${API_BASE}/validate-directory`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ directory, worktreeRoot }),
|
||||
@@ -1087,7 +1057,7 @@ export async function canonicalizeWorktreeState(
|
||||
degraded: boolean;
|
||||
attentionReason?: 'merge' | 'rebase' | 'cherry-pick' | 'revert' | 'bisect' | null;
|
||||
}> {
|
||||
const response = await fetch(`${API_BASE}/canonicalize-worktree-state`, {
|
||||
const response = await runtimeFetch(`${API_BASE}/canonicalize-worktree-state`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ directory }),
|
||||
|
||||
@@ -231,19 +231,50 @@ export const settingsDict = {
|
||||
'settings.magicPrompts.sidebar.item.sessionFusion': 'Fusion',
|
||||
'settings.remoteInstances.sidebar.title': 'Remote Instances',
|
||||
'settings.remoteInstances.sidebar.total': 'Total {count}',
|
||||
'settings.remoteInstances.sidebar.newSshInstanceName': 'New SSH Instance',
|
||||
'settings.remoteInstances.sidebar.actions.addSshInstance': 'Add SSH instance',
|
||||
'settings.remoteInstances.sidebar.newSshInstanceName': 'New SSH connection',
|
||||
'settings.remoteInstances.sidebar.actions.addSshInstance': 'Add SSH connection',
|
||||
'settings.remoteInstances.sidebar.actions.connect': 'Connect',
|
||||
'settings.remoteInstances.sidebar.actions.disconnect': 'Disconnect',
|
||||
'settings.remoteInstances.sidebar.actions.retry': 'Retry',
|
||||
'settings.remoteInstances.sidebar.actions.remove': 'Remove',
|
||||
'settings.remoteInstances.sidebar.confirm.localPortInUseRetry': 'Local port is already in use. Pick a random free local port and retry?',
|
||||
'settings.remoteInstances.sidebar.toast.createFailed': 'Failed to create SSH instance',
|
||||
'settings.remoteInstances.sidebar.toast.createFailed': 'Failed to create SSH connection',
|
||||
'settings.remoteInstances.sidebar.toast.retriedWithRandomPort': 'Retried with a random local port',
|
||||
'settings.remoteInstances.sidebar.toast.connectFailed': 'Failed to connect instance',
|
||||
'settings.remoteInstances.sidebar.toast.disconnectFailed': 'Failed to disconnect instance',
|
||||
'settings.remoteInstances.sidebar.toast.retryFailed': 'Failed to retry connection',
|
||||
'settings.remoteInstances.sidebar.toast.removeFailed': 'Failed to remove instance',
|
||||
'settings.remoteInstances.direct.sidebarTitle': 'Server links',
|
||||
'settings.remoteInstances.direct.sidebarDescription': 'Connect with a link or token',
|
||||
'settings.remoteInstances.direct.title': 'Other OpenChamber servers',
|
||||
'settings.remoteInstances.direct.description': 'Add another OpenChamber server by URL. Use this when the server is already running and you have a connection token.',
|
||||
'settings.remoteInstances.direct.field.labelPlaceholder': 'Label (optional)',
|
||||
'settings.remoteInstances.direct.field.urlPlaceholder': 'https://host:port',
|
||||
'settings.remoteInstances.direct.field.tokenPlaceholder': 'Connection token (optional for trusted local servers)',
|
||||
'settings.remoteInstances.direct.note': 'Connection tokens are saved on this device and used only when this app connects to that server.',
|
||||
'settings.remoteInstances.direct.actions.add': 'Add Server',
|
||||
'settings.remoteInstances.direct.import.description': 'Paste a connection link from another OpenChamber server.',
|
||||
'settings.remoteInstances.direct.import.placeholder': 'openchamber://connect?...',
|
||||
'settings.remoteInstances.direct.import.action': 'Import Link',
|
||||
'settings.remoteInstances.direct.error.invalidConnectLink': 'Invalid OpenChamber connection link.',
|
||||
'settings.remoteInstances.direct.state.loading': 'Loading instances...',
|
||||
'settings.remoteInstances.direct.state.empty': 'No other servers added yet.',
|
||||
'settings.remoteInstances.clientAuth.title': 'Connect to this server',
|
||||
'settings.remoteInstances.clientAuth.description': 'Create a secure link or token so OpenChamber Desktop can connect to this server.',
|
||||
'settings.remoteInstances.clientAuth.field.labelPlaceholder': 'Device name (optional)',
|
||||
'settings.remoteInstances.clientAuth.actions.create': 'Create Token',
|
||||
'settings.remoteInstances.clientAuth.actions.pair': 'Create Link',
|
||||
'settings.remoteInstances.clientAuth.actions.revoke': 'Revoke',
|
||||
'settings.remoteInstances.clientAuth.actions.clearRevoked': 'Clear revoked',
|
||||
'settings.remoteInstances.clientAuth.qrAlt': 'OpenChamber connection QR code',
|
||||
'settings.remoteInstances.clientAuth.pairingUrl': 'Connection link',
|
||||
'settings.remoteInstances.clientAuth.createdToken': 'Copy this token now. For security, it will not be shown again.',
|
||||
'settings.remoteInstances.clientAuth.state.loading': 'Loading tokens...',
|
||||
'settings.remoteInstances.clientAuth.state.empty': 'No devices connected yet.',
|
||||
'settings.remoteInstances.clientAuth.state.revoked': 'Revoked',
|
||||
'settings.remoteInstances.clientAuth.state.thisDevice': 'This device',
|
||||
'settings.remoteInstances.clientAuth.lastUsed': 'Last used {date}',
|
||||
'settings.remoteInstances.clientAuth.neverUsed': 'Never used',
|
||||
'settings.remoteInstances.sidebar.phase.ready': 'Ready',
|
||||
'settings.remoteInstances.sidebar.phase.error': 'Error',
|
||||
'settings.remoteInstances.sidebar.phase.reconnect': 'Reconnect',
|
||||
@@ -254,20 +285,20 @@ export const settingsDict = {
|
||||
'settings.remoteInstances.sidebar.phase.connecting': 'Connecting',
|
||||
'settings.remoteInstances.sidebar.phase.idle': 'Idle',
|
||||
'settings.remoteInstances.page.section.instance': 'Instance',
|
||||
'settings.remoteInstances.page.section.instanceDescription': 'Core SSH settings.',
|
||||
'settings.remoteInstances.page.section.instanceDescription': 'Choose the SSH command and a display name for this connection.',
|
||||
'settings.remoteInstances.page.field.mode': 'Mode',
|
||||
'settings.remoteInstances.page.field.modeHint': 'Managed installs/updates and starts OpenChamber remotely. External assumes it is already running.',
|
||||
'settings.remoteInstances.page.field.modeHint': 'Choose whether OpenChamber should start the server for you, or connect to one that is already running.',
|
||||
'settings.remoteInstances.page.field.modePlaceholder': 'Select mode',
|
||||
'settings.remoteInstances.page.field.modeManaged': 'Managed (auto start)',
|
||||
'settings.remoteInstances.page.field.modeExternal': 'External (already running)',
|
||||
'settings.remoteInstances.page.field.modeManaged': 'Start it for me',
|
||||
'settings.remoteInstances.page.field.modeExternal': 'Already running',
|
||||
'settings.remoteInstances.page.field.preferredRemotePort': 'Preferred remote port',
|
||||
'settings.remoteInstances.page.field.preferredRemotePortHint': 'Port OpenChamber should use on the remote host. Leave empty to let the runtime choose.',
|
||||
'settings.remoteInstances.page.field.preferredRemotePortHint': 'Port to use on the remote machine. Leave empty to choose one automatically.',
|
||||
'settings.remoteInstances.page.field.keepServerRunning': 'Keep server running',
|
||||
'settings.remoteInstances.page.field.keepServerRunningHint': 'If enabled, OpenChamber daemon is left running remotely when you disconnect.',
|
||||
'settings.remoteInstances.page.field.keepServerRunningHint': 'Keep OpenChamber running on the remote machine after you disconnect.',
|
||||
'settings.remoteInstances.page.field.bindHost': 'Bind host',
|
||||
'settings.remoteInstances.page.field.bindHostHint': 'Network interface for the main local URL. Use 127.0.0.1/localhost for local-only access.',
|
||||
'settings.remoteInstances.page.field.bindHostHint': 'Where the local connection should listen. Use 127.0.0.1 or localhost unless you need LAN access.',
|
||||
'settings.remoteInstances.page.field.preferredLocalPort': 'Preferred local port',
|
||||
'settings.remoteInstances.page.field.preferredLocalPortHint': 'Preferred local port for the main OpenChamber tunnel. Leave empty for auto-select.',
|
||||
'settings.remoteInstances.page.field.preferredLocalPortHint': 'Local port to open for this connection. Leave empty to choose one automatically.',
|
||||
'settings.remoteInstances.page.field.forwardType': 'Forward type',
|
||||
'settings.remoteInstances.page.field.localHostPlaceholder': '127.0.0.1',
|
||||
'settings.remoteInstances.page.field.remoteHostPlaceholder': '127.0.0.1',
|
||||
@@ -276,7 +307,7 @@ export const settingsDict = {
|
||||
'settings.remoteInstances.page.preview.localSocks5': '(local SOCKS5)',
|
||||
'settings.remoteInstances.page.preview.local': '(local)',
|
||||
'settings.remoteInstances.page.preview.remote': '(remote)',
|
||||
'settings.remoteInstances.page.toast.openLocalEndpointFailed': 'Failed to open local endpoint',
|
||||
'settings.remoteInstances.page.toast.openLocalEndpointFailed': 'Failed to open local address',
|
||||
'settings.remoteInstances.page.toast.localUrlCopied': 'Local URL copied',
|
||||
'settings.remoteInstances.page.actions.copyLocalUrl': 'Copy local URL',
|
||||
'settings.remoteInstances.page.actions.open': 'Open',
|
||||
@@ -978,26 +1009,26 @@ export const settingsDict = {
|
||||
'settings.usage.pace.waitSeparator': ' · Wait ',
|
||||
'settings.usage.pace.predictionLabel': 'Pred: ',
|
||||
'settings.remoteInstances.page.title': 'Remote Instance',
|
||||
'settings.remoteInstances.page.description': 'Configure SSH connection, remote server, and forwarding settings.',
|
||||
'settings.remoteInstances.page.description': 'Connect to another machine over SSH and open OpenChamber there.',
|
||||
'settings.remoteInstances.page.empty.selectInstance': 'Select an instance to view and edit its settings.',
|
||||
'settings.remoteInstances.page.empty.noExtraForwards': 'No extra port forwards configured.',
|
||||
'settings.remoteInstances.page.section.actions': 'Actions',
|
||||
'settings.remoteInstances.page.section.actionsDescription': 'Connect, reconnect, inspect logs, or remove this instance.',
|
||||
'settings.remoteInstances.page.section.remoteServer': 'Remote Server',
|
||||
'settings.remoteInstances.page.section.remoteServerDescription': 'How OpenChamber is managed and started on the remote host.',
|
||||
'settings.remoteInstances.page.section.mainTunnel': 'Main Tunnel',
|
||||
'settings.remoteInstances.page.section.mainTunnelDescription': 'Primary local endpoint for this remote instance.',
|
||||
'settings.remoteInstances.page.section.actionsDescription': 'Connect, reconnect, view logs, or remove this connection.',
|
||||
'settings.remoteInstances.page.section.remoteServer': 'OpenChamber on the remote machine',
|
||||
'settings.remoteInstances.page.section.remoteServerDescription': 'Choose how OpenChamber should run after SSH connects.',
|
||||
'settings.remoteInstances.page.section.mainTunnel': 'Local access',
|
||||
'settings.remoteInstances.page.section.mainTunnelDescription': 'Choose the local address used to open this remote OpenChamber server.',
|
||||
'settings.remoteInstances.page.section.authentication': 'Authentication',
|
||||
'settings.remoteInstances.page.section.authenticationDescription': 'Optional credentials for SSH and the remote OpenChamber UI.',
|
||||
'settings.remoteInstances.page.section.portForwards': 'Port Forwards',
|
||||
'settings.remoteInstances.page.section.portForwardsDescription': 'Additional SSH forwards beyond the main tunnel.',
|
||||
'settings.remoteInstances.page.section.portForwardsDescription': 'Optional extra ports to make available through this SSH connection.',
|
||||
'settings.remoteInstances.page.field.sshCommand': 'SSH command',
|
||||
'settings.remoteInstances.page.field.sshCommandPlaceholder': 'ssh user@host',
|
||||
'settings.remoteInstances.page.field.nickname': 'Nickname',
|
||||
'settings.remoteInstances.page.field.nicknamePlaceholder': 'My remote host',
|
||||
'settings.remoteInstances.page.field.nicknamePlaceholder': 'Work laptop',
|
||||
'settings.remoteInstances.page.field.connectionTimeoutSeconds': 'Connection timeout (seconds)',
|
||||
'settings.remoteInstances.page.field.installMethod': 'Install method',
|
||||
'settings.remoteInstances.page.field.installMethodHint': 'How OpenChamber is installed when running in managed mode.',
|
||||
'settings.remoteInstances.page.field.installMethodHint': 'How OpenChamber should be placed on the remote machine when this app starts it for you.',
|
||||
'settings.remoteInstances.page.field.selectInstallMethodPlaceholder': 'Select install method',
|
||||
'settings.remoteInstances.page.field.installMethodDownloadRelease': 'Download release',
|
||||
'settings.remoteInstances.page.field.installMethodUploadBundle': 'Upload bundle',
|
||||
@@ -1006,14 +1037,14 @@ export const settingsDict = {
|
||||
'settings.remoteInstances.page.field.sshPasswordPlaceholder': 'Enter SSH password',
|
||||
'settings.remoteInstances.page.field.uiPasswordOptional': 'UI password (optional)',
|
||||
'settings.remoteInstances.page.field.uiPasswordPlaceholder': 'Enter UI password',
|
||||
'settings.remoteInstances.page.field.forwardTypeHint': 'Choose local (-L), remote (-R), or dynamic (-D) forwarding.',
|
||||
'settings.remoteInstances.page.field.forwardTypeHint': 'Choose what kind of port access this SSH connection should provide.',
|
||||
'settings.remoteInstances.page.field.typePlaceholder': 'Type',
|
||||
'settings.remoteInstances.page.forwardType.local': 'Local (-L)',
|
||||
'settings.remoteInstances.page.forwardType.remote': 'Remote (-R)',
|
||||
'settings.remoteInstances.page.forwardType.dynamic': 'Dynamic (-D)',
|
||||
'settings.remoteInstances.page.forwardTypeDescription.local': 'Forward local traffic to a remote destination.',
|
||||
'settings.remoteInstances.page.forwardTypeDescription.remote': 'Expose a remote endpoint and forward it back to your local machine.',
|
||||
'settings.remoteInstances.page.forwardTypeDescription.dynamic': 'Expose a local SOCKS5 proxy over SSH.',
|
||||
'settings.remoteInstances.page.forwardTypeDescription.local': 'Open a local port that connects to something on the remote machine.',
|
||||
'settings.remoteInstances.page.forwardTypeDescription.remote': 'Open a port on the remote machine that connects back to your computer.',
|
||||
'settings.remoteInstances.page.forwardTypeDescription.dynamic': 'Open a local SOCKS proxy through the SSH connection.',
|
||||
'settings.remoteInstances.page.actions.create': 'Create',
|
||||
'settings.remoteInstances.page.actions.cancel': 'Cancel',
|
||||
'settings.remoteInstances.page.actions.connecting': 'Connecting...',
|
||||
@@ -1024,7 +1055,7 @@ export const settingsDict = {
|
||||
'settings.remoteInstances.page.actions.enableForwardAria': 'Enable forward',
|
||||
'settings.remoteInstances.page.actions.openLocal': 'Open local',
|
||||
'settings.remoteInstances.page.actions.addForward': 'Add forward',
|
||||
'settings.remoteInstances.page.import.sectionTitle': 'Import from SSH config',
|
||||
'settings.remoteInstances.page.import.sectionTitle': 'Saved SSH hosts',
|
||||
'settings.remoteInstances.page.import.loading': 'Loading SSH hosts...',
|
||||
'settings.remoteInstances.page.import.noneFound': 'No SSH hosts found.',
|
||||
'settings.remoteInstances.page.import.noneAvailable': 'No SSH hosts available to import.',
|
||||
@@ -1034,12 +1065,12 @@ export const settingsDict = {
|
||||
'settings.remoteInstances.page.logsDialog.title': 'SSH Logs',
|
||||
'settings.remoteInstances.page.logsDialog.loading': 'Loading logs...',
|
||||
'settings.remoteInstances.page.logsDialog.empty': 'No SSH logs yet.',
|
||||
'settings.remoteInstances.page.patternDialog.title': 'Create from wildcard pattern',
|
||||
'settings.remoteInstances.page.patternDialog.title': 'Choose an SSH destination',
|
||||
'settings.remoteInstances.page.patternDialog.destinationPlaceholder': 'user@host',
|
||||
'settings.remoteInstances.page.phase.resolvingConfiguration': 'Resolving configuration',
|
||||
'settings.remoteInstances.page.phase.checkingAuth': 'Checking authentication',
|
||||
'settings.remoteInstances.page.phase.establishingSsh': 'Establishing SSH connection',
|
||||
'settings.remoteInstances.page.phase.probingRemote': 'Probing remote host',
|
||||
'settings.remoteInstances.page.phase.probingRemote': 'Checking remote machine',
|
||||
'settings.remoteInstances.page.phase.installingOpenChamber': 'Installing OpenChamber',
|
||||
'settings.remoteInstances.page.phase.updatingOpenChamber': 'Updating OpenChamber',
|
||||
'settings.remoteInstances.page.phase.detectingServer': 'Detecting server',
|
||||
@@ -1504,6 +1535,9 @@ export const settingsDict = {
|
||||
'settings.voice.page.preview.voiceLine': 'Hello! I\'m {voiceName}. This is how I sound.',
|
||||
'settings.voice.page.preview.customServerLine': 'Hello! This is a preview of the custom TTS server.',
|
||||
'settings.openchamber.visual.section.colorMode': 'Color Mode',
|
||||
'settings.openchamber.visual.section.mobileLayout': 'Mobile Layout',
|
||||
'settings.openchamber.visual.option.mobileLayout.default': 'Default',
|
||||
'settings.openchamber.visual.option.mobileLayout.new': 'New',
|
||||
'settings.openchamber.visual.section.localization': 'Localization',
|
||||
'settings.openchamber.visual.section.spacingAndLayout': 'Spacing & Layout',
|
||||
'settings.openchamber.visual.section.navigation': 'Navigation',
|
||||
|
||||
@@ -26,6 +26,94 @@ export const dict = {
|
||||
'layout.mainTab.files': 'Files',
|
||||
'layout.mainTab.terminal': 'Terminal',
|
||||
'layout.mainTab.context': 'Context',
|
||||
'mobile.nav.aria': 'Mobile navigation',
|
||||
'mobile.nav.changes': 'Changes',
|
||||
'mobile.nav.settings': 'Settings',
|
||||
'mobile.surface.closeAria': 'Close',
|
||||
'mobile.header.openMenuAria': 'Open menu',
|
||||
'mobile.menu.titleAria': 'Workspace tools',
|
||||
'mobile.menu.files': 'Files',
|
||||
'mobile.menu.changes': 'Changes',
|
||||
'mobile.menu.settings': 'Settings',
|
||||
'mobile.sessions.newChatCta': 'New chat in {project}',
|
||||
'mobile.sessions.dateGroup.today': 'Today',
|
||||
'mobile.sessions.dateGroup.yesterday': 'Yesterday',
|
||||
'mobile.sessions.dateGroup.thisWeek': 'Earlier this week',
|
||||
'mobile.sessions.dateGroup.older': 'Older',
|
||||
'mobile.sessions.section.worktrees': 'Worktrees',
|
||||
'mobile.sessions.section.otherProjects': 'Switch project',
|
||||
'mobile.sessions.section.projects': 'Projects',
|
||||
'mobile.sessions.empty.noProjectsTitle': 'No projects yet',
|
||||
'mobile.sessions.empty.noProjectsDescription': 'Add a project to start chatting with your code.',
|
||||
'mobile.sessions.empty.noSessionsTitle': 'No sessions yet',
|
||||
'mobile.sessions.empty.noSessionsDescription': 'Start your first chat to see it here.',
|
||||
'mobile.sessions.empty.searchTitle': 'No matches',
|
||||
'mobile.sessions.empty.searchDescription': 'Try a different search term.',
|
||||
'mobile.sessions.showArchived': 'Show archived ({count})',
|
||||
'mobile.sessions.hideArchived': 'Hide archived',
|
||||
'mobile.sessions.activeWorktreeAria': 'Active worktree',
|
||||
'mobile.sessions.activeProjectAria': 'Active project',
|
||||
'mobile.sessions.startNewChat': 'Start new chat',
|
||||
'mobile.sessions.newChat': 'New chat',
|
||||
'mobile.sessions.editOrder': 'Reorder projects',
|
||||
'mobile.sessions.doneEditing': 'Done',
|
||||
'mobile.sessions.editOrderHint': 'Drag the handle or use the arrows to reorder projects. Tap the check to finish.',
|
||||
'mobile.sessions.dragHandleAria': 'Drag {label} to reorder',
|
||||
'mobile.sessions.moveUpAria': 'Move {label} up',
|
||||
'mobile.sessions.moveDownAria': 'Move {label} down',
|
||||
'mobile.sessions.removeProjectAria': 'Remove {label}',
|
||||
'mobile.sessions.cancelRemoveProjectAria': 'Cancel removing {label}',
|
||||
'mobile.sessions.confirmRemoveProject': 'Delete',
|
||||
'mobile.sessions.confirmRemoveProjectAria': 'Confirm removing {label}',
|
||||
'mobile.sessions.toast.projectRemoved': 'Removed {label}',
|
||||
'mobile.sessions.showMore': 'Show {count} more',
|
||||
'mobile.sessions.search.section.sessions': 'Sessions',
|
||||
'mobile.sessions.search.section.archived': 'Archived',
|
||||
'mobile.sessions.search.section.projects': 'Projects',
|
||||
'mobile.sessions.clearSearchAria': 'Clear search',
|
||||
'mobile.header.noProject': 'Select a project',
|
||||
'mobile.header.activeSession': 'Active session',
|
||||
'mobile.header.noSession': 'No active session',
|
||||
'mobile.sessions.openSheetAria': 'Open sessions and projects',
|
||||
'mobile.sessions.closeSheetAria': 'Close sessions and projects',
|
||||
'mobile.sessions.sheet.title': 'Sessions',
|
||||
'mobile.sessions.sheet.description': 'Switch projects, open sessions, or start a new chat.',
|
||||
'mobile.sessions.search.placeholder': 'Search sessions',
|
||||
'mobile.sessions.empty': 'No sessions found.',
|
||||
'mobile.sessions.unassignedProject': 'Other sessions',
|
||||
'mobile.sessions.newSessionAria': 'Start a new session in this project',
|
||||
'mobile.sessions.untitled': 'Untitled session',
|
||||
'mobile.sessions.project.sessionsSingle': '1 session',
|
||||
'mobile.sessions.project.sessionsPlural': '{count} sessions',
|
||||
'mobile.files.refreshAria': 'Refresh files',
|
||||
'mobile.files.backToParentAria': 'Back to {name}',
|
||||
'mobile.files.rootDirectory': 'Project files',
|
||||
'mobile.files.search.placeholder': 'Search files',
|
||||
'mobile.files.search.empty': 'No files found.',
|
||||
'mobile.files.parentDirectory': 'Parent directory',
|
||||
'mobile.files.empty.noDirectory': 'Select a project to browse files.',
|
||||
'mobile.files.empty.directory': 'This directory is empty.',
|
||||
'mobile.files.error.listFailed': 'Failed to load files',
|
||||
'mobile.files.error.readUnavailable': 'File preview is unavailable in this runtime.',
|
||||
'mobile.files.file.truncated': 'File preview truncated for mobile.',
|
||||
'mobile.files.copyPathAria': 'Copy file path',
|
||||
'mobile.files.copyContent': 'Copy content',
|
||||
'mobile.files.copyContentAria': 'Copy file content',
|
||||
'mobile.files.toast.pathCopied': 'Path copied',
|
||||
'mobile.files.toast.contentCopied': 'Content copied',
|
||||
'mobile.files.toast.copyFailed': 'Copy failed',
|
||||
'mobile.changes.placeholder.title': 'Changes',
|
||||
'mobile.changes.placeholder.description': 'Working-tree review, sync, and commit actions will live here.',
|
||||
'mobile.changes.branchLabel': 'Branch: {branch}',
|
||||
'mobile.changes.noRemote': 'No remote available',
|
||||
'mobile.changes.cleanDescription': 'There are no changed files in this workspace.',
|
||||
'mobile.changes.diffDetail.subtitle': 'Read-only diff',
|
||||
'mobile.changes.diffDetail.loadFailed': 'Failed to load diff',
|
||||
'mobile.changes.diffDetail.missingTitle': 'File is no longer changed',
|
||||
'mobile.changes.diffDetail.missingDescription': 'Go back to Changes and refresh the list.',
|
||||
'mobile.changes.diffDetail.imageUnavailable': 'Image diffs are not available in mobile Changes yet.',
|
||||
'mobile.settings.placeholder.title': 'Settings',
|
||||
'mobile.settings.placeholder.description': 'Focused mobile connection and app settings will live here.',
|
||||
'layout.rightSidebar.git': 'Git',
|
||||
'layout.rightSidebar.files': 'Files',
|
||||
'layout.rightSidebar.context': 'Context',
|
||||
@@ -1159,6 +1247,12 @@ export const dict = {
|
||||
'header.services.refreshRateLimitsAria': 'Refresh rate limits',
|
||||
'header.services.noRateLimits': 'No rate limits available.',
|
||||
'header.services.noRateLimitsReported': 'No rate limits reported.',
|
||||
'header.services.remoteUpdate.title': 'Remote instance update',
|
||||
'header.services.remoteUpdate.checking': 'Looking for updates...',
|
||||
'header.services.remoteUpdate.upToDate': 'This instance is up to date.',
|
||||
'header.services.remoteUpdate.available': 'Version {version} is available for this instance.',
|
||||
'header.services.remoteUpdate.error': 'Failed to check remote instance updates',
|
||||
'header.services.remoteUpdate.actions.open': 'Update',
|
||||
'header.services.used': 'Used',
|
||||
'header.services.remaining': 'Remaining',
|
||||
'header.services.modelFamily.other': 'Other',
|
||||
@@ -2069,6 +2163,9 @@ export const dict = {
|
||||
'desktopHostSwitcher.header.currentDefaultColon': 'Current default:',
|
||||
'desktopHostSwitcher.status.connected': 'Connected',
|
||||
'desktopHostSwitcher.status.authRequired': 'Auth required',
|
||||
'desktopHostSwitcher.status.checking': 'Checking',
|
||||
'desktopHostSwitcher.status.updateRecommended': 'Update recommended',
|
||||
'desktopHostSwitcher.status.incompatible': 'Incompatible',
|
||||
'desktopHostSwitcher.status.wrongService': 'Wrong service',
|
||||
'desktopHostSwitcher.status.unreachable': 'Unreachable',
|
||||
'desktopHostSwitcher.status.unknown': 'Unknown',
|
||||
@@ -2255,6 +2352,8 @@ export const dict = {
|
||||
'onboarding.remoteConnection.actions.chooseDifferentServer': 'Choose Different Server',
|
||||
'onboarding.remoteConnection.actions.useLocalInstead': 'Use Local Instead',
|
||||
'onboarding.remoteConnection.probe.authMessage': 'Server requires authentication. You can still connect, but may need to provide credentials.',
|
||||
'onboarding.remoteConnection.probe.updateRecommendedMessage': 'This instance is running a different OpenChamber version. You can connect, but update both apps if something does not work.',
|
||||
'onboarding.remoteConnection.probe.incompatibleMessage': 'Server is running OpenChamber but is not compatible with this app version. Update OpenChamber on the server, then try again.',
|
||||
'onboarding.remoteConnection.probe.wrongServiceMessage': 'Server responded but is not running OpenChamber. Verify the address points to an OpenChamber server.',
|
||||
'onboarding.remoteConnection.probe.unreachableMessage': 'Server is unreachable. Check your network connection and verify the server address.',
|
||||
'onboarding.desktopRecovery.localUnavailable.title': 'Local OpenCode Unavailable',
|
||||
@@ -2268,6 +2367,8 @@ export const dict = {
|
||||
'onboarding.desktopRecovery.remoteUnreachable.retry': 'Retry Connection',
|
||||
'onboarding.desktopRecovery.incompatibleServer.title': 'Incompatible Server',
|
||||
'onboarding.desktopRecovery.incompatibleServer.description': 'The server at "{host}" is not running OpenChamber. Verify the address points to an OpenChamber server.',
|
||||
'onboarding.desktopRecovery.remoteIncompatible.title': 'Server Update Required',
|
||||
'onboarding.desktopRecovery.remoteIncompatible.description': 'The OpenChamber server at "{host}" is not compatible with this app version. Update OpenChamber on the server, then try again.',
|
||||
'onboarding.desktopRecovery.common.useLocal': 'Use Local',
|
||||
'onboarding.desktopRecovery.common.useRemote': 'Use Remote',
|
||||
'onboarding.desktopRecovery.actions.retrying': 'Retrying…',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user