-
{entry.displayName}
-
{entry.capability ?? entry.state.replace(/-/g, ' ')}
+ {thumbnailDataUrl && (
+

+ )}
+
+
+ {entry.displayName}
+ {entry.capability ?? entry.state.replace(/-/g, ' ')}
+
+
{entry.workspacePath}
-
{entry.workspacePath}
)
})}
@@ -675,8 +705,11 @@ export default function GeneratePage(): JSX.Element {
const [librarySearchQuery, setLibrarySearchQuery] = useState('')
const [librarySortMode, setLibrarySortMode] = useState
('type')
const [libraryCollapsedSectionKeys, setLibraryCollapsedSectionKeys] = useState(() => getDefaultAssetLibraryCollapsedSectionKeys())
+ const [libraryThumbnails, setLibraryThumbnails] = useState>({})
+ const [libraryPanelWidth, setLibraryPanelWidth] = useState(() => getStoredAssetLibraryPanelWidth())
const [gizmoMode, setGizmoMode] = useState<'translate' | 'rotate' | 'scale' | null>(null)
const dragging = useRef(false)
+ const libraryPanelDragging = useRef(false)
const [selectedPointLightId, setSelectedPointLightId] = useState(null)
// Populated by Viewer3D — undoes the latest live gizmo transform, if any.
const gizmoUndoRef = useRef<(() => boolean) | null>(null)
@@ -758,6 +791,11 @@ export default function GeneratePage(): JSX.Element {
// eslint-disable-next-line react-hooks/exhaustive-deps -- lazy-load guarded by loaded/loading flags
}, [openPanel, libraryLoaded, libraryLoading])
+ // Persist the library panel's width so a manual resize survives app restarts.
+ useEffect(() => {
+ storeAssetLibraryPanelWidth(libraryPanelWidth)
+ }, [libraryPanelWidth])
+
async function handleUnloadAll() {
await window.electron.model.unloadAll()
setUnloadStatus('done')
@@ -863,6 +901,7 @@ export default function GeneratePage(): JSX.Element {
? current
: result.entries.find(isAssetLibraryEntryOpenable)?.id ?? result.entries[0]?.id ?? null)
setLibraryLoaded(true)
+ void loadLibraryThumbnails(result.entries)
} catch (err) {
setLibraryLoaded(false)
setLibraryEntries([])
@@ -873,6 +912,17 @@ export default function GeneratePage(): JSX.Element {
}
}
+ // Thumbnails are best-effort: only .glb/.gltf entries can have a rendered
+ // .thumb.png, and a missing one is silently skipped rather than surfaced.
+ async function loadLibraryThumbnails(entries: ProjectedAssetLibraryEntry[]) {
+ const candidates = entries.filter((entry) => entry.previewKind === '3d-model')
+ const results = await Promise.all(candidates.map(async (entry) => {
+ const result = await assetLibraryService.thumbnail({ workspacePath: entry.workspacePath })
+ return [entry.workspacePath, result.success ? result.dataUrl : null] as const
+ }))
+ setLibraryThumbnails(Object.fromEntries(results.filter((pair): pair is [string, string] => pair[1] !== null)))
+ }
+
async function handleOpenSelectedLibraryEntry() {
const selectedEntry = libraryEntries.find((entry) => entry.id === librarySelectedEntryId) ?? null
if (!selectedEntry) {
@@ -959,6 +1009,23 @@ export default function GeneratePage(): JSX.Element {
window.addEventListener('mouseup', onMouseUp)
}, [])
+ const onLibraryPanelResizeMouseDown = useCallback((e: React.MouseEvent) => {
+ e.preventDefault()
+ libraryPanelDragging.current = true
+
+ const onMouseMove = (ev: MouseEvent) => {
+ if (!libraryPanelDragging.current) return
+ setLibraryPanelWidth((w) => clampAssetLibraryPanelWidth(w + ev.movementX))
+ }
+ const onMouseUp = () => {
+ libraryPanelDragging.current = false
+ window.removeEventListener('mousemove', onMouseMove)
+ window.removeEventListener('mouseup', onMouseUp)
+ }
+ window.addEventListener('mousemove', onMouseMove)
+ window.addEventListener('mouseup', onMouseUp)
+ }, [])
+
return (
<>
@@ -1082,6 +1149,8 @@ export default function GeneratePage(): JSX.Element {
searchQuery={librarySearchQuery}
sortMode={librarySortMode}
collapsedSectionKeys={libraryCollapsedSectionKeys}
+ thumbnails={libraryThumbnails}
+ panelWidth={libraryPanelWidth}
onSelectEntry={(entryId) => {
setLibraryError(null)
setLibrarySelectedEntryId(entryId)
@@ -1092,6 +1161,7 @@ export default function GeneratePage(): JSX.Element {
onOpenSelected={() => { void handleOpenSelectedLibraryEntry() }}
onRefresh={() => { void loadLibraryEntries() }}
onClose={() => setOpenPanel(null)}
+ onResizeMouseDown={onLibraryPanelResizeMouseDown}
/>
)}
diff --git a/src/areas/generate/assetLibraryService.test.ts b/src/areas/generate/assetLibraryService.test.ts
index 9e7938c4..0ac08bce 100644
--- a/src/areas/generate/assetLibraryService.test.ts
+++ b/src/areas/generate/assetLibraryService.test.ts
@@ -50,6 +50,7 @@ test('renderer service delegates safe IPC calls and normalizes returned entries'
}),
read: async () => ({ success: false, error: { code: 'missing', message: 'Missing' } }),
open: async (request) => { openRequest = request; return { success: false, error: { code: 'missing', message: 'Missing' } } },
+ thumbnail: async () => ({ success: false }),
})
const result = await service.list()
@@ -58,3 +59,21 @@ test('renderer service delegates safe IPC calls and normalizes returned entries'
await service.open({ workspacePath: 'Workflows/hero.landmarks.v1.json', sourceWorkspacePath: 'Workflows/hero.glb' })
assert.deepEqual(openRequest, { workspacePath: 'Workflows/hero.landmarks.v1.json', sourceWorkspacePath: 'Workflows/hero.glb' })
})
+
+test('renderer service validates thumbnail requests before IPC and never surfaces an error', async () => {
+ let invoked = false
+ const service = createAssetLibraryService({
+ list: async () => ({ success: true, entries: [] }),
+ read: async () => ({ success: false, error: { code: 'unexpected', message: 'should not run' } }),
+ open: async () => ({ success: false, error: { code: 'unexpected', message: 'should not run' } }),
+ thumbnail: async () => { invoked = true; return { success: true, dataUrl: 'data:image/png;base64,ok' } },
+ })
+
+ const unsafe = await service.thumbnail({ workspacePath: '../escape.glb' })
+ assert.deepEqual(unsafe, { success: false })
+ assert.equal(invoked, false)
+
+ const safe = await service.thumbnail({ workspacePath: 'Exports/hero.glb' })
+ assert.deepEqual(safe, { success: true, dataUrl: 'data:image/png;base64,ok' })
+ assert.equal(invoked, true)
+})
diff --git a/src/areas/generate/assetLibraryService.ts b/src/areas/generate/assetLibraryService.ts
index 38738579..5c18d49d 100644
--- a/src/areas/generate/assetLibraryService.ts
+++ b/src/areas/generate/assetLibraryService.ts
@@ -5,6 +5,8 @@ import type {
AssetLibraryOpenResult,
AssetLibraryReadRequest,
AssetLibraryReadResult,
+ AssetLibraryThumbnailRequest,
+ AssetLibraryThumbnailResult,
} from '../../shared/types/assetLibrary'
import { projectAssetLibraryEntry, type ProjectedAssetLibraryEntry } from './assetLibraryProjection'
@@ -12,6 +14,7 @@ export interface AssetLibraryPreloadApi {
list: () => Promise
read: (request: AssetLibraryReadRequest) => Promise
open: (request: AssetLibraryOpenRequest) => Promise
+ thumbnail: (request: AssetLibraryThumbnailRequest) => Promise
}
export type ProjectedAssetLibraryListResult =
@@ -60,6 +63,12 @@ export function createAssetLibraryService(api: AssetLibraryPreloadApi) {
if (!result.success) return result
return { success: true, entry: projectAssetLibraryEntry(result.entry) }
},
+ // A missing/invalid thumbnail is never an error the UI needs to show — the
+ // Library list just falls back to its text-only row.
+ async thumbnail(request: AssetLibraryThumbnailRequest): Promise {
+ if (validateWorkspacePath(request.workspacePath)) return { success: false }
+ return api.thumbnail(request)
+ },
}
}
diff --git a/src/areas/generate/assetLibraryUi.test.ts b/src/areas/generate/assetLibraryUi.test.ts
index e6b9fbca..681c292e 100644
--- a/src/areas/generate/assetLibraryUi.test.ts
+++ b/src/areas/generate/assetLibraryUi.test.ts
@@ -3,12 +3,15 @@ import test from 'node:test'
import {
buildAssetLibraryOpenRequest,
+ clampAssetLibraryPanelWidth,
createAssetLibraryOpenJob,
describeAssetLibraryOpenability,
filterAssetLibraryScopeGroups,
getDefaultAssetLibraryCollapsedSectionKeys,
+ getStoredAssetLibraryPanelWidth,
isAssetLibraryEntryOpenable,
resolveOpenPanelAfterLibrarySelection,
+ storeAssetLibraryPanelWidth,
toggleAssetLibrarySectionKey,
type AssetLibrarySortMode,
type GenerateOpenPanel,
@@ -123,3 +126,12 @@ test('builds linked-source open requests and import jobs for safe sidecars', ()
assert.equal(selection?.historyUrl, '/workspace/Workflows/run/hero.glb')
assert.equal(selection?.job.outputUrl, '/workspace/Workflows/run/hero.glb')
})
+
+test('Library panel width clamps to sane bounds and degrades silently without a localStorage', () => {
+ assert.equal(clampAssetLibraryPanelWidth(0), 260)
+ assert.equal(clampAssetLibraryPanelWidth(999), 560)
+ assert.equal(clampAssetLibraryPanelWidth(400), 400)
+ // node --test has no `localStorage` global — both helpers must fall back rather than throw.
+ assert.equal(getStoredAssetLibraryPanelWidth(), 320)
+ assert.doesNotThrow(() => storeAssetLibraryPanelWidth(400))
+})
diff --git a/src/areas/generate/assetLibraryUi.ts b/src/areas/generate/assetLibraryUi.ts
index 5e83e65a..295dd185 100644
--- a/src/areas/generate/assetLibraryUi.ts
+++ b/src/areas/generate/assetLibraryUi.ts
@@ -168,6 +168,35 @@ export function resolveOpenPanelAfterLibrarySelection(currentPanel: GenerateOpen
return currentPanel === 'library' ? 'library' : currentPanel
}
+export const ASSET_LIBRARY_PANEL_MIN_WIDTH = 260
+export const ASSET_LIBRARY_PANEL_MAX_WIDTH = 560
+export const ASSET_LIBRARY_PANEL_DEFAULT_WIDTH = 320
+
+const ASSET_LIBRARY_PANEL_WIDTH_STORAGE_KEY = 'modly-library-panel-width'
+
+export function clampAssetLibraryPanelWidth(width: number): number {
+ return Math.min(ASSET_LIBRARY_PANEL_MAX_WIDTH, Math.max(ASSET_LIBRARY_PANEL_MIN_WIDTH, width))
+}
+
+/** Reads the user's saved Library panel width, falling back to the default when unset, invalid, or unreadable. */
+export function getStoredAssetLibraryPanelWidth(): number {
+ try {
+ const raw = Number(localStorage.getItem(ASSET_LIBRARY_PANEL_WIDTH_STORAGE_KEY))
+ return Number.isFinite(raw) && raw > 0 ? clampAssetLibraryPanelWidth(raw) : ASSET_LIBRARY_PANEL_DEFAULT_WIDTH
+ } catch {
+ return ASSET_LIBRARY_PANEL_DEFAULT_WIDTH
+ }
+}
+
+/** Persists the Library panel width so it survives app restarts. */
+export function storeAssetLibraryPanelWidth(width: number): void {
+ try {
+ localStorage.setItem(ASSET_LIBRARY_PANEL_WIDTH_STORAGE_KEY, String(width))
+ } catch {
+ // Ignore quota/private-mode failures — the panel just won't remember its size.
+ }
+}
+
function sortAssetLibraryEntries(
entries: ProjectedAssetLibraryEntry[],
sortMode: AssetLibrarySortMode,
diff --git a/src/shared/types/assetLibrary.ts b/src/shared/types/assetLibrary.ts
index fb5a602d..87c48785 100644
--- a/src/shared/types/assetLibrary.ts
+++ b/src/shared/types/assetLibrary.ts
@@ -81,3 +81,13 @@ export type AssetLibraryReadResult =
export type AssetLibraryOpenResult =
| { success: true, entry: AssetLibraryEntry }
| { success: false, error: AssetLibraryError }
+
+export interface AssetLibraryThumbnailRequest {
+ workspacePath: string
+}
+
+// Missing thumbnails are an expected, silent condition (not every asset has
+// a rendered preview), so failure carries no error payload for the UI to show.
+export type AssetLibraryThumbnailResult =
+ | { success: true, dataUrl: string }
+ | { success: false }
diff --git a/src/shared/types/electron.d.ts b/src/shared/types/electron.d.ts
index c171b664..ab907c17 100644
--- a/src/shared/types/electron.d.ts
+++ b/src/shared/types/electron.d.ts
@@ -7,6 +7,8 @@ import type {
AssetLibraryOpenResult,
AssetLibraryReadRequest,
AssetLibraryReadResult,
+ AssetLibraryThumbnailRequest,
+ AssetLibraryThumbnailResult,
} from './assetLibrary'
// ─── Extension types ──────────────────────────────────────────────────────────
@@ -297,6 +299,7 @@ declare global {
list: () => Promise
read: (request: AssetLibraryReadRequest) => Promise
open: (request: AssetLibraryOpenRequest) => Promise
+ thumbnail: (request: AssetLibraryThumbnailRequest) => Promise
}
}
setup: {
From 7264ac59d632b0af2ab49977b04840b6a10614f1 Mon Sep 17 00:00:00 2001
From: JayceeB1 <45721154+JayceeB1@users.noreply.github.com>
Date: Fri, 9 Oct 2026 23:30:50 +0200
Subject: [PATCH 04/11] fix(viewer): repair malformed animation clip fallback
from #242
---
src/areas/generate/components/Viewer3D.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/areas/generate/components/Viewer3D.tsx b/src/areas/generate/components/Viewer3D.tsx
index 0d43d5c2..e3de5d6b 100644
--- a/src/areas/generate/components/Viewer3D.tsx
+++ b/src/areas/generate/components/Viewer3D.tsx
@@ -316,7 +316,7 @@ function GltfMeshModel(props: MeshModelProps): JSX.Element {
className="bg-zinc-800 text-zinc-200 border border-zinc-700 rounded px-2 py-1 text-xs"
>
{animations.map((clip, i) => (
-
+
))}