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:
@@ -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.
|
||||
@@ -1625,6 +1625,17 @@ async function handleDelegatedClick(event: MouseEvent): Promise<void> {
|
||||
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]')
|
||||
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 `
|
||||
<article class="dev-detail-card ${artifact.kind}">
|
||||
<header class="dev-detail-head">
|
||||
@@ -1884,7 +1903,10 @@ function renderDevArtifactDetail(artifact: DevArtifact): string {
|
||||
<h3>${escapeHtml(artifact.title)}</h3>
|
||||
<p>${escapeHtml(artifact.subtitle)}</p>
|
||||
</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>
|
||||
<section class="dev-detail-grid">
|
||||
${renderDevFact(t('dev.source'), artifact.source ?? t('dev.unknown'))}
|
||||
|
||||
Vendored
+1
@@ -29,6 +29,7 @@ declare global {
|
||||
}
|
||||
dev: {
|
||||
status(): Promise<unknown>
|
||||
openPath(path: string): Promise<unknown>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -39,6 +39,7 @@ const api = {
|
||||
},
|
||||
dev: {
|
||||
status: () => ipcRenderer.invoke('dev:status'),
|
||||
openPath: (path) => ipcRenderer.invoke('dev:open-path', { path }),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user