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.
This commit is contained in:
NI0317
2026-07-19 21:39:08 +08:00
parent 97df884937
commit c2fdfae170
8 changed files with 63 additions and 4 deletions

View File

@@ -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. - **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. - **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. - **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. - **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.

View File

@@ -1625,6 +1625,17 @@ async function handleDelegatedClick(event: MouseEvent): Promise<void> {
return return
} }
const openPath = target.closest<HTMLElement>('[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<HTMLElement>('[data-copy-target]') const copyTarget = target.closest<HTMLElement>('[data-copy-target]')
if (copyTarget !== null) { if (copyTarget !== null) {
const graphTarget = state.graph.targets.get(copyTarget.dataset.copyTarget ?? '') const graphTarget = state.graph.targets.get(copyTarget.dataset.copyTarget ?? '')
@@ -1875,7 +1886,15 @@ function devListSubtitle(artifact: DevArtifact): string {
return artifact.subtitle 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 { function renderDevArtifactDetail(artifact: DevArtifact): string {
const editablePath = editableSourcePath(artifact)
return ` return `
<article class="dev-detail-card ${artifact.kind}"> <article class="dev-detail-card ${artifact.kind}">
<header class="dev-detail-head"> <header class="dev-detail-head">
@@ -1884,7 +1903,10 @@ function renderDevArtifactDetail(artifact: DevArtifact): string {
<h3>${escapeHtml(artifact.title)}</h3> <h3>${escapeHtml(artifact.title)}</h3>
<p>${escapeHtml(artifact.subtitle)}</p> <p>${escapeHtml(artifact.subtitle)}</p>
</div> </div>
<em>${escapeHtml(artifact.status ?? artifact.kind)}</em> <div class="dev-detail-actions">
${editablePath === undefined ? '' : `<button type="button" data-open-path="${escapeHtml(editablePath)}">${escapeHtml(t('dev.openInEditor'))}</button>`}
<em>${escapeHtml(artifact.status ?? artifact.kind)}</em>
</div>
</header> </header>
<section class="dev-detail-grid"> <section class="dev-detail-grid">
${renderDevFact(t('dev.source'), artifact.source ?? t('dev.unknown'))} ${renderDevFact(t('dev.source'), artifact.source ?? t('dev.unknown'))}

View File

@@ -29,6 +29,7 @@ declare global {
} }
dev: { dev: {
status(): Promise<unknown> status(): Promise<unknown>
openPath(path: string): Promise<unknown>
} }
} }
} }

View File

@@ -75,6 +75,9 @@ const messages = {
'toast.copied': '已复制', 'toast.copied': '已复制',
'feedback.saved': '标注已保存', 'feedback.saved': '标注已保存',
'feedback.failed': '标注保存失败', 'feedback.failed': '标注保存失败',
'dev.openInEditor': '在编辑器中打开',
'dev.openedInEditor': '已在默认编辑器中打开',
'dev.openFailed': '打开失败',
'app.resizeSidebar': '调整侧栏宽度', 'app.resizeSidebar': '调整侧栏宽度',
'app.resizeInspector': '调整检查器宽度', 'app.resizeInspector': '调整检查器宽度',
'kind.user': '用户', 'kind.user': '用户',
@@ -348,6 +351,9 @@ const messages = {
'toast.copied': 'Copied', 'toast.copied': 'Copied',
'feedback.saved': 'Feedback saved', 'feedback.saved': 'Feedback saved',
'feedback.failed': 'Failed to save feedback', '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.resizeSidebar': 'Resize sidebar',
'app.resizeInspector': 'Resize inspector', 'app.resizeInspector': 'Resize inspector',
'kind.user': 'User', 'kind.user': 'User',

View File

@@ -529,6 +529,18 @@ function registerIpc() {
shell.showItemInFolder(file) shell.showItemInFolder(file)
return { ok: true, path: 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('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:list', (_event, { sessionId, targetId }) => readFeedback(String(sessionId), targetId === undefined ? undefined : String(targetId)))
ipcMain.handle('feedback:add', (_event, entry) => appendFeedback(entry)) ipcMain.handle('feedback:add', (_event, entry) => appendFeedback(entry))

View File

@@ -39,6 +39,7 @@ const api = {
}, },
dev: { dev: {
status: () => ipcRenderer.invoke('dev:status'), status: () => ipcRenderer.invoke('dev:status'),
openPath: (path) => ipcRenderer.invoke('dev:open-path', { path }),
}, },
} }

View File

@@ -2977,3 +2977,20 @@ dd {
.empty-state p { .empty-state p {
text-wrap: pretty; 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;
}

View File

@@ -93,7 +93,7 @@ describe('desktop renderer chat lifecycle', () => {
}, },
trace: { read: async () => traceRead }, trace: { read: async () => traceRead },
feedback: { list: async () => [], add: async () => ({}) }, feedback: { list: async () => [], add: async () => ({}) },
dev: { status: async () => ({ git: {} }) }, dev: { status: async () => ({ git: {} }), openPath: async () => ({}) },
} }
await import('../src/app.ts') await import('../src/app.ts')
@@ -276,7 +276,7 @@ describe('desktop renderer chat lifecycle', () => {
}, },
trace: { read: async () => traceRead }, trace: { read: async () => traceRead },
feedback: { list: async () => [], add: async () => ({}) }, feedback: { list: async () => [], add: async () => ({}) },
dev: { status: async () => ({ git: {} }) }, dev: { status: async () => ({ git: {} }), openPath: async () => ({}) },
} }
await import('../src/app.ts') await import('../src/app.ts')