Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,63 @@ test("Runtime Host Artifact IPC preserves previews and streams complete exports"
}
});

test("HTML Artifact materializes and opens with the operating system default app", async () => {
const root = await mkdtemp(join(tmpdir(), "maka-host-html-artifact-ipc-"));
const presentationRoot = join(root, "presentations");
const content = Buffer.from("<!doctype html><button>Run interaction</button>");
const handlers = new Map<string, Handler>();
const openedPaths: string[] = [];
const artifact = previewArtifact({
name: "interactive.html",
kind: "html",
mimeType: "text/html",
sizeBytes: content.byteLength,
});

try {
registerRuntimeHostArtifactsIpc({
uiLocale: () => "en" as const,
ipcMain: {
handle: (channel, handler) => handlers.set(channel, handler as Handler),
},
client: {
hostEpoch: "host-1",
async getArtifact() {
return artifact;
},
async streamArtifact(
_sessionId: string,
_artifactId: string,
writeChunk: (chunk: Uint8Array) => Promise<void>,
) {
await writeChunk(content);
return content.byteLength;
},
} as never,
mainWindowController: {} as never,
showItemInFolder: () => {
throw new Error("HTML artifacts must use openPath");
},
openPath: async (path) => {
openedPaths.push(path);
return "";
},
presentationRoot,
});

const open = handlers.get("app:openArtifactPath");
assert.ok(open);
assert.deepEqual(await open({}, "session-1", "artifact-1"), {
ok: true,
opened: "interactive.html",
});
assert.equal(openedPaths.length, 1);
assert.equal(await readFile(openedPaths[0]!, "utf8"), content.toString("utf8"));
} finally {
await rm(root, { recursive: true, force: true });
}
});

