diff --git a/apps/desktop/README.md b/apps/desktop/README.md index d611cb4285..93e60739b1 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -139,7 +139,7 @@ Sub-folders hold OS-facing implementations such as `browser/`, `computer-use/`, Three patterns, all rooted in preload's `maka` namespace. Channel names are `:`. - **Request/response** — `ipcRenderer.invoke(':', …args)` in preload ↔ `ipcMain.handle(':', …)`. Runtime domains are projected by `runtime-host-*-ipc-main.ts`; OS-facing client domains use a focused `*-ipc-main.ts` module. -- **Main→renderer push** — main sends through the safe-send guard (`safeSendToRenderer` via `mainWindowController.send`), not raw `webContents.send` (which throws when the window/`webContents` is destroyed); preload subscribes via `ipcRenderer.on` and returns an unsubscribe fn (e.g. `sessions:changed`, `scheduled-tasks:changed`, `artifacts:changed`). The guard checks both the `BrowserWindow` and its `webContents` before delivery. Route every new main-window push through it. +- **Main→renderer push** — main sends through the safe-send guard (`safeSendToRenderer` via `mainWindowController.send`), not raw `webContents.send` (which throws when the window/`webContents` is destroyed); preload subscribes via `ipcRenderer.on` and returns an unsubscribe fn (e.g. `sessions:changed`, `scheduled-tasks:changed`). The guard checks both the `BrowserWindow` and its `webContents` before delivery. Route every new main-window push through it. - **Renderer→main fire-and-forget** — `ipcRenderer.send(':', …)` in preload ↔ `ipcMain.on(':', …)`. Used when no response is needed (e.g. `browser:active-session`, `browser:setViewport`). Adding a new IPC surface: if extracting, write the `*-ipc-main.ts` exporting a `register*Ipc(...)`, import it in `main.ts`, and call it inside `registerIpc()`; add the matching method to the `maka` namespace in `preload.ts`; add the method to the `window.maka` type in `src/global.d.ts` (the renderer's typed bridge — without it, renderer calls get a TS error); keep the `:` channel naming. A handler file that isn't registered in `registerIpc()` compiles but never mounts. diff --git a/apps/desktop/src/main/__tests__/runtime-host-artifacts-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-artifacts-ipc-main.test.ts index 6c452d5922..283d33f342 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-artifacts-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-artifacts-ipc-main.test.ts @@ -63,7 +63,6 @@ function attachmentReadHandler( streamArtifact, } as never, mainWindowController: {} as never, - sendToRenderer() {}, showItemInFolder() {}, }); const handler = handlers.get("attachments:readBytes"); @@ -78,7 +77,6 @@ test("Runtime Host Artifact IPC preserves previews and streams complete exports" const content = Buffer.alloc(70 * 1024, 5); const handlers = new Map(); const opened: string[] = []; - const events: unknown[] = []; const artifact = { id: "artifact-1", sessionId: "session-1", @@ -105,7 +103,7 @@ test("Runtime Host Artifact IPC preserves previews and streams complete exports" return { ok: false, reason: "unsupported_mime" }; }, async deleteArtifact() { - return { kind: "deleted", artifact: { ...artifact, status: "deleted" } }; + return { kind: "deleted" }; }, async streamArtifact( _sessionId: string, @@ -128,7 +126,6 @@ test("Runtime Host Artifact IPC preserves previews and streams complete exports" mainWindowController: { showSaveDialog: async () => ({ canceled: false, filePath: savedPath }), } as never, - sendToRenderer: (_channel, event) => events.push(event), showItemInFolder: (path) => opened.push(path), presentationRoot, }); @@ -159,7 +156,6 @@ test("Runtime Host Artifact IPC preserves previews and streams complete exports" assert.deepEqual(await readFile(opened[0]!), content); await handlers.get("artifacts:delete")?.({}, "session-1", "artifact-1"); - assert.equal((events[0] as { reason: string }).reason, "deleted"); } finally { await rm(root, { recursive: true, force: true }); } diff --git a/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts b/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts index 05ba07dce8..6e92fabf11 100644 --- a/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts +++ b/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts @@ -37,7 +37,6 @@ function createBridgeRecorder(): { 'browser.setViewport', 'browser.onState', 'browser.onLive', - 'artifacts.subscribeChanges', 'inspector.subscribeUsageChanges', ]); // Adapters that reshape a bridge answer need one to reshape. @@ -132,11 +131,10 @@ describe('createDesktopWorkbarServices', () => { services.browser.subscribeState(eventHandler)(); services.browser.subscribeLive(eventHandler)(); - await services.artifacts.list('s', { includeDeleted: true }); + await services.artifacts.list('s'); await services.artifacts.readText('s', 'a'); await services.artifacts.readBinary('s', 'a'); await services.artifacts.delete('s', 'a'); - services.artifacts.subscribeChanges(eventHandler)(); await services.artifacts.openPath('s', 'a'); await services.artifacts.saveAs('s', 'a'); @@ -207,7 +205,6 @@ describe('createDesktopWorkbarServices', () => { 'artifacts.readText', 'artifacts.readBinary', 'artifacts.delete', - 'artifacts.subscribeChanges', 'app.openArtifactPath', 'app.saveArtifactAs', 'inspector.trace', diff --git a/apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts b/apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts index 4032293ef4..f5c3b7200b 100644 --- a/apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts @@ -39,7 +39,6 @@ interface RuntimeHostArtifactsIpcDeps { readonly ipcMain: ReconnectableReadIpcMain; readonly client: DesktopRuntimeHostClient; readonly mainWindowController: ReturnType; - readonly sendToRenderer: (channel: string, ...args: unknown[]) => void; readonly showItemInFolder: (path: string) => void; readonly presentationRoot?: string; } @@ -61,16 +60,7 @@ export function registerRuntimeHostArtifactsIpc( handleReconnectableRead( deps.ipcMain, "artifacts:list", - async ( - _event, - sessionId: string, - options?: { includeDeleted?: boolean }, - ) => { - const artifacts = await deps.client.listArtifacts(sessionId); - return options?.includeDeleted - ? artifacts - : artifacts.filter(({ status }) => status !== "deleted"); - }, + (_event, sessionId: string) => deps.client.listArtifacts(sessionId), ); handleReconnectableRead( deps.ipcMain, @@ -86,23 +76,15 @@ export function registerRuntimeHostArtifactsIpc( ); deps.ipcMain.handle( "artifacts:delete", - async (_event, sessionId: string, artifactId: string) => { - const result = await deps.client.deleteArtifact(sessionId, artifactId); - deps.sendToRenderer("artifacts:changed", { - reason: "deleted", - artifactId, - sessionId, - ts: Date.now(), - }); - return result; - }, + (_event, sessionId: string, artifactId: string) => + deps.client.deleteArtifact(sessionId, artifactId), ); registerRuntimeHostAttachmentPreviewIpc(deps); deps.ipcMain.handle( "app:openArtifactPath", async (_event, sessionId: string, artifactId: string) => { const artifact = await deps.client.getArtifact(sessionId, artifactId); - if (!artifact || artifact.status === "deleted") { + if (!artifact) { return { ok: false as const, reason: "missing" as const }; } try { @@ -128,7 +110,6 @@ export function registerRuntimeHostArtifactsIpc( ): Promise => { const artifact = await deps.client.getArtifact(sessionId, artifactId); if (!artifact) return { ok: false, reason: "not_found" }; - if (artifact.status === "deleted") return { ok: false, reason: "deleted" }; const result = await deps.mainWindowController.showSaveDialog({ title: `另存为 ${artifact.name}`, defaultPath: artifact.name, @@ -161,10 +142,7 @@ export function registerRuntimeHostAttachmentPreviewIpc( "attachments:readBytes", async (_event, sessionId: string, artifactId: string) => { const artifact = await deps.client.getArtifact(sessionId, artifactId); - if ( - !artifact || - artifact.status === "deleted" - ) { + if (!artifact) { return { ok: false as const, reason: "not_found" }; } const preview = resolveArtifactImagePreview(artifact); diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 7caee1cf13..192e465f8f 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -1499,7 +1499,6 @@ function registerHostClientIpc( ipcMain: scopedIpc, client, mainWindowController, - sendToRenderer, showItemInFolder: (path) => shell.showItemInFolder(path), }); registerRuntimeHostOAuthIpc({ diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 70b940e6b6..779c6651a6 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -76,7 +76,6 @@ import type { } from '@maka/core/git-review'; import type { ArtifactBinaryReadResult, - ArtifactChangedEvent, ArtifactDescriptor, ArtifactSaveResult, ArtifactTextReadResult, @@ -1810,11 +1809,10 @@ export interface MakaBridge { getState(): Promise; }; artifacts: { - list(sessionId: string, opts?: { includeDeleted?: boolean }): Promise; + list(sessionId: string): Promise; readText(sessionId: string, artifactId: string): Promise; readBinary(sessionId: string, artifactId: string): Promise; delete(sessionId: string, artifactId: string): Promise; - subscribeChanges(handler: (event: ArtifactChangedEvent) => void): () => void; }; skills: { list(host?: DesktopRuntimeHostRef): Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index bcb2babbf9..e4b6a58c31 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -162,7 +162,6 @@ import type { } from '@maka/core/git-review'; import type { ArtifactBinaryReadResult, - ArtifactChangedEvent, ArtifactDescriptor, ArtifactSaveResult, ArtifactTextReadResult, @@ -3598,8 +3597,8 @@ const makaBridge = { }, }, artifacts: { - list(sessionId: string, opts?: { includeDeleted?: boolean }): Promise { - return invokeProjectedSessionRuntimeHost('artifacts:list', sessionId, opts); + list(sessionId: string): Promise { + return invokeProjectedSessionRuntimeHost('artifacts:list', sessionId); }, readText(sessionId: string, artifactId: string): Promise { return invokeSessionRuntimeHost('artifacts:readText', sessionId, artifactId); @@ -3610,14 +3609,6 @@ const makaBridge = { delete(sessionId: string, artifactId: string): Promise { return invokeSessionRuntimeHost('artifacts:delete', sessionId, artifactId); }, - subscribeChanges(handler: (event: ArtifactChangedEvent) => void): () => void { - return subscribeEveryRuntimeHostEvent('artifacts:changed', (scope, event: ArtifactChangedEvent) => - handler({ - ...event, - sessionId: recordRuntimeHostSessionScope(scope, event.sessionId), - }), - ); - }, }, skills: { list(host?: DesktopRuntimeHostRef): Promise { diff --git a/apps/desktop/src/renderer/features/workbar/ports.ts b/apps/desktop/src/renderer/features/workbar/ports.ts index 34ac1d89e7..4ee8b1cd65 100644 --- a/apps/desktop/src/renderer/features/workbar/ports.ts +++ b/apps/desktop/src/renderer/features/workbar/ports.ts @@ -24,7 +24,6 @@ import type { } from '@maka/core/events'; import type { ArtifactBinaryReadResult, - ArtifactChangedEvent, ArtifactDescriptor, ArtifactSaveResult, ArtifactTextReadResult, @@ -126,10 +125,7 @@ export type WorkbarOpenArtifactResult = }; export interface WorkbarArtifactsService { - list( - sessionId: string, - options?: { includeDeleted?: boolean }, - ): Promise; + list(sessionId: string): Promise; readText( sessionId: string, artifactId: string, @@ -139,9 +135,6 @@ export interface WorkbarArtifactsService { artifactId: string, ): Promise; delete(sessionId: string, artifactId: string): Promise; - subscribeChanges( - handler: (event: ArtifactChangedEvent) => void, - ): WorkbarUnsubscribe; openPath( sessionId: string, artifactId: string, diff --git a/apps/desktop/src/renderer/features/workbar/testing.ts b/apps/desktop/src/renderer/features/workbar/testing.ts index 28604bef18..11c1e45a56 100644 --- a/apps/desktop/src/renderer/features/workbar/testing.ts +++ b/apps/desktop/src/renderer/features/workbar/testing.ts @@ -102,7 +102,6 @@ export function createFakeWorkbarServices( readText: async () => ({ ok: false, reason: 'not_found' }), readBinary: async () => ({ ok: false, reason: 'not_found' }), delete: async () => undefined, - subscribeChanges: noopSubscription, openPath: async () => ({ ok: false, reason: 'missing' }), saveAs: async () => ({ ok: false, reason: 'canceled' }), }, diff --git a/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx b/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx index 89fd331441..b7c27cb40c 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx +++ b/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx @@ -60,12 +60,11 @@ import { Copy, Trash2, } from '@maka/ui/icons'; -import type { ArtifactDescriptor, ArtifactKind } from '@maka/core/artifacts'; +import { canUserDeleteArtifact, type ArtifactDescriptor, type ArtifactKind } from '@maka/core/artifacts'; import type { UiLocale } from '@maka/core/ui-locale'; import { formatRelativeTimestamp } from '@maka/core/relative-time'; import { generalizedErrorMessageForLocale, redactSecrets } from '@maka/core/redaction'; import { - Badge, Banner, Button, MoreMenu, @@ -84,6 +83,7 @@ import { useWorkbarServices } from '../../services-context.js'; export function ArtifactPane(props: { sessionId: string; + refreshEnabled: boolean; onCountChange?: (count: number) => void; onDismiss?: () => void; }) { @@ -133,7 +133,7 @@ export function ArtifactPane(props: { setSelectedId(null); }, [sessionId]); - const refresh = useCallback(async () => { + const refresh = useCallback(async (showFailureToast = true) => { const requestSeq = ++artifactListRequestSeqRef.current; if (!sessionId) { recordsSessionIdRef.current = undefined; @@ -143,9 +143,7 @@ export function ArtifactPane(props: { return; } try { - const next = await artifacts.list(sessionId, { - includeDeleted: true, - }); + const next = await artifacts.list(sessionId); if (artifactPaneMountedRef.current && requestSeq === artifactListRequestSeqRef.current) { recordsSessionIdRef.current = sessionId; setRecordsSessionId(sessionId); @@ -160,7 +158,7 @@ export function ArtifactPane(props: { recordsSessionIdRef.current = undefined; setRecordsSessionId(undefined); setRecords([]); - } else { + } else if (showFailureToast) { toast.error(copy.pane.refreshFailed, message, undefined, { sessionId }); } } @@ -168,22 +166,22 @@ export function ArtifactPane(props: { }, [artifacts, copy, locale, sessionId, toast]); useEffect(() => { - void refresh(); - if (!sessionId) return; - // Keep the list in sync without polling. The - // backend emits `{ reason: 'created' | 'deleted' | 'purged' }` on the - // `artifacts:changed` channel; we just re-list since the list is bounded - // (one session's worth) and the metadata is already in memory on main. - const unsubscribe = artifacts.subscribeChanges((event) => { - if (event.sessionId === sessionId) { - void refresh(); - } - }); + if (!props.refreshEnabled) return; + let stopped = false; + let timer: ReturnType | undefined; + // Writeback can commit after the terminal Session event. Read the existing + // catalog while the workbar is visible, including its background file tab's count. + const poll = async () => { + await refresh(false); + if (!stopped) timer = setTimeout(() => void poll(), 2_000); + }; + void poll(); return () => { + stopped = true; + clearTimeout(timer); artifactListRequestSeqRef.current += 1; - unsubscribe(); }; - }, [artifacts, sessionId, refresh]); + }, [props.refreshEnabled, sessionId, refresh]); const activeRecords = useMemo( () => (recordsSessionId === sessionId ? filterUserVisibleArtifacts(records) : []), @@ -194,7 +192,6 @@ export function ArtifactPane(props: { props.onCountChange?.(activeRecords.length); }, [activeRecords.length, props.onCountChange]); - // 已删除墓碑记录保持可选,用于展示明确失败态;只有选中 id 彻底消失时才回退到最新 live artifact。 useEffect(() => { if (activeRecords.length === 0) { if (selectedId !== null) setSelectedId(null); @@ -356,6 +353,7 @@ export function ArtifactPane(props: { async function deleteArtifact(artifactId: string) { const actionSessionId = sessionId; const record = activeRecords.find((entry) => entry.id === artifactId); + if (!record || !canUserDeleteArtifact(record)) return; const name = record?.name ?? copy.pane.fallbackName; const ok = await toast.confirm({ title: copy.pane.deleteTitle(name), @@ -494,7 +492,6 @@ export function ArtifactPane(props: { // ArrowUp/Down. tabIndex={-1} data-selected={record.id === selectedId ? 'true' : 'false'} - data-deleted={record.status === 'deleted' ? 'true' : 'false'} onClick={() => openPreview(record.id)} label={record.name} icon={( @@ -508,9 +505,6 @@ export function ArtifactPane(props: { {formatRelativeTimestamp(record.createdAt, Date.now(), locale)} - {record.status === 'deleted' && ( - - )} )} /> @@ -566,22 +560,14 @@ export function ArtifactPane(props: { onClick: () => void runArtifactAction(`${previewRecord.id}:copy`, () => copyText(previewRecord.id)), }] : []), - { type: 'divider' as const }, - { - label: - previewRecord.source === 'deep_research' || - previewRecord.source === 'tool_result_archive' - ? copy.pane.deleteReadOnly - : copy.pane.delete, + ...(canUserDeleteArtifact(previewRecord) ? [{ type: 'divider' as const }, { + label: copy.pane.delete, icon: