From 07a6b8a21edefeafc74fd07a9470c6b4d624c990 Mon Sep 17 00:00:00 2001 From: rijulshrestha Date: Wed, 19 Aug 2026 15:38:41 +0100 Subject: [PATCH 01/12] feat: run cloned Next.js repos on webpack instead of Turbopack --- src/lib/ide/native-deps.ts | 36 ++++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/src/lib/ide/native-deps.ts b/src/lib/ide/native-deps.ts index db42cca..3c74f89 100644 --- a/src/lib/ide/native-deps.ts +++ b/src/lib/ide/native-deps.ts @@ -11,6 +11,9 @@ type Manifest = { /** What the repo declares, package name to version spec, dependencies and devDependencies. */ type DeclaredDeps = Map; +/** Reads the repo's own value for an entry, never an earlier patch's, so rule order cannot matter. */ +type CurrentValue = (section: string, name: string) => string | undefined; + type SectionPatch = { /** Top-level manifest key holding a name to value map. */ section: string; @@ -21,7 +24,7 @@ type SectionPatch = { type FrameworkRule = { applies: (deps: DeclaredDeps) => boolean; - patches: SectionPatch[]; + patches: (deps: DeclaredDeps, current: CurrentValue) => SectionPatch[]; }; /** Ours wins: for where the repo's own value is the thing that breaks the pod. */ @@ -51,15 +54,36 @@ const FRAMEWORK_RULES: FrameworkRule[] = [ // Vite 8+ { applies: (deps) => majorAtLeast(deps, 'vite', 8), - patches: [fill('devDependencies', { '@rolldown/binding-wasm32-wasi': '1.2.2' })] + patches: () => [fill('devDependencies', { '@rolldown/binding-wasm32-wasi': '1.2.2' })] }, // Vite 7 and earlier { applies: (deps) => majorBelow(deps, 'vite', 8), - patches: [force('overrides', WASM_BUNDLERS)] + patches: () => [force('overrides', WASM_BUNDLERS)] + }, + // Next.js + { + applies: (deps) => deps.has('next'), + patches: (deps, current) => { + const dev = nextDevWithWebpack(current('scripts', 'dev'), deps); + return dev ? [force('scripts', { dev })] : []; + } } ]; +/** + * Turbopack needs native bindings the pod cannot execute. Dropping its flags is enough through Next + * 15; 16 defaults to it and needs `--webpack` to opt out. + */ +function nextDevWithWebpack(script: string | undefined, deps: DeclaredDeps): string | undefined { + if (!script) return undefined; + const withoutTurbopack = script.replace(/ --turbo(?:pack)?\b/g, ''); + if (!majorAtLeast(deps, 'next', 16) || withoutTurbopack.includes('--webpack')) { + return withoutTurbopack; + } + return withoutTurbopack.replace(/\bnext\s+dev\b/, '$& --webpack'); +} + /** Highest major the spec could install. Null means no ceiling at all. */ function highestMajor(spec: string): number | null { if (spec.includes('>') && !spec.includes('<')) return null; @@ -116,6 +140,10 @@ export function patchClonedManifest( const deps: DeclaredDeps = new Map( Object.entries({ ...manifest.dependencies, ...manifest.devDependencies }) ); + const currentValue: CurrentValue = (section, name) => { + const held = manifest[section]; + return isRecord(held) ? held[name] : undefined; + }; // Working copies, seeded from the manifest the first time a rule touches the section. A section // holding anything other than a map is treated as absent; npm would reject it anyway. const sections = new Map>(); @@ -132,7 +160,7 @@ export function patchClonedManifest( const notes: string[] = []; for (const rule of FRAMEWORK_RULES) { if (!rule.applies(deps)) continue; - for (const { section: sectionName, mode, entries } of rule.patches) { + for (const { section: sectionName, mode, entries } of rule.patches(deps, currentValue)) { const section = workingCopy(sectionName); for (const [name, value] of Object.entries(entries)) { if (mode === 'fill' && alreadyPresent(section, sectionName, name, deps)) continue; From 373f0d8d74cd8d6a58160e4a9b2a4156303e9e0f Mon Sep 17 00:00:00 2001 From: rijulshrestha Date: Mon, 24 Aug 2026 09:11:30 +0100 Subject: [PATCH 02/12] fix: apply the rolldown wasm binding only from Vite 8.2 --- src/lib/ide/native-deps.ts | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/src/lib/ide/native-deps.ts b/src/lib/ide/native-deps.ts index 3c74f89..5b838f2 100644 --- a/src/lib/ide/native-deps.ts +++ b/src/lib/ide/native-deps.ts @@ -45,16 +45,17 @@ const DEP_SECTIONS = new Set([ 'peerDependencies' ]); +/** Native binaries the pod cannot execute. */ const WASM_BUNDLERS = { - esbuild: 'npm:esbuild-wasm@0.25.11', - rollup: 'npm:@rollup/wasm-node@4.52.4' + esbuild: 'npm:esbuild-wasm@*', + rollup: 'npm:@rollup/wasm-node@*' }; const FRAMEWORK_RULES: FrameworkRule[] = [ - // Vite 8+ + // Vite 8.2+ { - applies: (deps) => majorAtLeast(deps, 'vite', 8), - patches: () => [fill('devDependencies', { '@rolldown/binding-wasm32-wasi': '1.2.2' })] + applies: (deps) => minorAtLeast(deps, 'vite', 8, 2), + patches: () => [fill('devDependencies', { '@rolldown/binding-wasm32-wasi': '1.2.5' })] }, // Vite 7 and earlier { @@ -92,6 +93,27 @@ function highestMajor(spec: string): number | null { return Math.max(...versions.map((version) => Number.parseInt(version, 10))); } +/** Highest minor the spec could install within `major`, Infinity when it leaves the minor free. */ +function highestMinor(spec: string, major: number): number { + // A caret or a bare major floats the minor: `^8.1.1` installs 8.2 today, so it reads as 8.x. + if (spec.includes('^')) return Infinity; + const minors = (spec.match(/\d+(?:\.\d+)*/g) ?? []) + .map((version) => version.split('.').map(Number)) + .filter(([declared]) => declared === major) + .map(([, minor]) => minor ?? Infinity); + return minors.length > 0 ? Math.max(...minors) : Infinity; +} + +/** True when `name` is declared and can install `major.minor` or newer. */ +function minorAtLeast(deps: DeclaredDeps, name: string, major: number, minor: number): boolean { + const spec = deps.get(name); + if (spec === undefined) return false; + const highest = highestMajor(spec); + if (highest === null) return true; + if (highest !== major) return highest > major; + return highestMinor(spec, major) >= minor; +} + /** True when `name` is declared and can install `major` or newer, an unversioned spec included. */ function majorAtLeast(deps: DeclaredDeps, name: string, major: number): boolean { const spec = deps.get(name); From ed4871cf8f3736c8ba48429cd2a1873f2cfaeee3 Mon Sep 17 00:00:00 2001 From: rijulshrestha Date: Mon, 24 Aug 2026 10:52:06 +0100 Subject: [PATCH 03/12] feat: render image files in an image viewer instead of raw bytes --- src/lib/components/ide/EditorPane.svelte | 13 +- src/lib/components/ide/ImageViewer.svelte | 169 ++++++++++++++++++++++ src/lib/ide/media.ts | 57 ++++++++ src/lib/ide/session.svelte.ts | 37 ++++- 4 files changed, 266 insertions(+), 10 deletions(-) create mode 100644 src/lib/components/ide/ImageViewer.svelte create mode 100644 src/lib/ide/media.ts diff --git a/src/lib/components/ide/EditorPane.svelte b/src/lib/components/ide/EditorPane.svelte index 1119ab1..03b7ca0 100644 --- a/src/lib/components/ide/EditorPane.svelte +++ b/src/lib/components/ide/EditorPane.svelte @@ -3,6 +3,7 @@ import { SvelteMap } from 'svelte/reactivity'; import Icon from '@iconify/svelte'; import { fileIcon } from '$lib/ide/file-icons'; + import ImageViewer from '$lib/components/ide/ImageViewer.svelte'; import type * as Monaco from 'monaco-editor'; import type { IdeSession } from '$lib/ide/session.svelte'; @@ -18,6 +19,8 @@ const viewStates = new SvelteMap(); let renderedPath = ''; + let activeFile = $derived(session.openFiles.find((file) => file.path === session.selectedFile)); + // Responsive font const FONT_QUERY = '(min-width: 640px)'; const fontSizeFor = (desktop: boolean) => (desktop ? 12.8 : 11.5); @@ -82,11 +85,12 @@ // Show the active tab: park the outgoing view state, attach the incoming // model, restore its cursor/scroll. $effect(() => { - const entry = session.openFiles.find((file) => file.path === session.selectedFile); + const entry = activeFile; if (!editor || !monacoMod) return; // Track the reveal request so a jump to the already-open file still re-runs this effect. void session.revealRequest; - if (!entry) { + // Detaching on an image tab stops the previous file's text showing through under it. + if (!entry || entry.image) { if (renderedPath) viewStates.set(renderedPath, editor.saveViewState()); editor.setModel(null); renderedPath = ''; @@ -198,6 +202,11 @@
+ {#if activeFile?.image} +
+ +
+ {/if} {#if session.openFiles.length === 0 && !session.loading && editor}
No file open diff --git a/src/lib/components/ide/ImageViewer.svelte b/src/lib/components/ide/ImageViewer.svelte new file mode 100644 index 0000000..cce0d9a --- /dev/null +++ b/src/lib/components/ide/ImageViewer.svelte @@ -0,0 +1,169 @@ + + +
+
+ {#if failed} +
+ + This image could not be displayed +
+ {:else} + +
+ {path} (failed = true)} + class:pixelated={scale > 1} + class={natural ? 'm-auto' : 'm-auto max-h-full max-w-full object-contain'} + style={natural + ? `width: ${Math.round(natural.width * scale)}px; height: ${Math.round(natural.height * scale)}px;` + : ''} + /> +
+ {/if} +
+
+ {#if natural} + {natural.width} × {natural.height} + {/if} + {formatBytes(image.bytes)} + {#if natural && !failed} +
+ + + +
+ {/if} +
+
+ + diff --git a/src/lib/ide/media.ts b/src/lib/ide/media.ts new file mode 100644 index 0000000..d950591 --- /dev/null +++ b/src/lib/ide/media.ts @@ -0,0 +1,57 @@ +/** + * Image tabs: pod files have no URL an `` can reach, so the bytes are read once and + * republished as an object URL, owned by the tab that holds it. + */ +import type { BrowserPod } from '@leaningtech/browserpod'; +import { readPodBinaryFile } from '$lib/pod/fs'; + +/** `url` stays valid until {@link releaseImage}. */ +export type ImagePayload = { url: string; bytes: number }; + +/** Extensions a browser renders in an ``. SVG is absent so it stays editable as text; */ +const IMAGE_MIME: Record = { + png: 'image/png', + apng: 'image/apng', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + jpe: 'image/jpeg', + jif: 'image/jpeg', + jfif: 'image/jpeg', + gif: 'image/gif', + webp: 'image/webp', + avif: 'image/avif', + bmp: 'image/bmp', + ico: 'image/x-icon' +}; + +const extensionOf = (path: string): string => path.slice(path.lastIndexOf('.') + 1).toLowerCase(); + +/** True when `path` opens as an image tab rather than in the text editor. */ +export function isImagePath(path: string): boolean { + return extensionOf(path) in IMAGE_MIME; +} + +/** Reads an image out of the pod and publishes it as an object URL. */ +export async function loadPodImage(pod: BrowserPod, absPath: string): Promise { + const bytes = await readPodBinaryFile(pod, absPath); + const blob = new Blob([bytes], { type: IMAGE_MIME[extensionOf(absPath)] }); + return { url: URL.createObjectURL(blob), bytes: bytes.byteLength }; +} + +/** Releases the object URL; a leaked one pins the whole image in memory. */ +export function releaseImage(image: ImagePayload | undefined): void { + if (image) URL.revokeObjectURL(image.url); +} + +const BYTE_UNITS = ['B', 'KB', 'MB']; + +/** Byte count for the viewer's status line, e.g. `24.1 KB`. */ +export function formatBytes(bytes: number): string { + let value = bytes; + let unit = 0; + while (value >= 1024 && unit < BYTE_UNITS.length - 1) { + value /= 1024; + unit++; + } + return `${unit === 0 ? value : value.toFixed(1)} ${BYTE_UNITS[unit]}`; +} diff --git a/src/lib/ide/session.svelte.ts b/src/lib/ide/session.svelte.ts index b0d218f..72e4751 100644 --- a/src/lib/ide/session.svelte.ts +++ b/src/lib/ide/session.svelte.ts @@ -15,6 +15,7 @@ import { } from '$lib/pod/fs'; import { ANSI, BP_RC, BP_RC_PATH } from './shell-rc'; import { patchClonedManifest } from './native-deps'; +import { isImagePath, loadPodImage, releaseImage, type ImagePayload } from './media'; import { fetchRepoTree } from '$lib/github/api'; import { trackEvent } from '$lib/utils/useLazyTracking'; import type { PortalUpdate } from '$lib/pod/portals'; @@ -29,7 +30,14 @@ const COLOR_ENV = ['FORCE_COLOR=3', 'COLORTERM=truecolor']; * A file open as an editor tab. A `preview` tab (opened by single-click) * is reused by the next preview open; double-clicking or editing pins it. */ -export type OpenFile = { path: string; content: string; savedContent: string; preview: boolean }; +export type OpenFile = { + path: string; + content: string; + savedContent: string; + preview: boolean; + /** Set on image tabs, which render as a picture and never save. */ + image?: ImagePayload; +}; /** Where the boot pipeline currently is; drives the loader's progress readout. `copying` is * framework-only, `cloning` GitHub-only. */ @@ -461,6 +469,7 @@ export class IdeSession { const gone = (p: string) => p === path || p.startsWith(`${path}/`); this.projectFiles = this.projectFiles.filter((p) => !gone(p)); this.projectDirs = this.projectDirs.filter((p) => !gone(p)); + for (const file of this.openFiles) if (gone(file.path)) releaseImage(file.image); this.openFiles = this.openFiles.filter((file) => !gone(file.path)); if (gone(this.selectedFile)) this.selectedFile = this.openFiles.at(-1)?.path ?? ''; return null; @@ -486,20 +495,28 @@ export class IdeSession { this.loading = true; this.selectedFile = path; try { - const content = await readPodFile(this.pod, `${this.workdir}/${path}`); - if (this.unmounted || this.openFiles.some((file) => file.path === path)) return; + const absPath = `${this.workdir}/${path}`; + const image = isImagePath(path) ? await loadPodImage(this.pod, absPath) : undefined; + const content = image ? '' : await readPodFile(this.pod, absPath); + if (this.unmounted || this.openFiles.some((file) => file.path === path)) { + releaseImage(image); + return; + } // A pin that arrived while the read was in flight wins over the preview flag. const entry: OpenFile = { path, content, savedContent: content, - preview: preview && !this.pendingPins.delete(path) + preview: preview && !this.pendingPins.delete(path), + image }; const previewIndex = entry.preview ? this.openFiles.findIndex((file) => file.preview) : -1; - this.openFiles = - previewIndex >= 0 - ? this.openFiles.map((file, i) => (i === previewIndex ? entry : file)) - : [...this.openFiles, entry]; + if (previewIndex >= 0) { + releaseImage(this.openFiles[previewIndex].image); + this.openFiles = this.openFiles.map((file, i) => (i === previewIndex ? entry : file)); + } else { + this.openFiles = [...this.openFiles, entry]; + } } catch (error) { console.error('Failed to load file:', error); this.pendingPins.delete(path); @@ -531,6 +548,7 @@ export class IdeSession { if (index < 0) return; const entry = this.openFiles[index]; if (entry.content !== entry.savedContent) void this.saveEntry(entry); + releaseImage(entry.image); this.openFiles = this.openFiles.filter((file) => file.path !== path); if (this.selectedFile === path) this.selectedFile = (this.openFiles[index] ?? this.openFiles[index - 1])?.path ?? ''; @@ -554,6 +572,8 @@ export class IdeSession { } private async saveEntry(entry: OpenFile): Promise { + // An image tab carries no text, so writing its content back would truncate the file. + if (entry.image) return; // Saving only makes sense once the dev server is reachable; earlier writes // would race the template hydration. if (!this.pod || !this.hasPortal || this.unmounted) return; @@ -594,6 +614,7 @@ export class IdeSession { /** Tears down the pod and cancels any in-flight boot. */ shutdown(): void { this.unmounted = true; + for (const file of this.openFiles) releaseImage(file.image); this.bootToken += 1; if (this.pod) void shutdownPod(this.pod); } From ba25384f3560f95315bc54490e1919a81b3ff438 Mon Sep 17 00:00:00 2001 From: rijulshrestha Date: Mon, 24 Aug 2026 13:05:04 +0100 Subject: [PATCH 04/12] feat: revamp the preview toolbar with hide and reload controls --- src/lib/components/Portal.svelte | 370 +++++++++++++++++++------ src/lib/components/ide/IdeShell.svelte | 157 +++++++---- src/lib/stores/portals.svelte.ts | 37 ++- src/routes/agents/[tool]/+page.svelte | 8 +- 4 files changed, 425 insertions(+), 147 deletions(-) diff --git a/src/lib/components/Portal.svelte b/src/lib/components/Portal.svelte index b40a86a..32a1caf 100644 --- a/src/lib/components/Portal.svelte +++ b/src/lib/components/Portal.svelte @@ -1,4 +1,5 @@ {#if portals.length > 0}
- +
-
- - Preview -
+ {#if onCollapse} + + + {/if} {#if src} -
- {#if portals.length > 1} -
- -
+
+ + {#if hasChoice} + + {/if} + + + {#if showPorts} +
+ {#each portals as item (item.port)} + + {/each}
{/if} +
+ + + + + +
{#if showMenu} -
- - -
{/if}
+ {#if showInfo} +
+
+ +
+ {#if qrError} +

{qrError}

+ {:else} +

Scan to open this preview on your phone.

+

{src}

+ {/if} +
+ {/if} + {/if} + + {#if sweeping} + {#key sweepId} +
+ {/key} {/if}
@@ -153,42 +267,140 @@ {src} id="portal" title="Portal content" + bind:this={frameEl} onload={onFrameLoad} class="h-full min-h-0 w-full border-none {frameStatus === 'ready' ? 'bg-white' : 'bg-bc-navy'}" > {/if} - - {#if showInfo} -
- - -
- -
- - {#if qrError} -
{qrError}
- {:else} - -
- {src} -
- {/if} -
- {/if}
{/if}
{/if} + + diff --git a/src/lib/components/ide/IdeShell.svelte b/src/lib/components/ide/IdeShell.svelte index 60fb0cd..6390048 100644 --- a/src/lib/components/ide/IdeShell.svelte +++ b/src/lib/components/ide/IdeShell.svelte @@ -76,10 +76,24 @@ let activePanel = $state<'files' | 'search' | null>('files'); let fileTree = $state<{ startCreate: (kind: 'file' | 'folder') => void } | null>(null); + // ── Mobile state ────────────────────────────────────────────────────────── + let isMobile = $state(false); + let activeMobileView = $state<'editor' | 'terminal' | 'preview'>('editor'); + // Frameworks with a declared app port keep the preview pinned to it; other - // ports stay reachable through the port selector. + // ports stay reachable through the toolbar's port menu. const portal = new PortalState({ preferredPort: () => session.appPort }); + let isPreviewVisible = $state(true); + let previewCollapsed = $derived(!isPreviewVisible && !isMobile); + + /** Collapses to the stub without unmounting: a remount would lose the previewed app's route. */ + function togglePreview(): void { + isPreviewVisible = !isPreviewVisible; + // xterm only refits on a resize event. + setTimeout(() => fitTerminals(), 0); + } + // Recomputed as the preview moves ports, so a report always carries the live portal URL. let bugReportHref = $derived(bugReportUrl({ repo: session.repo, previewUrl: portal.url })); @@ -93,10 +107,6 @@ let bootLines = $derived(BOOT_LOG[session.mode]); let activeLine = $derived(STAGE_LINE[session.bootStage]); - // ── Mobile state ────────────────────────────────────────────────────────── - let isMobile = $state(false); - let activeMobileView = $state<'editor' | 'terminal' | 'preview'>('editor'); - // ── Resize state ────────────────────────────────────────────────────────── let filePanelWidth = $state(208); let leftColFraction = $state(0.6); @@ -369,14 +379,18 @@
@@ -396,7 +410,7 @@ {/if}
- {#if !isMobile} + {#if !isMobile && isPreviewVisible} + {/if} + +
+ {#if !isCompatibleBrowser}
- +
+ +
+

Incompatible Browser

+

+ Requires Atomics.waitAsync (Chrome, Edge, Safari + 16.4+). +

-

Incompatible Browser

-

- Requires Atomics.waitAsync (Chrome, Edge, Safari - 16.4+). -

-
- {:else} - {#if portal.portals.length > 0} - - {/if} - - {#if loaderVisible} -
- (loaderVisible = false)} + {:else} + {#if portal.portals.length > 0} + session.saveAll()} + onCollapse={isMobile ? undefined : togglePreview} /> -
+ {/if} + + {#if loaderVisible} +
+ (loaderVisible = false)} + /> +
+ {/if} {/if} - {/if} +
@@ -570,8 +611,8 @@ /* ── Mobile ────────────────────────────────────────────────────────────── */ /* Keep hidden panes mounted (terminals/iframes need persistent DOM) but - take them out of layout so the active pane fills the viewport. */ - .mobile-hidden { + take them out of layout so the visible panes fill the space. */ + .pane-hidden { display: none !important; } diff --git a/src/lib/stores/portals.svelte.ts b/src/lib/stores/portals.svelte.ts index 98a1d33..96318c0 100644 --- a/src/lib/stores/portals.svelte.ts +++ b/src/lib/stores/portals.svelte.ts @@ -35,6 +35,7 @@ export class PortalState { url = $state(''); frameStatus = $state('waiting'); showMenu = $state(false); + showPorts = $state(false); showInfo = $state(false); copied = $state(false); qrError = $state(''); @@ -76,11 +77,8 @@ export class PortalState { if (next.length === 0) this.options.onEmpty?.(); }; - /** Change handler for Portal.svelte's port (apiKey = event.currentTarget.value)} + value={secret} + oninput={(event) => (secret = event.currentTarget.value)} class="w-full rounded-lg border border-bc-mist/18 bg-bc-navy/55 py-2.5 pr-11 pl-3 font-mono text-[13px] tracking-tight text-zinc-50 outline-none focus:border-bc-azure/65" />
@@ -99,7 +106,7 @@ {/if} diff --git a/src/routes/agents/[tool]/+page.svelte b/src/routes/agents/[tool]/+page.svelte index 6bf4475..8bd7e2d 100644 --- a/src/routes/agents/[tool]/+page.svelte +++ b/src/routes/agents/[tool]/+page.svelte @@ -6,13 +6,13 @@ import { onMount } from 'svelte'; import { page } from '$app/stores'; - import { bootCLI, describeError, type CLIBootHooks } from '$lib/agents/boot'; - import { getCodexApiKey, setCodexApiKey } from '$lib/agents/codex'; - import CodexErrorCard from '$lib/components/agents/CodexErrorCard.svelte'; - import CodexLoadingCard from '$lib/components/agents/CodexLoadingCard.svelte'; - import CodexSignInCard from '$lib/components/agents/CodexSignInCard.svelte'; + import { bootCLI } from '$lib/agents/boot'; + import { CredentialGate } from '$lib/agents/credential-gate.svelte'; + import AgentErrorCard from '$lib/components/agents/AgentErrorCard.svelte'; + import AgentLoadingCard from '$lib/components/agents/AgentLoadingCard.svelte'; + import CredentialCard from '$lib/components/agents/CredentialCard.svelte'; import { openTour } from '$lib/stores/stepper.svelte'; - import { toolItems } from '$lib/config/tools'; + import { cliConfigs, toolItems } from '$lib/config/tools'; import { requestSingleTabLock } from '$lib/utils/tabLock'; import { watchIsMobile } from '$lib/utils/viewport'; import { @@ -112,43 +112,17 @@ // Tool switching is a full page load, so the active tool is fixed for this page's lifetime const activeTool = getActiveTool(); - - let codexStage = $state<'idle' | 'loading' | 'signin' | 'error'>('idle'); - let codexError = $state(''); - let codexHasKey = $state(true); - let codexChangeKeyOpen = $state(false); - let resolveSignIn: ((key: string) => void) | null = null; - - // The loading card covers the terminal, so a boot that dies behind it would otherwise just - // spin forever. Swap it for the failure instead. - function reportCodexBootFailure(error: unknown) { - codexError = describeError(error); - codexStage = 'error'; - } + // getActiveTool() only ever returns an id that is in toolItems, so this always resolves. + const toolItem = toolItems.find((item) => item.id === activeTool)!; + // Mirrors bootCLI's own resolution, so the gate matches the config that actually launches. + const credential = (cliConfigs[activeTool] ?? cliConfigs.claude).credential; function retryBoot() { markIntentionalNavigation(); window.location.reload(); } - // OPENAI_API_KEY is fixed at process launch, so boot blocks here rather than overlaying a CLI - // already running without a key. - const codexBootHooks: CLIBootHooks = { - beforeLaunch: async () => { - if (!getCodexApiKey()) { - codexStage = 'signin'; - setCodexApiKey(await new Promise((resolve) => (resolveSignIn = resolve))); - codexHasKey = true; - } - codexStage = 'idle'; - } - }; - - // A changed key only applies to a fresh launch, so saving restarts the whole session. - function saveKeyAndRestart(key: string) { - setCodexApiKey(key); - retryBoot(); - } + const gate = credential ? new CredentialGate(credential, { onRestart: retryBoot }) : null; function toggleToolMenu() { showToolMenu = !showToolMenu; @@ -192,19 +166,17 @@ return; } - // Covers pod boot, the image streaming in, and the warm-up probe. - if (tool === 'codex') { - codexHasKey = getCodexApiKey() !== null; - codexStage = 'loading'; - } - - // bootCLI already logs and writes the failure into the terminal; Codex additionally needs - // its overlay taken down, since it hides that terminal. - bootCLI(tool, consoleEl, portal.apply, tool === 'codex' ? codexBootHooks : undefined).catch( - (error) => { - if (tool === 'codex') reportCodexBootFailure(error); - } - ); + // Covers pod boot, the image streaming in, and any warm-up probe. + gate?.begin(); + + // bootCLI already logs and writes the failure into the terminal; a gated boot additionally + // needs its overlay taken down, since it hides that terminal. + bootCLI( + tool, + consoleEl, + portal.apply, + gate ? { beforeLaunch: gate.beforeLaunch } : undefined + ).catch((error) => gate?.reportBootFailure(error)); }); return () => { @@ -277,12 +249,12 @@ /> {/if} - - {#if activeTool === 'codex'} + + {#if gate && credential} {/if} - {#if codexStage !== 'idle' || codexChangeKeyOpen} + {#if gate?.overlayVisible && credential}
- {#if codexStage === 'loading'} - navigateWithLeaveGuard('/agents', false)} /> - {:else if codexStage === 'error'} - navigateWithLeaveGuard('/agents', false)} /> - {:else if codexStage === 'signin'} - resolveSignIn?.(key)} + onSubmit={gate.submit} onCancel={() => navigateWithLeaveGuard('/agents', false)} /> {:else} - (codexChangeKeyOpen = false)} + onSubmit={gate.saveAndRestart} + onCancel={gate.closeChange} /> {/if}
From b38785091903b7054603a0b5a5c39fd97e5c87b0 Mon Sep 17 00:00:00 2001 From: rijulshrestha Date: Tue, 1 Sep 2026 13:58:33 +0100 Subject: [PATCH 08/12] refactor: share pane-drag logic --- src/lib/components/ide/IdeShell.svelte | 88 ++++++++++---------------- src/lib/utils/drag.ts | 32 ++++++++++ src/routes/agents/[tool]/+page.svelte | 33 ++++------ src/routes/layout.css | 10 +++ 4 files changed, 86 insertions(+), 77 deletions(-) create mode 100644 src/lib/utils/drag.ts diff --git a/src/lib/components/ide/IdeShell.svelte b/src/lib/components/ide/IdeShell.svelte index d38a18e..fcc98c4 100644 --- a/src/lib/components/ide/IdeShell.svelte +++ b/src/lib/components/ide/IdeShell.svelte @@ -14,6 +14,7 @@ import { PortalState } from '$lib/stores/portals.svelte'; import type { PortalUpdate } from '$lib/pod/portals'; import { installLeaveGuard } from '$lib/stores/leaveWarning.svelte'; + import { startDrag } from '$lib/utils/drag'; import { watchIsMobile } from '$lib/utils/viewport'; import { bugReportUrl } from '$lib/utils/bug-report'; import { trackEvent } from '$lib/utils/useLazyTracking'; @@ -108,6 +109,10 @@ let bootLines = $derived(BOOT_LOG[session.mode]); let activeLine = $derived(STAGE_LINE[session.bootStage]); + /** Editor floor, so dragging the terminal up leaves a usable strip of it. */ + const MIN_EDITOR_FRACTION = 0.1; + const MAX_EDITOR_FRACTION = 0.85; + // ── Resize state ────────────────────────────────────────────────────────── let filePanelWidth = $state(208); let leftColFraction = $state(0.6); @@ -122,14 +127,8 @@ window.dispatchEvent(new Event('resize')); } - function startDrag(which: 'file' | 'col' | 'row', e: MouseEvent) { - e.preventDefault(); + function startPaneDrag(which: 'file' | 'col' | 'row', event: MouseEvent) { dragging = which; - document.body.classList.add('dragging'); - document.body.style.cursor = which === 'row' ? 'row-resize' : 'col-resize'; - - const startX = e.clientX; - const startY = e.clientY; const startFileW = filePanelWidth; const startEditorFrac = editorFraction; const startLeftW = leftColEl?.clientWidth ?? 0; @@ -137,43 +136,30 @@ // 40px = icon rail width const startTotalW = bodyEl ? bodyEl.clientWidth - 40 - (activePanel ? filePanelWidth : 0) : 1; - function onMove(ev: MouseEvent) { - const dx = ev.clientX - startX; - const dy = ev.clientY - startY; - - if (which === 'file') { - const requested = startFileW + dx; - if (requested < 100) { - // Dragged shut — collapse the panel instead of pinning to min width - activePanel = null; - onUp(); - return; + startDrag(event, { + cursor: which === 'row' ? 'row-resize' : 'col-resize', + move: (dx, dy, stop) => { + if (which === 'file') { + const requested = startFileW + dx; + if (requested < 100) { + // Dragged shut — collapse the panel instead of pinning to min width + activePanel = null; + stop(); + return; + } + filePanelWidth = Math.max(140, Math.min(480, requested)); + } else if (which === 'col') { + leftColFraction = Math.max(0.25, Math.min(0.8, (startLeftW + dx) / startTotalW)); + } else if (which === 'row') { + editorFraction = Math.max( + MIN_EDITOR_FRACTION, + Math.min(MAX_EDITOR_FRACTION, (startLeftH * startEditorFrac + dy) / startLeftH) + ); } - filePanelWidth = Math.max(140, Math.min(480, requested)); - } else if (which === 'col') { - leftColFraction = Math.max(0.25, Math.min(0.8, (startLeftW + dx) / startTotalW)); - } else if (which === 'row') { - // Cap the terminal at 600px by bumping the editor's minimum fraction - const maxTerminalPx = 600; - const minEditorFrac = startLeftH > 0 ? Math.max(0.2, 1 - maxTerminalPx / startLeftH) : 0.2; - editorFraction = Math.max( - minEditorFrac, - Math.min(0.85, (startLeftH * startEditorFrac + dy) / startLeftH) - ); - } - fitTerminals(); - } - - function onUp() { - dragging = null; - document.body.classList.remove('dragging'); - document.body.style.cursor = ''; - window.removeEventListener('mousemove', onMove); - window.removeEventListener('mouseup', onUp); - } - - window.addEventListener('mousemove', onMove); - window.addEventListener('mouseup', onUp); + fitTerminals(); + }, + end: () => (dragging = null) + }); } // ── Mobile detection ────────────────────────────────────────────────────── @@ -373,7 +359,7 @@ type="button" class="divider divider-col" class:active={dragging === 'file'} - onmousedown={(e) => startDrag('file', e)} + onmousedown={(e) => startPaneDrag('file', e)} aria-label="Resize side panel" >
@@ -408,7 +394,7 @@ type="button" class="divider divider-row" class:active={dragging === 'row'} - onmousedown={(e) => startDrag('row', e)} + onmousedown={(e) => startPaneDrag('row', e)} aria-label="Resize terminal panel" >
@@ -419,7 +405,7 @@ class:pane-hidden={isMobile && activeMobileView !== 'terminal'} style={isMobile ? 'flex: 1 1 0; min-height: 0; height: 100%;' - : `flex: 0 0 auto; height: ${(1 - editorFraction) * 100}%; max-height: 600px; min-height: 0;`} + : `flex: 0 0 auto; height: ${(1 - editorFraction) * 100}%; min-height: 0;`} > @@ -431,7 +417,7 @@ type="button" class="divider divider-col" class:active={dragging === 'col'} - onmousedown={(e) => startDrag('col', e)} + onmousedown={(e) => startPaneDrag('col', e)} aria-label="Resize preview panel" >
@@ -546,14 +532,6 @@