diff --git a/apps/desktop/src/components/chat/local-files.tsx b/apps/desktop/src/components/chat/local-files.tsx index ee5d0e16..04d48174 100644 --- a/apps/desktop/src/components/chat/local-files.tsx +++ b/apps/desktop/src/components/chat/local-files.tsx @@ -99,10 +99,9 @@ export function useMessageFiles(markdown: string): MessageFiles | null { }); return useMemo(() => { if (!data?.files.length) return null; - const files = runnerId ? data.files.map((f) => (f.image ? { ...f, image: onRunner(f.image, runnerId) } : f)) : data.files; + const files = runnerId ? data.files.map((f) => (f.remote && f.image ? { ...f, image: onRunner(f.image, runnerId) } : f)) : data.files; const pictures = new Map(files.filter((f) => f.image).map((f) => [f.path, f])); - // The runner answers for its own machine; this computer's file manager has nothing to show for its paths. - return { byRef: new Map(files.map((f) => [f.ref, f])), pictures: [...pictures.values()], local: runnerId ? false : data.local }; + return { byRef: new Map(files.map((f) => [f.ref, f])), pictures: [...pictures.values()], local: data.local }; }, [data, runnerId]); } @@ -114,20 +113,24 @@ const SHOW_IN_FOLDER = isMac ? "Show in Finder" : "Show in folder"; const MessageScope = createContext<{ local: boolean; preview: (path: string) => void }>({ local: false, preview: () => {} }); -/** What a click on a file does: it opens the file manager there, or — from another computer — copies the path. */ -function useShowInFolder(): { label: string; icon: LucideIcon; show: (file: ChatFile) => void } { +/** + * What a click on a file does: it opens the file manager there, or — from another computer, or for a file on the chat's + * runner, which this computer's file manager can't show — copies the path. + */ +function useShowInFolder(): (file: ChatFile) => { label: string; icon: LucideIcon; show: () => void } { const { local } = useContext(MessageScope); - const show = useCallback( + return useCallback( (file: ChatFile) => { - if (!local) return void copyText(file.path, "Path copied"); - api.files.reveal(file.path).catch((err) => { - const gone = err instanceof ApiRequestError && err.status === 404; - toast.error(gone ? `${file.name} doesn't exist anymore` : `Couldn't open ${isMac ? "Finder" : "the file manager"}`, gone ? undefined : { description: errorMessage(err) }); - }); + if (!local || file.remote) return { label: "Copy path", icon: Copy, show: () => void copyText(file.path, "Path copied") }; + const show = () => + void api.files.reveal(file.path).catch((err) => { + const gone = err instanceof ApiRequestError && err.status === 404; + toast.error(gone ? `${file.name} doesn't exist anymore` : `Couldn't open ${isMac ? "Finder" : "the file manager"}`, gone ? undefined : { description: errorMessage(err) }); + }); + return { label: SHOW_IN_FOLDER, icon: FolderOpen, show }; }, [local], ); - return local ? { label: SHOW_IN_FOLDER, icon: FolderOpen, show } : { label: "Copy path", icon: Copy, show }; } /** Everything the files of one message share: where they are, and the preview its pictures open in. */ @@ -145,14 +148,14 @@ export function MessageFilesScope({ files, children }: { files: MessageFiles | n /** A file or folder named in a message; `chip` when it was written as code or a bare path. */ export function FileLink({ file, chip, children }: { file: ChatFile; chip?: boolean; children?: ReactNode }) { - const { label, show } = useShowInFolder(); + const { label, show } = useShowInFolder()(file); const Icon = file.kind === "folder" ? Folder : fileIcon(file.image ? "image/" : "", file.name); // The icon stays with the first letter: a long path wraps as a whole, and must not leave its icon behind. const start = typeof children === "string" ? [...children][0] : undefined; const activate = (e: { preventDefault: () => void; stopPropagation: () => void }) => { e.preventDefault(); e.stopPropagation(); - show(file); + show(); }; return ( preview(file.path)}> - show(file)}> + @@ -241,7 +244,7 @@ export function Picture({ file }: { file: ChatFile }) { /** The pictures of a message at full size, one at a time: ← and → step through them. */ function PicturePreview({ pictures, path, onChange }: { pictures: ChatFile[]; path: string | null; onChange: (path: string | null) => void }) { - const { label, icon: ShowIcon, show } = useShowInFolder(); + const showFor = useShowInFolder(); const index = pictures.findIndex((p) => p.path === path); const current = pictures[index]; // While the dialog closes, and while the next picture loads, the last one stays. @@ -267,6 +270,7 @@ function PicturePreview({ pictures, path, onChange }: { pictures: ChatFile[]; pa }, [index, many, step]); if (!file) return null; + const { label, icon: ShowIcon, show } = showFor(file); return ( )} - {!isTauri && file.image && ( diff --git a/packages/core/src/remote/routing.ts b/packages/core/src/remote/routing.ts index 92ee641c..0cbb2bc0 100644 --- a/packages/core/src/remote/routing.ts +++ b/packages/core/src/remote/routing.ts @@ -7,9 +7,13 @@ * only makes sense here (pinning and archiving a chat) stays here. */ import type { Context, MiddlewareHandler } from "hono"; -import { parseRunnerView, type ConversationWithMessages, type Run } from "@godmode/shared"; +import { parseRunnerView, type ChatFile, type ChatFiles, type ConversationWithMessages, type Run } from "@godmode/shared"; import { get, run as sql } from "../db"; import { logger } from "../log"; +import { isLocalRequest } from "../server/auth"; +import { chatFilesBody } from "../server/routes/files"; +import { body } from "../server/validate"; +import { resolveChatFiles } from "../services/chatFiles"; import { conversationExists, deleteConversation, emitConversationUpdated, getConversation, messageWindow, windowConversation } from "../services/conversations"; import { HttpError, badRequest } from "../util"; import { requireLicense } from "../license/license"; @@ -63,6 +67,28 @@ async function jsonBody(c: Context): Promise | null> { } } +/** + * The paths a runner's chat names are looked up on the runner first, then here: its agent may have worked on this + * computer (before the chat moved, or while the runner was away), and an offline runner still leaves its files here. + */ +async function chatFiles(c: Context, runnerId: string, id: string): Promise { + const { messages } = await body(c, chatFilesBody); + let there: ChatFile[][] = []; + try { + const answer = await forward(c, runnerId, pathAndQuery(c), JSON.stringify({ messages }), READ_TIMEOUT_MS); + if (!answer.ok) return answer; + there = ((await answer.json()) as ChatFiles).files; + } catch (err) { + if (!isUnanswered(err)) throw err; + } + const here = resolveChatFiles(id, messages); + const files = messages.map((refs, n) => { + const found = new Map([...(here[n] ?? []), ...(there[n] ?? []).map((f) => ({ ...f, remote: true }))].map((f) => [f.ref, f])); + return [...new Set(refs)].flatMap((ref) => found.get(ref) ?? []); + }); + return c.json({ local: isLocalRequest(c), files }); +} + /** Forward the request as it is (method, path and query, content type, body) and answer with what the runner says. */ async function forward(c: Context, runnerId: string, path: string, body?: Uint8Array | string, timeoutMs = 180_000): Promise { const l = link(runnerId); @@ -186,6 +212,7 @@ async function conversationRequest(c: Context, runnerId: string, id: string, res } return null; } + if (method === "POST" && rest === "/files") return chatFiles(c, runnerId, id); // Pages of older messages and pictures come from the copy here: the runner's chat was just mirrored for its window. if (method === "GET" && rest.startsWith("/messages")) return null; if (!FORWARDED.test(rest)) return null; diff --git a/packages/core/src/server/routes/files.ts b/packages/core/src/server/routes/files.ts index 87a5425b..77d25452 100644 --- a/packages/core/src/server/routes/files.ts +++ b/packages/core/src/server/routes/files.ts @@ -7,10 +7,11 @@ import { isLocalRequest } from "../auth"; import { body, z } from "../validate"; const refs = z.array(z.string().max(1024)).max(MAX_CHAT_FILE_REFS); +export const chatFilesBody = z.object({ messages: z.array(refs).max(200) }); export function registerFileRoutes(app: Hono): void { app.post("/api/conversations/:id/files", async (c) => { - const { messages } = await body(c, z.object({ messages: z.array(refs).max(200) })); + const { messages } = await body(c, chatFilesBody); return c.json({ local: isLocalRequest(c), files: resolveChatFiles(c.req.param("id"), messages) }); }); diff --git a/packages/core/test/chat-files.test.ts b/packages/core/test/chat-files.test.ts index b269e86a..c9842637 100644 --- a/packages/core/test/chat-files.test.ts +++ b/packages/core/test/chat-files.test.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; import type { Agent, ChatFile, ChatFiles, Conversation } from "@godmode/shared"; import { barePaths, fileRefs, isPathLike } from "@godmode/shared"; import { loadConfig } from "../src/config"; -import { closeDb, openDb } from "../src/db"; +import { closeDb, openDb, run as sql } from "../src/db"; import { setLogLevel } from "../src/log"; import { resetSettingsCache } from "../src/services/settings"; import { createApp } from "../src/server/app"; @@ -184,6 +184,20 @@ describe("files of a chat", () => { expect(data.files[0]!.map((f) => f.path)).toEqual([join(folder, "workspace", "shots", "01-positions.png"), join(agent.repoPath, "workspace", "report.pdf")]); }); + test("a chat on a runner that is away still finds the files on this computer", async () => { + const away = (await call("POST", "/api/conversations", { agentId: agent.id })).data; + const now = new Date().toISOString(); + sql("INSERT INTO runners (id, name, public_key, port, kind, created_at, updated_at) VALUES ('rnr_away', 'Other Mac', 'key-away', 7789, 'device', ?, ?)", now, now); + sql("UPDATE conversations SET runner_id = 'rnr_away' WHERE id = ?", away.id); + const { status, data } = await call("POST", `/api/conversations/${away.id}/files`, { messages: [["workspace/shots/", "workspace/report.pdf", "missing.png"]] }); + expect(status).toBe(200); + expect(data.files[0]!.map((f) => [f.path, f.remote])).toEqual([ + [shots, undefined], + [join(agent.repoPath, "workspace", "report.pdf"), undefined], + ]); + expect((await call("POST", `/api/conversations/${away.id}/files`, { messages: "a.png" })).status).toBe(400); + }); + test("only pictures are served", async () => { const image = (await resolveRefs("workspace/shots/01-positions.png"))[0]!.image!; const { status, res } = await call("GET", image); diff --git a/packages/core/test/remote-e2e.test.ts b/packages/core/test/remote-e2e.test.ts index d84ff368..995d1da3 100644 --- a/packages/core/test/remote-e2e.test.ts +++ b/packages/core/test/remote-e2e.test.ts @@ -7,7 +7,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync, chmodSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { applyRunDelta, type Conversation, type Message, type RunDelta, type RunDeltaState, type ServerEvent, type StartChatResult } from "@godmode/shared"; +import { applyRunDelta, type ChatFiles, type Conversation, type Message, type RunDelta, type RunDeltaState, type ServerEvent, type StartChatResult } from "@godmode/shared"; import { loadConfig, config } from "../src/config"; import { all, closeDb, get, getMeta, openDb, setMeta } from "../src/db"; import { bus } from "../src/events/bus"; @@ -202,6 +202,11 @@ describe("a runner, end to end", () => { const viaApi = (await (await api(`/api/conversations/${id}`)).json()) as { runnerId: string; messages: Message[] }; expect(viaApi.runnerId).toBe(runnerId); expect(viaApi.messages.map((m) => m.id)).toContain(started.message.id); + + // The paths its messages name are the runner's first. + const named = join(runnerDir, "fake-claude"); + const files = (await (await api(`/api/conversations/${id}/files`, { method: "POST", body: JSON.stringify({ messages: [[named]] }) })).json()) as ChatFiles; + expect(files.files[0]!.map((f) => [f.path, f.remote])).toEqual([[named, true]]); }, 60_000); test("a turn that failed on the runner is tried again there, from here", async () => { @@ -245,8 +250,11 @@ describe("a runner, end to end", () => { const refused = await api(`/api/conversations/${id}/messages`, { method: "POST", body: JSON.stringify({ content: "Are you there?" }) }); expect(refused.status).toBe(409); expect(((await refused.json()) as { code: string }).code).toBe("runner_offline"); - // The copy here still answers. + // The copy here still answers, and so do the paths its messages name. expect((await api(`/api/conversations/${id}`)).status).toBe(200); + const named = join(runnerDir, "fake-claude"); + const files = (await (await api(`/api/conversations/${id}/files`, { method: "POST", body: JSON.stringify({ messages: [[named]] }) })).json()) as ChatFiles; + expect(files.files[0]!.map((f) => [f.path, f.remote])).toEqual([[named, undefined]]); await startChild(); connectRunner(runnerId); diff --git a/packages/shared/src/files.ts b/packages/shared/src/files.ts index 139ef386..670223c7 100644 --- a/packages/shared/src/files.ts +++ b/packages/shared/src/files.ts @@ -13,6 +13,8 @@ export interface ChatFile { kind: "file" | "folder"; /** Where the core serves the file, when it is a picture the chat can show. */ image: string | null; + /** It is on the chat's runner, not on the core's machine. */ + remote?: boolean; } export interface ChatFiles {