test("Attachment byte IPC rejects preview-ineligible metadata before streaming", async () => {
for (const [overrides, reason] of [
[{ id: "artifact-large", sizeBytes: 2 * 1024 * 1024 + 1 }, "too_large"],
Expand Down
8 changes: 7 additions & 1 deletion apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ interface RuntimeHostArtifactsIpcDeps {
readonly client: DesktopRuntimeHostClient;
readonly mainWindowController: ReturnType<typeof createMainWindowController>;
readonly showItemInFolder: (path: string) => void;
readonly openPath?: (path: string) => Promise<string>;
readonly presentationRoot?: string;
}

Expand Down Expand Up @@ -96,7 +97,12 @@ export function registerRuntimeHostArtifactsIpc(
`${artifactId}-${sanitizeArtifactName(artifact.name)}`,
);
await materializeArtifact(deps.client, sessionId, artifactId, path, artifact.sizeBytes);
deps.showItemInFolder(path);
if (artifact.kind === 'html' && deps.openPath) {
const error = await deps.openPath(path);
if (error) return { ok: false as const, reason: "open-failed" as const };
} else {
deps.showItemInFolder(path);
}
return { ok: true as const, opened: artifact.name };
} catch {
return { ok: false as const, reason: "open-failed" as const };
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/main/runtime-host-boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1640,6 +1640,7 @@ function registerHostClientIpc(
client,
mainWindowController,
showItemInFolder: (path) => shell.showItemInFolder(path),
openPath: (path) => shell.openPath(path),
});
registerRuntimeHostOAuthIpc({
ipcMain: scopedIpc,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@
* 5. **Copy/export policy**: only the text-based kinds (`file`, `diff`,
* `html`) expose a Copy button. `image` / `pdf` rows do NOT — those are
* binary, and silently base64-stuffing a multi-MB PDF into the clipboard
* is a footgun. Both kinds still get「在 Finder 中打开」and「另存为」.
* is a footgun. HTML gets「打开」(the system default app); other kinds
* get「在 Finder 中打开」. All kinds still get「另存为」.
*
* Layout: fills the Generated files tab and switches between a list and one
* full-panel preview while reporting its authoritative filtered count.
Expand Down Expand Up @@ -544,7 +545,7 @@ export function ArtifactPane(props: {
onOpenChange={setMoreMenuOpen}
items={[
{
label: copy.pane.openInFinder,
label: previewRecord.kind === 'html' ? copy.pane.open : copy.pane.openInFinder,
icon: <FolderOpen size={ICON_SIZE.control} aria-hidden="true" />,
onClick: () => void runArtifactAction(`${previewRecord.id}:open`, () => openInFinder(previewRecord.id)),
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export function ArtifactPreview(props: { record: ArtifactDescriptor; onShowInFol
case 'diff':
return <DiffPreview record={record} copy={copy} />;
case 'html':
return <HtmlPreview record={record} copy={copy} />;
return <HtmlPreview record={record} copy={copy} onShowInFolder={onShowInFolder} />;
case 'image':
// PR-UI-RENDER-3a: route image previews through the typed
// registry shell so the resolution path (mime match / ext
Expand Down Expand Up @@ -176,10 +176,10 @@ function DiffPreview(props: { record: ArtifactDescriptor; copy: ArtifactCopy })
);
}

function HtmlPreview(props: { record: ArtifactDescriptor; copy: ArtifactCopy }) {
function HtmlPreview(props: { record: ArtifactDescriptor; copy: ArtifactCopy; onShowInFolder?: () => void }) {
const result = useTextRead(props.record.sessionId, props.record.id);
if (result.state === 'loading') return <PreviewLoading label={props.copy.preview.loadingHtml} />;
if (!result.value.ok) return <TextFailureCard record={props.record} reason={result.value.reason} copy={props.copy} />;
if (!result.value.ok) return <TextFailureCard record={props.record} reason={result.value.reason} copy={props.copy} onShowInFolder={props.onShowInFolder} />;
const bounded = boundPreviewText(result.value.text);
if (bounded.isDisplayTruncated) {
return (
Expand Down Expand Up @@ -335,9 +335,18 @@ function PreviewLoading(props: { label: string }) {
);
}

function TextFailureCard(props: { record: ArtifactDescriptor; reason: TextFailureReason; copy: ArtifactCopy }) {
function TextFailureCard(props: { record: ArtifactDescriptor; reason: TextFailureReason; copy: ArtifactCopy; onShowInFolder?: () => void }) {
const { status, title, description } = failureCopyText(props.record, props.reason, props.copy);
return <Banner status={status} role="status" title={title} description={description} />;
return (
<div className="maka-artifact-preview-failure">
<Banner status={status} role="status" title={title} description={description} />
{props.onShowInFolder ? (
<Button variant="secondary" size="sm" label={props.copy.pane.openInFinder} onClick={props.onShowInFolder}>
{props.copy.pane.openInFinder}
</Button>
) : null}
</div>
);
}

function BinaryFailureCard(props: { record: ArtifactDescriptor; reason: BinaryFailureReason; copy: ArtifactCopy }) {
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/renderer/locales/artifact-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export type ArtifactCopy = {
emptyHint: string;
back: string;
moreActions(name: string): string;
open: string;
openInFinder: string;
saveAs: string;
copy: string;
Expand Down Expand Up @@ -98,6 +99,7 @@ const ARTIFACT_COPY = {
listLoadFailed: '生成文件列表载入失败', retrying: '重试中…', retry: '重试', listAria: '生成文件列表',
previewNamed: (name) => `预览 ${name}`, empty: '暂无生成文件', emptyHint: '助手生成文件后会显示在这里。',
back: '返回生成文件列表', moreActions: (name) => `${name} 的更多操作`,
open: '打开',
openInFinder: '在 Finder 中打开', saveAs: '另存为', copy: '复制',
saveFailures: { not_found: '生成文件不存在。', not_allowed: '生成文件路径检查未通过。', write_failed: '目标位置无法写入。', deleted: '生成文件已删除,不能另存。', source_failed: '生成文件传输中断,请重试。', size_mismatch: '生成文件大小在传输过程中发生变化,请重试。', target_write_failed: '目标位置无法写入。', replace_failed: '替换目标文件失败,原文件已保留。', default: '无法保存生成文件。' },
actionFailed: '生成文件操作失败,请稍后重试。',
Expand Down Expand Up @@ -133,6 +135,7 @@ const ARTIFACT_COPY = {
listLoadFailed: '生成檔案列表載入失敗', retrying: '重試中…', retry: '重試', listAria: '生成檔案列表',
previewNamed: (name) => `預覽 ${name}`, empty: '暫無生成檔案', emptyHint: '助手生成檔案後會顯示在這裡。',
back: '返回生成檔案列表', moreActions: (name) => `${name} 的更多操作`,
open: '開啟',
openInFinder: '在 Finder 中開啟', saveAs: '另存為', copy: '複製',
saveFailures: { not_found: '生成檔案不存在。', not_allowed: '生成檔案路徑檢查未透過。', write_failed: '目標位置無法寫入。', deleted: '生成檔案已刪除,不能另存。', source_failed: '生成檔案傳輸中斷,請重試。', size_mismatch: '生成檔案大小在傳輸過程中發生變化,請重試。', target_write_failed: '目標位置無法寫入。', replace_failed: '替換目標檔案失敗,原檔案已保留。', default: '無法儲存生成檔案。' },
actionFailed: '生成檔案操作失敗,請稍後重試。',
Expand Down Expand Up @@ -168,6 +171,7 @@ const ARTIFACT_COPY = {
listLoadFailed: 'Failed to load generated files', retrying: 'Retrying…', retry: 'Retry', listAria: 'Generated files',
previewNamed: (name) => `Preview ${name}`, empty: 'No generated files', emptyHint: 'Files generated by the assistant appear here.',
back: 'Back to generated files', moreActions: (name) => `More actions for ${name}`,
open: 'Open',
openInFinder: 'Show in Finder', saveAs: 'Save as', copy: 'Copy',
saveFailures: { not_found: 'The generated file does not exist.', not_allowed: 'The generated file failed the path safety check.', write_failed: 'The destination is not writable.', deleted: 'Deleted generated files cannot be saved.', source_failed: 'The generated file transfer was interrupted. Try again.', size_mismatch: 'The generated file size changed during transfer. Try again.', target_write_failed: 'The destination is not writable.', replace_failed: 'Could not replace the destination. The original file was kept.', default: 'Could not save the generated file.' },
actionFailed: 'The generated file action failed. Try again later.',
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/__tests__/artifacts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,10 @@ describe('Artifact source policy', () => {
assert.equal(isArtifactUserVisible(projection), false);
assert.equal(isArtifactSharedSessionReadable(projection), true);
});

test('exposes directly written HTML files while keeping other tool results internal', () => {
assert.equal(isArtifactUserVisible({ source: 'tool_result', kind: 'html' }), true);
assert.equal(isArtifactUserVisible({ source: 'tool_result', kind: 'file' }), false);
assert.equal(isArtifactUserVisible({ source: 'tool_result', kind: 'diff' }), false);
});
});
9 changes: 8 additions & 1 deletion packages/core/src/artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,14 @@ const CHILD_RESULT_OUTPUT_SOURCES = new Set<ArtifactSource>([
'deep_research',
]);

export function isArtifactUserVisible(record: Pick<ArtifactRecord, 'source'>): boolean {
export function isArtifactUserVisible(
record: Pick<ArtifactRecord, 'source'> & Partial<Pick<ArtifactRecord, 'kind'>>,
): boolean {
// A directly written HTML file is an intentional user-facing deliverable:
// the Artifact Pane must be able to preview and open it without requiring a
// child-workspace writeback. Other tool results remain internal to avoid
// flooding the Generated Files tab with command output and diffs.
if (record.source === 'tool_result' && record.kind === 'html') return true;
return ARTIFACT_SOURCE_POLICIES[record.source].userVisible;
}

Expand Down