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
11 changes: 10 additions & 1 deletion electron/preload.cjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Renderer bridge. contextIsolation stays on; the renderer only ever sees
// this narrow surface (window.ogb), never Node or ipcRenderer itself.
const { contextBridge, ipcRenderer } = require("electron");
const { contextBridge, ipcRenderer, webUtils } = require("electron");

contextBridge.exposeInMainWorld("ogb", {
/** Host platform ("darwin" | "win32" | "linux") — for platform-aware UI. */
Expand All @@ -20,6 +20,15 @@ contextBridge.exposeInMainWorld("ogb", {
ipcRenderer.on("speech:end", handler);
return () => ipcRenderer.removeListener("speech:end", handler);
},
/** Absolute path of a dropped File — Electron 32 removed File.path, and
* only the preload can ask. "" when the drag carried no file on disk. */
getPathForFile: (file) => {
try {
return webUtils.getPathForFile(file);
} catch {
return "";
}
},
/** {mic} TCC status strings: granted|denied|not-determined|unknown.
* No screen field — macOS 15+ caches that status per-process, so any
* value here would lie for the whole session after a grant. */
Expand Down
49 changes: 49 additions & 0 deletions server/composer-attachments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import { describe, expect, it } from "vitest";
import {
PASTE_CHARS,
PASTE_LINES,
attachmentsFromDroppedFiles,
byteLength,
composeMessage,
fileAttachment,
isAttachment,
isLongPaste,
pasteAttachment,
Expand Down Expand Up @@ -38,9 +40,56 @@ describe("composer paste attachments", () => {
);
});

it("keeps unusual file paths inside the attachment attribute", () => {
const file = fileAttachment("report.txt", '/tmp/a"&<>\t\n\r.txt', 42);
expect(composeMessage("", [file])).toBe(
'<attached-file path="/tmp/a&quot;&amp;&lt;&gt;&#9;&#10;&#13;.txt" />',
);
});

it("preserves drop order and falls back to small pathless text", async () => {
const dropped = [
{
name: "on-disk.md",
size: 12,
type: "text/markdown",
path: "/tmp/on-disk.md",
text: async () => "not read",
},
{
name: "browser.txt",
size: 7,
type: "text/plain",
path: "",
text: async () => "browser",
},
{
name: "image.png",
size: 10,
type: "image/png",
path: "",
text: async () => "not text",
},
];

const result = await attachmentsFromDroppedFiles(dropped, (file) => file.path);
expect(result.attachments.map((attachment) => attachment.kind)).toEqual(["file", "paste"]);
expect(result.attachments[0]).toMatchObject({
kind: "file",
name: "on-disk.md",
path: "/tmp/on-disk.md",
});
expect(result.attachments[1]).toMatchObject({ kind: "paste", text: "browser" });
expect(result.rejectedNames).toEqual(["image.png"]);
});

it("rejects malformed persisted attachments", () => {
expect(isAttachment({ kind: "paste", id: "a", text: "ok", size: 2, lines: 1 })).toBe(true);
expect(
isAttachment({ kind: "file", id: "f", name: "notes.txt", path: "/tmp/notes.txt", size: 2 }),
).toBe(true);
expect(isAttachment({ kind: "paste", id: "a", text: "missing size" })).toBe(false);
expect(isAttachment({ kind: "file", id: "a", text: "wrong kind", size: 2 })).toBe(false);
expect(isAttachment({ kind: "file", id: "f", name: "empty", path: "", size: 0 })).toBe(false);
});
});
7 changes: 4 additions & 3 deletions server/drafts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
setDraft,
setDraftAttachments,
} from "../src/lib/drafts.ts";
import { pasteAttachment } from "../src/lib/composer-attachments.ts";
import { fileAttachment, pasteAttachment } from "../src/lib/composer-attachments.ts";

