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

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

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

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

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

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

Also refresh agent guidance and project skills so future runtime/API, auth,
preview, UI, CLI, settings, locale, and drag-to-reorder work follows the new
architecture.
This commit is contained in:
Bohdan Triapitsyn
2026-06-02 00:43:05 +03:00
committed by GitHub
parent a4314c189b
commit 2031e3b4a8
282 changed files with 16524 additions and 4259 deletions
+468
View File
@@ -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>
);
}