diff --git a/src/areas/generate/components/WorkflowPanel.tsx b/src/areas/generate/components/WorkflowPanel.tsx
index ffc774c7..b05db987 100644
--- a/src/areas/generate/components/WorkflowPanel.tsx
+++ b/src/areas/generate/components/WorkflowPanel.tsx
@@ -15,6 +15,8 @@ import { buildAllWorkflowExtensions, getWorkflowExtension } from '@areas/workflo
import { validateWorkflowPreflight } from '@areas/workflows/preflight'
import type { WorkflowExtension } from '@areas/workflows/mockExtensions'
import type { Workflow, WFNode, WFEdge, ParamSchema } from '@shared/types/electron.d'
+import { PICKER_LABELS, openParamPicker, resolvePickerIntent } from '@shared/utils/paramPicker'
+import { PickerIcon } from '@shared/components/ui'
import ChatPanel from './ChatPanel'
type PanelMode = 'basic' | 'chat'
@@ -126,17 +128,17 @@ function ParamField({ param, value, onChange }: {
)
}
if (param.type === 'string') {
+ const intent = resolvePickerIntent(param)
return (
)
diff --git a/src/areas/workflows/nodes/ExtensionNode.tsx b/src/areas/workflows/nodes/ExtensionNode.tsx
index a9703290..13363f4c 100644
--- a/src/areas/workflows/nodes/ExtensionNode.tsx
+++ b/src/areas/workflows/nodes/ExtensionNode.tsx
@@ -4,6 +4,8 @@ import { useExtensionsStore } from '@shared/stores/extensionsStore'
import { buildAllWorkflowExtensions } from '../mockExtensions'
import type { ParamSchema } from '../mockExtensions'
import type { WFNodeData } from '@shared/types/electron.d'
+import { PICKER_LABELS, openParamPicker, resolvePickerIntent } from '@shared/utils/paramPicker'
+import { PickerIcon } from '@shared/components/ui'
import { useWorkflowRunStore } from '../workflowRunStore'
import BaseNode from './BaseNode'
@@ -129,20 +131,21 @@ function ParamControl({ param, value, onChange, resolvedParams }: {
)
}
if (param.type === 'string') {
+ const intent = resolvePickerIntent(param)
return (
)
diff --git a/src/shared/components/ui/PickerIcon.tsx b/src/shared/components/ui/PickerIcon.tsx
new file mode 100644
index 00000000..8893c3a0
--- /dev/null
+++ b/src/shared/components/ui/PickerIcon.tsx
@@ -0,0 +1,38 @@
+import type { PickerIntent } from '@shared/types/electron.d'
+
+/**
+ * Glyph for a param's browse button, matching the dialog it opens so the button
+ * advertises what it does (folder = the historical default).
+ */
+export function PickerIcon({ intent, size = 11 }: { intent: PickerIntent; size?: number }): JSX.Element {
+ const common = { width: size, height: size, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2 }
+
+ if (intent === 'image') {
+ return (
+
+ )
+ }
+ if (intent === 'mesh') {
+ return (
+
+ )
+ }
+ if (intent === 'text') {
+ return (
+
+ )
+ }
+ return (
+
+ )
+}
diff --git a/src/shared/components/ui/index.ts b/src/shared/components/ui/index.ts
index a31414a6..ef9d77f7 100644
--- a/src/shared/components/ui/index.ts
+++ b/src/shared/components/ui/index.ts
@@ -2,4 +2,5 @@ export { Tooltip } from './Tooltip'
export { FieldLabel } from './FieldLabel'
export { ConfirmModal } from './ConfirmModal'
export { ColorPicker } from './ColorPicker'
+export { PickerIcon } from './PickerIcon'
export { Toast } from './Toast'
diff --git a/src/shared/types/electron.d.ts b/src/shared/types/electron.d.ts
index 5be9ea27..d741a504 100644
--- a/src/shared/types/electron.d.ts
+++ b/src/shared/types/electron.d.ts
@@ -44,6 +44,8 @@ export interface ModelExtension {
manifestError?: 'missing' | 'invalid' | 'incomplete'
}
+export type PickerIntent = 'folder' | 'image' | 'mesh' | 'text'
+
export interface ParamSchema {
id: string
label: string
@@ -55,6 +57,10 @@ export interface ParamSchema {
step?: number
tooltip?: string
show_if?: Record
+ // string: which native dialog the browse button opens (default: 'folder')
+ pickerIntent?: PickerIntent
+ /** snake_case alias of pickerIntent, for manifests that follow show_if/dir_from. */
+ picker_intent?: PickerIntent
// file-select: dropdown of the files inside the folder held by another param
dir_from?: string // id of the (string) param holding the folder path
extensions?: string[] // file extensions to list (e.g. ["json"])
diff --git a/src/shared/utils/paramPicker.test.mjs b/src/shared/utils/paramPicker.test.mjs
new file mode 100644
index 00000000..5c975705
--- /dev/null
+++ b/src/shared/utils/paramPicker.test.mjs
@@ -0,0 +1,103 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { buildSync } from 'esbuild'
+import { createRequire } from 'node:module'
+import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join, resolve } from 'node:path'
+
+// paramPicker.ts only type-imports from electron.d, so esbuild erases it.
+function loadModule() {
+ const outfile = join(mkdtempSync(join(tmpdir(), 'modly-parampicker-test-')), 'paramPicker.cjs')
+ const require = createRequire(import.meta.url)
+ const result = buildSync({
+ entryPoints: [resolve('src/shared/utils/paramPicker.ts')],
+ bundle: true,
+ platform: 'node',
+ format: 'cjs',
+ write: false,
+ })
+ writeFileSync(outfile, result.outputFiles[0].text, 'utf8')
+ return require(outfile)
+}
+
+const { resolvePickerIntent, openParamPicker, PICKER_LABELS } = loadModule()
+
+/** Records which dialog was opened; each returns a path unique to that dialog. */
+function fakeFs() {
+ const calls = []
+ return {
+ calls,
+ selectDirectory: () => { calls.push('selectDirectory'); return Promise.resolve('C:\\picked\\folder') },
+ selectImage: () => { calls.push('selectImage'); return Promise.resolve('C:\\picked\\front.png') },
+ selectMeshFile: () => { calls.push('selectMeshFile'); return Promise.resolve('C:\\picked\\model.glb') },
+ selectTextFile: () => { calls.push('selectTextFile'); return Promise.resolve('C:\\picked\\notes.txt') },
+ }
+}
+
+const stringParam = (extra) => ({ id: 'front_image_path', label: 'Front image', type: 'string', default: '', ...extra })
+
+// ─── resolvePickerIntent ───────────────────────────────────────────────────────
+
+test('resolvePickerIntent honors pickerIntent and its snake_case alias', () => {
+ assert.equal(resolvePickerIntent(stringParam({ pickerIntent: 'image' })), 'image')
+ assert.equal(resolvePickerIntent(stringParam({ pickerIntent: 'mesh' })), 'mesh')
+ assert.equal(resolvePickerIntent(stringParam({ pickerIntent: 'text' })), 'text')
+ assert.equal(resolvePickerIntent(stringParam({ picker_intent: 'image' })), 'image')
+})
+
+test('resolvePickerIntent falls back to the folder picker when unset or unknown', () => {
+ assert.equal(resolvePickerIntent(stringParam()), 'folder') // pre-existing manifests
+ assert.equal(resolvePickerIntent(stringParam({ pickerIntent: 'folder' })), 'folder')
+ assert.equal(resolvePickerIntent(stringParam({ pickerIntent: 'hologram' })), 'folder') // newer manifest, older app
+ assert.equal(resolvePickerIntent(undefined), 'folder')
+})
+
+// ─── openParamPicker ───────────────────────────────────────────────────────────
+
+test('openParamPicker opens the image dialog for pickerIntent: image — issue #155', async () => {
+ const fs = fakeFs()
+ const picked = await openParamPicker(stringParam({ pickerIntent: 'image' }), fs)
+
+ assert.deepEqual(fs.calls, ['selectImage']) // and *not* selectDirectory
+ assert.equal(picked, 'C:\\picked\\front.png')
+})
+
+test('openParamPicker routes mesh/text intents to their existing dialogs', async () => {
+ const mesh = fakeFs()
+ assert.equal(await openParamPicker(stringParam({ pickerIntent: 'mesh' }), mesh), 'C:\\picked\\model.glb')
+ assert.deepEqual(mesh.calls, ['selectMeshFile'])
+
+ const text = fakeFs()
+ assert.equal(await openParamPicker(stringParam({ picker_intent: 'text' }), text), 'C:\\picked\\notes.txt')
+ assert.deepEqual(text.calls, ['selectTextFile'])
+})
+
+test('openParamPicker keeps the folder dialog when no intent is declared', async () => {
+ const fs = fakeFs()
+ assert.equal(await openParamPicker(stringParam(), fs), 'C:\\picked\\folder')
+ assert.deepEqual(fs.calls, ['selectDirectory'])
+})
+
+test('PICKER_LABELS names every intent, so the browse button always has an accessible name', () => {
+ for (const intent of ['folder', 'image', 'mesh', 'text']) {
+ assert.equal(typeof PICKER_LABELS[intent], 'string')
+ assert.ok(PICKER_LABELS[intent].length > 0)
+ }
+})
+
+// ─── Call sites ────────────────────────────────────────────────────────────────
+// The bug in #155 was not in a resolver (there wasn't one) — it was the string
+// param's browse button calling selectDirectory() unconditionally. There is no
+// DOM harness in this repo, so guard the wiring at the source level instead.
+
+for (const file of [
+ 'src/areas/workflows/nodes/ExtensionNode.tsx',
+ 'src/areas/generate/components/WorkflowPanel.tsx',
+]) {
+ test(`${file} routes its string param browse button through openParamPicker`, () => {
+ const src = readFileSync(resolve(file), 'utf8')
+ assert.match(src, /openParamPicker\(param, window\.electron\.fs\)/)
+ assert.doesNotMatch(src, /const p = await window\.electron\.fs\.selectDirectory\(\)/)
+ })
+}
diff --git a/src/shared/utils/paramPicker.ts b/src/shared/utils/paramPicker.ts
new file mode 100644
index 00000000..7e3a1974
--- /dev/null
+++ b/src/shared/utils/paramPicker.ts
@@ -0,0 +1,45 @@
+import type { ParamSchema, PickerIntent } from '@shared/types/electron.d'
+
+// Which native dialog the browse button next to a `string` param opens.
+// Extension manifests request one with `pickerIntent` (or `picker_intent`) on
+// the param; params that don't set it keep the historical folder picker.
+
+export const PICKER_INTENTS = ['folder', 'image', 'mesh', 'text'] as const
+
+/** Accessible name / tooltip for the browse button, per intent. */
+export const PICKER_LABELS: Record = {
+ folder: 'Browse for a folder…',
+ image: 'Browse for an image file…',
+ mesh: 'Browse for a 3D mesh file…',
+ text: 'Browse for a text file…',
+}
+
+/** Just the members of `window.electron.fs` a param picker can reach for. */
+export interface ParamPickerApi {
+ selectDirectory: (defaultPath?: string) => Promise
+ selectImage: () => Promise
+ selectMeshFile: () => Promise
+ selectTextFile: () => Promise
+}
+
+type PickerParam = Pick
+
+/**
+ * Intent a param asks for, falling back to 'folder' — the behavior every
+ * `string` param had before `pickerIntent` existed — when it is unset or is a
+ * value this build doesn't know about.
+ */
+export function resolvePickerIntent(param: PickerParam | undefined): PickerIntent {
+ const requested = param?.pickerIntent ?? param?.picker_intent
+ return PICKER_INTENTS.includes(requested as PickerIntent) ? (requested as PickerIntent) : 'folder'
+}
+
+/** Opens the dialog the param asked for. Resolves to null when cancelled. */
+export function openParamPicker(param: PickerParam | undefined, fs: ParamPickerApi): Promise {
+ switch (resolvePickerIntent(param)) {
+ case 'image': return fs.selectImage()
+ case 'mesh': return fs.selectMeshFile()
+ case 'text': return fs.selectTextFile()
+ default: return fs.selectDirectory()
+ }
+}