function memoryStore() {
const values = new Map<string, string>();
Expand All @@ -20,12 +20,13 @@ describe("composer drafts", () => {
it("keeps text and attachments isolated per bot or room", () => {
const store = memoryStore();
const paste = pasteAttachment("bot paste");
const file = fileAttachment("notes.txt", "/tmp/notes.txt", 12);
setDraft(store, "bot:one", "hello");
setDraftAttachments(store, "bot:one", [paste]);
setDraftAttachments(store, "bot:one", [paste, file]);
setDraft(store, "group:two", "room text");

expect(getDraft(store, "bot:one")).toBe("hello");
expect(getDraftAttachments(store, "bot:one")).toEqual([paste]);
expect(getDraftAttachments(store, "bot:one")).toEqual([paste, file]);
expect(getDraft(store, "group:two")).toBe("room text");
expect(getDraftAttachments(store, "group:two")).toEqual([]);
});
Expand Down
11 changes: 10 additions & 1 deletion src/components/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
composeMessage,
isLongPaste,
pasteAttachment,
type Attachment,
} from "@/lib/composer-attachments";
import { normalizeState } from "@/lib/mascot";
import { PendingApprovalActions, PendingApprovalPanel, pendingApprovals } from "./PendingApproval";
Expand Down Expand Up @@ -61,6 +62,10 @@ export function Composer({
const [text, setText, attachments, setAttachments] = useComposerDraft(
group ? `group:${group.id}` : `bot:${bot?.id ?? ""}`,
);
const addAttachments = useCallback(
(next: Attachment[]) => setAttachments((prev) => [...prev, ...next]),
[setAttachments],
);
const removeAttachment = useCallback(
(id: string) => setAttachments((prev) => prev.filter((a) => a.id !== id)),
[setAttachments],
Expand Down Expand Up @@ -252,7 +257,11 @@ export function Composer({
/>
</div>
)}
<ComposerAttachments items={attachments} onRemove={removeAttachment} />
<ComposerAttachments
items={attachments}
onAdd={addAttachments}
onRemove={removeAttachment}
/>
<div className="flex items-end gap-2 rounded-3xl border border-hairline/40 bg-raised/60 py-2 pl-3 pr-2">
<textarea
ref={inputRef}
Expand Down
169 changes: 145 additions & 24 deletions src/components/ComposerAttachments.tsx
Original file line number Diff line number Diff line change
@@ -1,55 +1,176 @@
// Chips for what is attached to the next message, shown above the input.
// A long paste collapses into a card of its first lines instead of
// flooding the composer with a wall of text.
import { ClipboardPaste, X } from "lucide-react";
// Chips for what is attached to the next message, plus the window-wide
// file drop that creates them. A long paste collapses into a card of its
// first lines instead of flooding the composer; a file dropped anywhere
// on the window attaches by path.
import { useEffect, useRef, useState } from "react";
import { ClipboardPaste, File as FileIcon, X } from "lucide-react";
import { cn } from "@/lib/cn";
import { pasteSummary, type Attachment } from "@/lib/composer-attachments";
import {
attachmentsFromDroppedFiles,
formatSize,
pasteSummary,
type Attachment,
} from "@/lib/composer-attachments";

/** Electron 32 removed File.path — only the preload can name a file. */
function pathForFile(file: File): string {
return window.ogb?.getPathForFile?.(file) ?? "";
}

export function ComposerAttachments({
items,
onAdd,
onRemove,
}: {
items: Attachment[];
onAdd: (attachments: Attachment[]) => void;
onRemove: (id: string) => void;
}) {
if (!items.length) return null;
const [dragging, setDragging] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
// dragenter/dragleave fire once per element crossed, so the overlay
// tracks depth rather than the last event it happened to see
const depth = useRef(0);

useEffect(() => {
let active = true;
const carriesFiles = (e: DragEvent) => Array.from(e.dataTransfer?.types ?? []).includes("Files");

const onEnter = (e: DragEvent) => {
if (!carriesFiles(e)) return;
depth.current += 1;
setDragging(true);
};
const onLeave = (e: DragEvent) => {
if (!carriesFiles(e)) return;
depth.current = Math.max(0, depth.current - 1);
if (depth.current === 0) setDragging(false);
};
// without preventDefault the window navigates to the dropped file and
// the app is simply gone
const onOver = (e: DragEvent) => {
if (carriesFiles(e)) e.preventDefault();
};
const onDrop = async (e: DragEvent) => {
if (!carriesFiles(e)) return;
e.preventDefault();
depth.current = 0;
setDragging(false);
const files = Array.from(e.dataTransfer?.files ?? []);
const { attachments, rejectedNames } = await attachmentsFromDroppedFiles(files, pathForFile);
if (!active) return;
if (attachments.length) onAdd(attachments);
setNotice(
rejectedNames.length
? `${rejectedNames.join(", ")} — that drag carried no file on disk. Save it first, then drop it from Finder.`
: null,
);
};

window.addEventListener("dragenter", onEnter);
window.addEventListener("dragleave", onLeave);
window.addEventListener("dragover", onOver);
window.addEventListener("drop", onDrop);
return () => {
active = false;
window.removeEventListener("dragenter", onEnter);
window.removeEventListener("dragleave", onLeave);
window.removeEventListener("dragover", onOver);
window.removeEventListener("drop", onDrop);
};
}, [onAdd]);

return (
<div className="mb-2 flex flex-wrap gap-2">
{items.map((a) => (
<PasteChip key={a.id} item={a} onRemove={() => onRemove(a.id)} />
))}
</div>
<>
{dragging && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/45 p-10">
<div className="rounded-2xl border-2 border-dashed border-accent/70 bg-panel/90 px-8 py-6 text-[14px] font-medium text-ink shadow-2xl">
Drop to attach — the bot gets the file path
</div>
</div>
)}

{notice && (
<div className="mb-2 flex items-start gap-2 rounded-lg border border-warning/30 bg-warning/10 px-3 py-2 text-[12px] text-warning">
<span className="min-w-0 flex-1">{notice}</span>
<button
onClick={() => setNotice(null)}
aria-label="Dismiss"
className="shrink-0 rounded p-0.5"
>
<X size={12} />
</button>
</div>
)}

{items.length > 0 && (
<div className="mb-2 flex flex-wrap gap-2">
{items.map((a) =>
a.kind === "paste" ? (
<Chip
key={a.id}
label="PASTED"
title={a.text.slice(0, 4000)}
onRemove={() => onRemove(a.id)}
>
<div className="relative h-[76px] overflow-hidden">
<pre className="whitespace-pre-wrap break-words font-mono text-[10.5px] leading-[1.45] text-ink-secondary">
{a.text.slice(0, 400)}
</pre>
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-8 bg-gradient-to-b from-transparent to-raised" />
</div>
<div className="mt-1 text-[10.5px] text-ink-secondary/70">{pasteSummary(a)}</div>
</Chip>
) : (
<Chip key={a.id} label="FILE" title={a.path} onRemove={() => onRemove(a.id)}>
<div className="flex h-[76px] items-center gap-2">
<FileIcon size={16} className="shrink-0 text-ink-secondary" />
<div className="min-w-0">
<div className="truncate text-[12px] text-ink">{a.name}</div>
<div className="text-[10.5px] text-ink-secondary/70">{formatSize(a.size)}</div>
</div>
</div>
</Chip>
),
)}
</div>
)}
</>
);
}

function PasteChip({ item, onRemove }: { item: Attachment; onRemove: () => void }) {
const { text } = item;
function Chip({
children,
label,
title,
onRemove,
}: {
children: React.ReactNode;
label: "PASTED" | "FILE";
title: string;
onRemove: () => void;
}) {
const Icon = label === "PASTED" ? ClipboardPaste : FileIcon;
return (
<div
title={text.slice(0, 4000)}
title={title}
className={cn(
"group relative w-[172px] rounded-xl border border-hairline/40 bg-raised px-2.5 py-2",
"transition-colors hover:border-hairline",
)}
>
<div className="relative h-[76px] overflow-hidden">
<pre className="whitespace-pre-wrap break-words font-mono text-[10.5px] leading-[1.45] text-ink-secondary">
{text.slice(0, 400)}
</pre>
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-8 bg-gradient-to-b from-transparent to-raised" />
</div>
<div className="mt-1 text-[10.5px] text-ink-secondary/70">{pasteSummary(item)}</div>
{children}
<div className="mt-1 flex items-center gap-1">
<ClipboardPaste size={11} className="text-ink-secondary/70" />
<Icon size={11} className="text-ink-secondary/70" />
<span className="rounded border border-hairline/60 px-1 py-px text-[9.5px] font-medium tracking-wide text-ink-secondary">
PASTED
{label}
</span>
</div>
{/* hover reveals it, but so must focus: `hidden` would take the only
way to drop a chip out of reach of the keyboard */}
<button
onClick={onRemove}
aria-label="Remove pasted text"
aria-label={`Remove ${label === "PASTED" ? "pasted text" : "file"}`}
className="absolute -right-1.5 -top-1.5 flex size-5 items-center justify-center rounded-full border border-hairline/60 bg-panel text-ink-secondary opacity-0 transition-opacity hover:text-ink focus-visible:opacity-100 group-hover:opacity-100"
>
<X size={11} />
Expand Down
Loading
Loading