From 07a6b8a21edefeafc74fd07a9470c6b4d624c990 Mon Sep 17 00:00:00 2001 From: rijulshrestha Date: Wed, 19 Aug 2026 15:38:41 +0100 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 4/5] 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