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
40 changes: 22 additions & 18 deletions apps/desktop/src/components/chat/local-files.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
}

Expand All @@ -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. */
Expand All @@ -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 (
<span
Expand Down Expand Up @@ -200,7 +203,7 @@ const NAME_END = 9;
/** A picture a message names, right in the chat: a click (or its button) opens the preview. */
export function Picture({ file }: { file: ChatFile }) {
const { preview } = useContext(MessageScope);
const { label, icon: ShowIcon, show } = useShowInFolder();
const { label, icon: ShowIcon, show } = useShowInFolder()(file);
const blob = useCoreFileUrl(file.image ?? undefined, true);
if (blob.failed) {
return (
Expand Down Expand Up @@ -231,7 +234,7 @@ export function Picture({ file }: { file: ChatFile }) {
<TileAction label="Preview" onClick={() => preview(file.path)}>
<Maximize2 />
</TileAction>
<TileAction label={label} onClick={() => show(file)}>
<TileAction label={label} onClick={show}>
<ShowIcon />
</TileAction>
</span>
Expand All @@ -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.
Expand All @@ -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 (
<Lightbox
src={src}
Expand All @@ -292,7 +296,7 @@ function PicturePreview({ pictures, path, onChange }: { pictures: ChatFile[]; pa
</Button>
</>
)}
<Button type="button" variant="ghost" size="sm" className="h-7 gap-1.5 px-2.5 text-xs" onClick={() => show(file)}>
<Button type="button" variant="ghost" size="sm" className="h-7 gap-1.5 px-2.5 text-xs" onClick={show}>
<ShowIcon className="size-3.5" /> {label}
</Button>
{!isTauri && file.image && (
Expand Down
29 changes: 28 additions & 1 deletion packages/core/src/remote/routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -63,6 +67,28 @@ async function jsonBody(c: Context): Promise<Record<string, unknown> | 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<Response> {
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<ChatFiles>({ 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<Response> {
const l = link(runnerId);
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/server/routes/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ChatFiles>({ local: isLocalRequest(c), files: resolveChatFiles(c.req.param("id"), messages) });
});

Expand Down
16 changes: 15 additions & 1 deletion packages/core/test/chat-files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<Conversation>("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<ChatFiles>("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);
Expand Down
12 changes: 10 additions & 2 deletions packages/core/test/remote-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading