From c2fdfae170750160118d86f2bc6efe052eedc716 Mon Sep 17 00:00:00 2001 From: NI0317 Date: Sun, 19 Jul 2026 21:39:08 +0800 Subject: [PATCH] feat(ui): open Develop artifacts in the OS editor Develop stays read-first: each artifact whose source is a repository path gains an Open in editor action routed through a path-validated main-process handler (shell.openPath), instead of an in-app write path. Persona and prompt-assembly sources open the governing cordis.yml or source file directly. --- packages/ui/desktop/README.md | 2 +- packages/ui/desktop/src/app.ts | 24 +++++++++++++++++++++- packages/ui/desktop/src/global.d.ts | 1 + packages/ui/desktop/src/i18n.ts | 6 ++++++ packages/ui/desktop/src/main.mjs | 12 +++++++++++ packages/ui/desktop/src/preload.cjs | 1 + packages/ui/desktop/src/styles.css | 17 +++++++++++++++ packages/ui/desktop/tests/renderer.spec.ts | 4 ++-- 8 files changed, 63 insertions(+), 4 deletions(-) diff --git a/packages/ui/desktop/README.md b/packages/ui/desktop/README.md index f6587d1df0..cfb84149db 100644 --- a/packages/ui/desktop/README.md +++ b/packages/ui/desktop/README.md @@ -189,6 +189,6 @@ Start with a desktop package that defines shared UI contracts, then build the El - **Development build only** — this package ships a usable Electron/Vite app and a real ACP subprocess bridge, but it is not yet packaged as a signed distributable. - **ACP is the first runtime channel** — direct in-process embedding could make context queries and restarts richer, but would make isolation, teardown, and hot reload harder. -- **Develop is read-first** — it exposes prompts, tools, plugins, config, runtime state, and the change loop as a source browser; direct graphical plugin/config editing is deferred. +- **Develop is read-first** — it exposes prompts, tools, plugins, config, runtime state, and the change loop as a source browser; editing routes to the OS default editor through each artifact's `Open in editor` action (repository paths only), and in-app graphical editing stays deferred. - **Trace refresh is mixed live/persisted** — chat streams from ACP live updates (rendered incrementally, so composer input, fold state, and scroll survive streaming), while Trajectory and Waterfall read persisted JSONL after turns complete. - **Context and Compare surfaces are unimplemented** — the session view ships `Chat`, `Trajectory`, and `Waterfall`; the `Context`/`Compare` contracts above and replay remain documented product shape for later work. diff --git a/packages/ui/desktop/src/app.ts b/packages/ui/desktop/src/app.ts index 9641645088..db07fa2356 100644 --- a/packages/ui/desktop/src/app.ts +++ b/packages/ui/desktop/src/app.ts @@ -1625,6 +1625,17 @@ async function handleDelegatedClick(event: MouseEvent): Promise { return } + const openPath = target.closest('[data-open-path]')?.dataset.openPath + if (openPath !== undefined) { + try { + await window.dshDesktop.dev.openPath(openPath) + toast(t('dev.openedInEditor')) + } catch (error) { + toast(`${t('dev.openFailed')}: ${String(error)}`) + } + return + } + const copyTarget = target.closest('[data-copy-target]') if (copyTarget !== null) { const graphTarget = state.graph.targets.get(copyTarget.dataset.copyTarget ?? '') @@ -1875,7 +1886,15 @@ function devListSubtitle(artifact: DevArtifact): string { return artifact.subtitle } +/** A source is editable through the OS editor only when it is a repository path. */ +function editableSourcePath(artifact: DevArtifact): string | undefined { + const source = artifact.source ?? '' + if (/^(packages|examples|plugins|cordis\.yml)/.test(source)) return source.replace(/\/$/, '') + return undefined +} + function renderDevArtifactDetail(artifact: DevArtifact): string { + const editablePath = editableSourcePath(artifact) return `
@@ -1884,7 +1903,10 @@ function renderDevArtifactDetail(artifact: DevArtifact): string {

${escapeHtml(artifact.title)}

${escapeHtml(artifact.subtitle)}

- ${escapeHtml(artifact.status ?? artifact.kind)} +
+ ${editablePath === undefined ? '' : ``} + ${escapeHtml(artifact.status ?? artifact.kind)} +
${renderDevFact(t('dev.source'), artifact.source ?? t('dev.unknown'))} diff --git a/packages/ui/desktop/src/global.d.ts b/packages/ui/desktop/src/global.d.ts index 1d67cd7535..73b4e4c205 100644 --- a/packages/ui/desktop/src/global.d.ts +++ b/packages/ui/desktop/src/global.d.ts @@ -29,6 +29,7 @@ declare global { } dev: { status(): Promise + openPath(path: string): Promise } } } diff --git a/packages/ui/desktop/src/i18n.ts b/packages/ui/desktop/src/i18n.ts index 1dd2f24af9..9df2a98174 100644 --- a/packages/ui/desktop/src/i18n.ts +++ b/packages/ui/desktop/src/i18n.ts @@ -75,6 +75,9 @@ const messages = { 'toast.copied': '已复制', 'feedback.saved': '标注已保存', 'feedback.failed': '标注保存失败', + 'dev.openInEditor': '在编辑器中打开', + 'dev.openedInEditor': '已在默认编辑器中打开', + 'dev.openFailed': '打开失败', 'app.resizeSidebar': '调整侧栏宽度', 'app.resizeInspector': '调整检查器宽度', 'kind.user': '用户', @@ -348,6 +351,9 @@ const messages = { 'toast.copied': 'Copied', 'feedback.saved': 'Feedback saved', 'feedback.failed': 'Failed to save feedback', + 'dev.openInEditor': 'Open in editor', + 'dev.openedInEditor': 'Opened in your default editor', + 'dev.openFailed': 'Failed to open', 'app.resizeSidebar': 'Resize sidebar', 'app.resizeInspector': 'Resize inspector', 'kind.user': 'User', diff --git a/packages/ui/desktop/src/main.mjs b/packages/ui/desktop/src/main.mjs index 0ec0f4f899..7577f3f13b 100644 --- a/packages/ui/desktop/src/main.mjs +++ b/packages/ui/desktop/src/main.mjs @@ -529,6 +529,18 @@ function registerIpc() { shell.showItemInFolder(file) return { ok: true, path: file } }) + // Develop stays read-first: editing routes to the user's editor instead of an + // in-app write path. Only repository paths may be opened. + ipcMain.handle('dev:open-path', async (_event, { path }) => { + const target = resolve(repoRoot, String(path)) + if (target !== repoRoot && !target.startsWith(`${repoRoot}/`)) { + throw new Error(`path escapes the repository: ${String(path)}`) + } + if (!existsSync(target)) throw new Error(`path not found: ${String(path)}`) + const error = await shell.openPath(target) + if (error.length > 0) throw new Error(error) + return { ok: true, path: target } + }) ipcMain.handle('trace:read', (_event, { sessionId }) => readTrace(String(sessionId))) ipcMain.handle('feedback:list', (_event, { sessionId, targetId }) => readFeedback(String(sessionId), targetId === undefined ? undefined : String(targetId))) ipcMain.handle('feedback:add', (_event, entry) => appendFeedback(entry)) diff --git a/packages/ui/desktop/src/preload.cjs b/packages/ui/desktop/src/preload.cjs index 84e6805154..d42b5a8da2 100644 --- a/packages/ui/desktop/src/preload.cjs +++ b/packages/ui/desktop/src/preload.cjs @@ -39,6 +39,7 @@ const api = { }, dev: { status: () => ipcRenderer.invoke('dev:status'), + openPath: (path) => ipcRenderer.invoke('dev:open-path', { path }), }, } diff --git a/packages/ui/desktop/src/styles.css b/packages/ui/desktop/src/styles.css index d2abc4023f..0eba79276d 100644 --- a/packages/ui/desktop/src/styles.css +++ b/packages/ui/desktop/src/styles.css @@ -2977,3 +2977,20 @@ dd { .empty-state p { text-wrap: pretty; } + +.dev-detail-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.dev-detail-actions button { + height: var(--control-md); + padding: 0 10px; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: #fff; + color: var(--ink-soft); + font-size: 12px; + font-weight: 650; +} diff --git a/packages/ui/desktop/tests/renderer.spec.ts b/packages/ui/desktop/tests/renderer.spec.ts index 0dd90d5a46..92d9dc580f 100644 --- a/packages/ui/desktop/tests/renderer.spec.ts +++ b/packages/ui/desktop/tests/renderer.spec.ts @@ -93,7 +93,7 @@ describe('desktop renderer chat lifecycle', () => { }, trace: { read: async () => traceRead }, feedback: { list: async () => [], add: async () => ({}) }, - dev: { status: async () => ({ git: {} }) }, + dev: { status: async () => ({ git: {} }), openPath: async () => ({}) }, } await import('../src/app.ts') @@ -276,7 +276,7 @@ describe('desktop renderer chat lifecycle', () => { }, trace: { read: async () => traceRead }, feedback: { list: async () => [], add: async () => ({}) }, - dev: { status: async () => ({ git: {} }) }, + dev: { status: async () => ({ git: {} }), openPath: async () => ({}) }, } await import('../src/app.ts')