Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
19 changes: 13 additions & 6 deletions apps/desktop/electron/main/ipc/workspace-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { BrowserWindow, dialog, shell, type OpenDialogOptions } from "electron";
import { dirname } from "node:path";
import { homedir } from "node:os";
import { existsSync, statSync } from "node:fs";
import { realpath } from "node:fs/promises";
import { realpath, stat } from "node:fs/promises";
import { isAbsolute, join, resolve } from "node:path";
import {
ErrorCodes,
Expand Down Expand Up @@ -32,9 +32,9 @@ import {
listDir,
readOpenableFile,
readOpenableImage,
resolveOpenablePath,
resolveRealOpenablePath,
} from "@pi-desktop/host-runtime";
import { openableMp4Path } from "../open-attachment-video";
import { resolveChatFileRef } from "../chat-ref-resolve";
import { getWorkspaceFileIndex } from "../fs-index";
import {
Expand Down Expand Up @@ -870,10 +870,11 @@ export function registerWorkspaceIpc({
return { ok: true };
});

handle(IPC.invoke.fsOpen, async (input: { path?: string } = {}) => {
handle(IPC.invoke.fsOpen, async (input: { path?: string; mimeType?: string } = {}) => {
const workspaceRoot = await optionalWorkspaceRoot();
const target = resolveOpenablePath(
String(input.path ?? ""),
const requested = String(input.path ?? "").trim();
const target = await resolveRealOpenablePath(
requested,
workspaceRoot,
await fsExtraRoots(workspaceRoot),
);
Expand All @@ -882,7 +883,13 @@ export function registerWorkspaceIpc({
errorCode: ErrorCodes.INVALID_ARGUMENT,
});
}
const openError = await shell.openPath(stripWinLongPrefix(target));
if (!(await stat(target)).isFile()) {
throw Object.assign(new Error("not a file"), {
errorCode: ErrorCodes.INVALID_ARGUMENT,
});
}
const openPath = await openableMp4Path(dataDir, target, input.mimeType);
const openError = await shell.openPath(stripWinLongPrefix(openPath));
if (openError) throw new Error(openError);
return { ok: true };
});
Expand Down
39 changes: 39 additions & 0 deletions apps/desktop/electron/main/open-attachment-video.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { lstat, mkdir, realpath, symlink } from "node:fs/promises";
import { basename, dirname, join } from "node:path";

const ATTACHMENT_HASH = /^[0-9a-f]{64}$/i;

/** Give an extensionless attachment its media association without copying it. */
export async function openableMp4Path(
dataDir: string,
target: string,
mimeType?: string,
): Promise<string> {
const hash = basename(target);
if (!ATTACHMENT_HASH.test(hash) || mimeType?.toLowerCase() !== "video/mp4") {
return target;
}
let attachmentRoot: string;
try {
attachmentRoot = await realpath(join(dataDir, "attachments"));
} catch {
return target;
}
if (dirname(target) !== attachmentRoot) return target;

const directory = join(dataDir, "openable-attachments");
await mkdir(directory, { recursive: true, mode: 0o700 });
if ((await lstat(directory)).isSymbolicLink()) {
throw new Error("attachment open directory is a symbolic link");
}
const alias = join(directory, `${hash.toLowerCase()}.mp4`);
try {
await symlink(target, alias, "file");
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
if (!(await lstat(alias)).isSymbolicLink() || (await realpath(alias)) !== target) {
throw new Error("attachment open path is already occupied");
}
}
return alias;
}
31 changes: 29 additions & 2 deletions apps/desktop/src/components/workpanel/FilesTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { useAppStore } from "../../stores/app-store";
import { api } from "../../lib/api";
import { Markdown } from "../Markdown";
import { fileDirOf } from "../../lib/chat-links";
import { cx } from "../ui";
import { Button, cx } from "../ui";
import { TooltipButton } from "../ui";
import {
ensureLang,
Expand Down Expand Up @@ -74,6 +74,13 @@ function isMarkdownPath(path: string): boolean {
return /\.(?:md|markdown)$/i.test(path);
}

function isMp4(path: string, mimeType?: string): boolean {
return /\.mp4$/i.test(path) || (
/(?:^|[\\/])attachments[\\/][0-9a-f]{64}$/i.test(path) &&
mimeType?.toLowerCase() === "video/mp4"
);
}

function formatSize(size: number): string {
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
Expand Down Expand Up @@ -149,11 +156,13 @@ export function FilesTab() {
const { t } = useTranslation();
const workspace = useAppStore((s) => s.workspace);
const fileRequest = useAppStore((s) => s.workPanelFileRequest);
const showToast = useAppStore((s) => s.showToast);
const root = workspace?.path ?? null;

const [dirs, setDirs] = useState<Record<string, DirState>>({});
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [selected, setSelected] = useState<string | null>(null);
const [selectedMimeType, setSelectedMimeType] = useState<string | undefined>();
const [file, setFile] = useState<FsReadResult | null>(null);
const [fileError, setFileError] = useState(false);

Expand All @@ -168,6 +177,7 @@ export function FilesTab() {
setDirs({});
setExpanded(new Set());
setSelected(null);
setSelectedMimeType(undefined);
setFile(null);
setFileError(false);
}, [root]);
Expand Down Expand Up @@ -207,6 +217,7 @@ export function FilesTab() {

const openFile = useCallback(async (rel: string, mimeType?: string) => {
setSelected(rel);
setSelectedMimeType(mimeType);
setFile(null);
setFileError(false);
try {
Expand All @@ -216,6 +227,15 @@ export function FilesTab() {
}
}, []);

const openMp4 = useCallback(async () => {
if (!selected) return;
try {
await api.fsOpen(selected, selectedMimeType);
} catch {
showToast(t("panel.files.openFailed"), { variant: "error" });
}
}, [selected, selectedMimeType, showToast, t]);

// Chat-initiated previews: open the file and expand its ancestor folders
// so "back" lands on a tree that reveals it. Attachment blobs and absolute
// scratch paths live outside the workspace tree.
Expand Down Expand Up @@ -316,6 +336,7 @@ export function FilesTab() {
ariaLabel={t("panel.files.back")}
onClick={() => {
setSelected(null);
setSelectedMimeType(undefined);
setFile(null);
}}
>
Expand Down Expand Up @@ -358,7 +379,13 @@ export function FilesTab() {
? t("panel.files.tooLarge")
: t("panel.files.binary")
}
/>
>
{isMp4(selected, selectedMimeType) && (
<Button type="button" onClick={() => void openMp4()}>
{t("chat.openFile")}
</Button>
)}
</WorkTabEmpty>
)}
</div>
</div>
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/features/chat/transcript/MessageRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@ export const MessageRow = memo(function MessageRow({
name={attachment.name}
path={attachment.ref}
kind={attachment.kind}
mimeType={attachment.mimeType}
onOpen={openFileRef}
/>
</span>
Expand Down
9 changes: 6 additions & 3 deletions apps/desktop/src/features/chat/transcript/shared.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -416,13 +416,15 @@ export function FileRefChip({
name,
path,
kind,
mimeType,
onOpen,
...position
}: {
name: string;
path: string;
kind?: "image" | "file";
onOpen: (path: string) => void;
mimeType?: string;
onOpen: (path: string, baseDir?: string, mimeType?: string) => void;
} & SourcePositionProps) {
const { t } = useTranslation();
const Icon = fileChipIcon(name, kind);
Expand All @@ -436,7 +438,7 @@ export function FileRefChip({
{...position}
title={`${html ? t("chat.previewUrl") : t("chat.openFile")} — ${path}`}
aria-label={`${name} — ${path}`}
onClick={() => onOpen(path)}
onClick={() => onOpen(path, undefined, mimeType)}
onContextMenu={(event) => openFileMenu(event, { path })}
>
<span className="composer-chip-icon" aria-hidden>
Expand All @@ -459,7 +461,7 @@ export function MessageAttachmentImage({
onOpenFile,
}: {
attachment: MessageAttachment;
onOpenFile: (path: string) => void;
onOpenFile: (path: string, baseDir?: string, mimeType?: string) => void;
}) {
const { fileMenu, openFileMenu, closeFileMenu } = useChatFileMenu();
const dataUrl = useReferencedImageDataUrl(attachment.ref, attachment.mimeType);
Expand All @@ -469,6 +471,7 @@ export function MessageAttachmentImage({
name={attachment.name}
path={attachment.ref}
kind="image"
mimeType={attachment.mimeType}
onOpen={onOpenFile}
/>
);
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1360,7 +1360,8 @@ export const api = {
...(mimeType ? { mimeType } : {}),
}),
fsReveal: (path: string) => invoke(IPC.invoke.fsReveal, { path }),
fsOpen: (path: string) => invoke(IPC.invoke.fsOpen, { path }),
fsOpen: (path: string, mimeType?: string) =>
invoke(IPC.invoke.fsOpen, { path, mimeType }),
fsIndex: () => invoke<FsIndexResult>(IPC.invoke.fsIndex),
/**
* Complete a file reference from chat text to a real file (D320 follow-up).
Expand Down
56 changes: 56 additions & 0 deletions apps/desktop/test/open-attachment-video.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import assert from "node:assert/strict";
import { mkdtemp, mkdir, readlink, realpath, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { openableMp4Path } from "../electron/main/open-attachment-video.ts";

test("an extensionless stored MP4 gets an OS-openable name without copying bytes", async (t) => {
const dataDir = await mkdtemp(join(tmpdir(), "pi-mp4-open-"));
t.after(() => rm(dataDir, { recursive: true, force: true }));
const attachments = join(dataDir, "attachments");
await mkdir(attachments);
const hash = "a".repeat(64);
const stored = join(attachments, hash);
await writeFile(stored, Buffer.alloc(512 * 1024 + 1));
const storedReal = await realpath(stored);

const alias = await openableMp4Path(dataDir, storedReal, "video/mp4");
assert.equal(alias, join(dataDir, "openable-attachments", `${hash}.mp4`));
assert.equal(await realpath(alias), storedReal);
assert.equal(await readlink(alias), storedReal);
assert.equal((await stat(alias)).size, 512 * 1024 + 1);
assert.equal(await openableMp4Path(dataDir, storedReal, "video/mp4"), alias);
});

test("only a typed content-addressed MP4 is given an alias", async (t) => {
const dataDir = await mkdtemp(join(tmpdir(), "pi-mp4-open-"));
t.after(() => rm(dataDir, { recursive: true, force: true }));
const file = join(dataDir, "clip.mp4");
await writeFile(file, "video");
const workspace = join(dataDir, "workspace");
await mkdir(workspace);
const hashNamedProjectFile = join(workspace, "b".repeat(64));
await writeFile(hashNamedProjectFile, "project file");

assert.equal(await openableMp4Path(dataDir, file, "video/mp4"), file);
assert.equal(await openableMp4Path(dataDir, file, "text/plain"), file);
assert.equal(await openableMp4Path(dataDir, hashNamedProjectFile, "video/mp4"), hashNamedProjectFile);
});

test("an occupied alias cannot redirect the OS handoff", async (t) => {
const dataDir = await mkdtemp(join(tmpdir(), "pi-mp4-open-"));
t.after(() => rm(dataDir, { recursive: true, force: true }));
const attachments = join(dataDir, "attachments");
const aliases = join(dataDir, "openable-attachments");
await Promise.all([mkdir(attachments), mkdir(aliases)]);
const hash = "c".repeat(64);
const stored = join(attachments, hash);
await writeFile(stored, "video");
await writeFile(join(aliases, `${hash}.mp4`), "different file");

await assert.rejects(
openableMp4Path(dataDir, await realpath(stored), "video/mp4"),
/already occupied/,
);
});
4 changes: 3 additions & 1 deletion apps/desktop/test/transcript-file-chips.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ test("sent user-message file refs render as composer-like chips", () => {
assert.match(transcript, /useOpenChatFileRef/);
assert.match(transcript, /composer-chip-name/);
assert.match(styles, /\.chat-file-chip[\s\S]*?appearance: none/);
assert.match(transcript, /mimeType=\{attachment\.mimeType\}/);
assert.match(transcript, /onOpen\(path, undefined, mimeType\)/);
});

test("a file chip is routed by where the reference resolved, never optimistically", () => {
Expand Down Expand Up @@ -53,7 +55,7 @@ test("a file chip is routed by where the reference resolved, never optimisticall
// The OS handoff is no longer what a chat click does; the channel itself
// stays part of the public IPC surface.
assert.doesNotMatch(hook, /api\.fsOpen\(/);
assert.match(api, /fsOpen: \(path: string\) => invoke\(IPC\.invoke\.fsOpen, \{ path \}\)/);
assert.match(api, /fsOpen: \(path: string, mimeType\?: string\) =>\s*invoke\(IPC\.invoke\.fsOpen, \{ path, mimeType \}\)/);
});

test("a tool row and a tool result row open a file where the message body does", () => {
Expand Down
8 changes: 6 additions & 2 deletions docs/spec/03-runtime/01-ipc-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -1778,8 +1778,12 @@ Renderer IPC kept for the Plan-safe preview facade and URL fallback:
containment as `fs/read`. Never returns non-image bytes. Renderer-only;
not a plugin host API.
- `fs/reveal({path})` → reveal in Finder. Same containment as `fs/read`.
- `fs/open({path})` → open with the OS default application. Same lexical
containment as `fs/read` (without the extra realpath step used by reads).
- `fs/open({path, mimeType?})` → open an existing regular file with the OS
default application. It uses the same realpath containment as `fs/read`,
including rejection of symlink escapes. For a content-addressed
`attachments/<sha256>` blob declared as `video/mp4`, the host creates a
`.mp4` symlink inside its private app-data directory before the OS handoff,
so the extensionless blob has a media association without copying its bytes.
- `fs/resolveRef({ref, sessionId?})` → `FsChatRefResolveResult`
(`{ match: FsChatRefMatch | null }`, the match naming the answering `root`
(`workspace` / `scratch` / `attachments`), the `relativePath` relative to that
Expand Down
5 changes: 5 additions & 0 deletions docs/spec/04-ux/09-interaction-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -1298,6 +1298,11 @@ Project drag/drop follows these patterns:
outside the project has no relative path to copy and says so. The OS default
application is no longer what this click does, though that action stays
reachable from the file view's own context menu.
- In the host `file:` tab, a conversation MP4 that cannot be previewed because
it is binary or exceeds the text preview limit offers **Open with default
application**. This applies to named `.mp4` files and extensionless
attachment blobs carrying `video/mp4` metadata. A failed OS handoff shows an
error; the user can still reveal the contained file in the file manager.
- The same destination rule governs every other surface of the transcript that
names a file, because one opener serves them all: clicking the file path in a
tool row's summary (Read, Write, Edit, fetch) and clicking a path in a tool
Expand Down
22 changes: 22 additions & 0 deletions docs/spec/06-delivery/04-e2e-test-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -8642,6 +8642,7 @@ must keep splitting are covered by `markdown-blocks.test.mjs`.
| Quality (Independent session communication) | E2E-SESSION-independent-top-level-communication, E2E-SESSION-hover-card-model-and-links |
| C — Conversation & stream (Hover card model and links) | E2E-SESSION-hover-card-model-and-links |
| C — Conversation & stream (Chat file references) | E2E-CHAT-shorthand-file-ref-opens-the-matching-file, E2E-CHAT-file-ref-opens-the-surface-that-owns-it |
| C / Quality / Security (Conversation MP4 attachments) | E2E-CHAT-mp4-attachment-opens-in-system-player |
| G — Plugins (Chat file references) | E2E-CHAT-file-ref-opens-the-surface-that-owns-it, E2E-PLUGIN-file-view-collapse-persists |
| Quality (Chat file references) | E2E-CHAT-shorthand-file-ref-opens-the-matching-file, E2E-CHAT-file-ref-opens-the-surface-that-owns-it, E2E-PLUGIN-file-view-collapse-persists |
| G — Plugins (project folder roots) | E2E-PLUGIN-file-view-switches-folder-per-project |
Expand Down Expand Up @@ -8677,6 +8678,7 @@ must keep splitting are covered by `markdown-blocks.test.mjs`.
| M6+ (Windows updater cache) | E2E-260 |
| M6+ (Independent session communication) | E2E-SESSION-independent-top-level-communication, E2E-SESSION-hover-card-model-and-links |
| M5 (Chat file references) | E2E-CHAT-shorthand-file-ref-opens-the-matching-file, E2E-CHAT-file-ref-opens-the-surface-that-owns-it |
| M5 (Conversation MP4 attachments) | E2E-CHAT-mp4-attachment-opens-in-system-player |
| M6+ (Chat file references) | E2E-PLUGIN-file-view-collapse-persists |
| M6+ (project folder roots) | E2E-PLUGIN-file-view-switches-folder-per-project |
| Post-MVP | E2E-022A, E2E-022B, E2E-022C, E2E-024I, E2E-024J, E2E-024K, E2E-024L, E2E-024M (plugin roadmap R2/R3/R6) |
Expand Down Expand Up @@ -11760,6 +11762,26 @@ are withdrawn with ADR 0165.
shape of resolution produces); full UI journey Draft (run only in a capable
environment when this surface changes)

#### E2E-CHAT-mp4-attachment-opens-in-system-player

- **Preconditions**: A conversation has one pasted MP4 in session scratch and
two stored MP4 attachments whose content-addressed refs have no extension:
one larger than 512 KiB and one smaller binary file. All retain `video/mp4`
metadata.
- **Steps**: Click each attachment in the transcript, then choose **Open with
default application** in the host file tab. Repeat with a workspace `.mp4`.
- **Expected**: The large attachment reports that inline preview is unavailable
because of size and the small attachment reports binary content; both offer
the OS-open action. The host validates realpath containment and hands the OS
an `.mp4` alias of the same stored bytes. A file outside allowed roots or a
symlink escape is refused. Failed OS handoff is visible to the user.
- **Specs linked**: `03-runtime/01-ipc-protocol.md` § fs,
`04-ux/09-interaction-patterns.md` §8a.2.
- **Acceptance**: C (conversation & stream), Quality, Security
- **Milestone**: M5
- **Status**: Electron fixture covered for pasted scratch MP4 and both blob sizes
(`test:e2e:composer-paste`); full installed-app/player journey Draft.

#### E2E-181: An imported skill is listed in the next session catalog

- **Preconditions**: Settings > Agent > Skills is open. A conventional
Expand Down
Loading
Loading