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
46 changes: 46 additions & 0 deletions server/composer-attachments.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";

import {
PASTE_CHARS,
PASTE_LINES,
byteLength,
composeMessage,
isAttachment,
isLongPaste,
pasteAttachment,
pasteSummary,
} from "../src/lib/composer-attachments.ts";

describe("composer paste attachments", () => {
it("classifies long character and line pastes without changing short text", () => {
expect(isLongPaste("x".repeat(PASTE_CHARS - 1))).toBe(false);
expect(isLongPaste("x".repeat(PASTE_CHARS))).toBe(true);
expect(isLongPaste(Array.from({ length: PASTE_LINES }, () => "x").join("\n"))).toBe(true);
});

it("measures UTF-8 once and reports a useful summary", () => {
const attachment = pasteAttachment("héllo\n世界");
expect(attachment.size).toBe(byteLength(attachment.text));
expect(attachment.size).toBeGreaterThan(attachment.text.length);
expect(attachment.lines).toBe(2);
expect(pasteSummary(attachment)).toMatch(/^2 lines, /);
});

it("composes attachment-only and mixed messages in a stable order", () => {
const first = pasteAttachment("first");
const second = pasteAttachment("second");
expect(composeMessage("", [first])).toBe(
'<pasted-text index="1">\nfirst\n</pasted-text>',
);
expect(composeMessage(" intro ", [first, second])).toBe(
'intro\n\n<pasted-text index="1">\nfirst\n</pasted-text>\n\n' +
'<pasted-text index="2">\nsecond\n</pasted-text>',
);
});

it("rejects malformed persisted attachments", () => {
expect(isAttachment({ kind: "paste", id: "a", text: "ok", size: 2, lines: 1 })).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);
});
});
45 changes: 45 additions & 0 deletions server/drafts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";

import {
getDraft,
getDraftAttachments,
setDraft,
setDraftAttachments,
} from "../src/lib/drafts.ts";
import { pasteAttachment } from "../src/lib/composer-attachments.ts";

function memoryStore() {
const values = new Map<string, string>();
return {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => void values.set(key, value),
};
}

describe("composer drafts", () => {
it("keeps text and attachments isolated per bot or room", () => {
const store = memoryStore();
const paste = pasteAttachment("bot paste");
setDraft(store, "bot:one", "hello");
setDraftAttachments(store, "bot:one", [paste]);
setDraft(store, "group:two", "room text");

expect(getDraft(store, "bot:one")).toBe("hello");
expect(getDraftAttachments(store, "bot:one")).toEqual([paste]);
expect(getDraft(store, "group:two")).toBe("room text");
expect(getDraftAttachments(store, "group:two")).toEqual([]);
});

it("clears empty entries and ignores malformed stored attachments", () => {
const store = memoryStore();
setDraft(store, "bot:one", "hello");
setDraft(store, "bot:one", "");
store.setItem(
"omb-draft-attachments",
JSON.stringify({ "bot:one": [{ kind: "paste", id: "broken" }] }),
);

expect(getDraft(store, "bot:one")).toBe("");
expect(getDraftAttachments(store, "bot:one")).toEqual([]);
});
});
50 changes: 41 additions & 9 deletions src/components/Composer.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import { track } from "@/lib/analytics";
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ArrowUp, Clock, Mic, Square, X } from "lucide-react";
import { useStore, visibleMessages, type Bot, type Group } from "@/state/store";
import { cn } from "@/lib/cn";
import { useDraft } from "@/lib/drafts";
import { useComposerDraft } from "@/lib/drafts";
import { MausAvatar } from "./Avatar";
import { ComposerAttachments } from "./ComposerAttachments";
import {
composeMessage,
isLongPaste,
pasteAttachment,
} from "@/lib/composer-attachments";
import { normalizeState } from "@/lib/mascot";
import { PendingApprovalActions, PendingApprovalPanel, pendingApprovals } from "./PendingApproval";
import { useDesktopCapabilities } from "./DesktopCapabilities";
Expand Down Expand Up @@ -50,9 +56,15 @@ export function Composer({
const busyName = group
? (members?.find((b) => b.id === group.busyBotId)?.name ?? "A bot")
: (bot?.name ?? "The bot");
// per-thread draft: switching bots unmounts this component, so the text
// has to outlive it (see lib/drafts)
const [text, setText] = useDraft(group ? `group:${group.id}` : `bot:${bot?.id ?? ""}`);
// Per-thread draft: switching bots unmounts this component, so both the
// text and its attachment chips have to outlive it (see lib/drafts).
const [text, setText, attachments, setAttachments] = useComposerDraft(
group ? `group:${group.id}` : `bot:${bot?.id ?? ""}`,
);
const removeAttachment = useCallback(
(id: string) => setAttachments((prev) => prev.filter((a) => a.id !== id)),
[setAttachments],
);
const [recording, setRecording] = useState(false);
const [speechError, setSpeechError] = useState<string | null>(null);
const [caret, setCaret] = useState(0);
Expand Down Expand Up @@ -103,12 +115,15 @@ export function Composer({
// One message may be queued while the bot works; it auto-sends the moment
// the turn settles. Enter during a turn queues instead of silently dying.
const [queued, setQueued] = useState<string | null>(null);
// a chip on its own is a message: the send control has to appear for it
const hasContent = Boolean(text.trim()) || attachments.length > 0;
const send = () => {
const t = text.trim();
const t = composeMessage(text, attachments);
if (!t) return;
if (busy) {
setQueued(t);
setText("");
setAttachments([]);
return;
}
if (group) {
Expand All @@ -119,6 +134,7 @@ export function Composer({
track("message_sent", { driver: bot.modelSelection?.instanceId });
}
setText("");
setAttachments([]);
};
useEffect(() => {
if (!busy && queued) {
Expand Down Expand Up @@ -236,6 +252,7 @@ export function Composer({
/>
</div>
)}
<ComposerAttachments items={attachments} 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 All @@ -246,6 +263,21 @@ export function Composer({
setCaret(e.target.selectionStart ?? e.target.value.length);
setDismissedAt(null);
}}
onPaste={(e) => {
// a wall of text becomes a chip instead of burying the input
const pasted = e.clipboardData.getData("text/plain");
if (!isLongPaste(pasted)) return;
e.preventDefault();
// Preserve native paste replacement semantics: if text was
// selected, the attachment replaces that selection.
const start = e.currentTarget.selectionStart;
const end = e.currentTarget.selectionEnd;
if (start !== end) {
setText(`${text.slice(0, start)}${text.slice(end)}`);
setCaret(start);
}
setAttachments((prev) => [...prev, pasteAttachment(pasted)]);
}}
onKeyUp={(e) => setCaret((e.target as HTMLTextAreaElement).selectionStart ?? 0)}
onClick={(e) => setCaret((e.target as HTMLTextAreaElement).selectionStart ?? 0)}
onKeyDown={(e) => {
Expand All @@ -268,7 +300,7 @@ export function Composer({
}
}
// an empty composer + ArrowUp = edit your last message (like a chat app)
if (e.key === "ArrowUp" && !text && onEditLast) {
if (e.key === "ArrowUp" && !hasContent && onEditLast) {
e.preventDefault();
onEditLast();
return;
Expand Down Expand Up @@ -308,7 +340,7 @@ export function Composer({
<Square size={14} className="fill-current" />
</button>
)}
{!busy && !text.trim() && capabilities.dictation.available && (
{!busy && !hasContent && capabilities.dictation.available && (
<button
onClick={toggleMic}
aria-label={recording ? "Stop dictation" : "Start dictation"}
Expand All @@ -323,7 +355,7 @@ export function Composer({
<Mic size={18} />
</button>
)}
{text.trim() && (
{hasContent && (
<button
onClick={send}
aria-label={busy ? "Queue message" : "Send message"}
Expand Down
59 changes: 59 additions & 0 deletions src/components/ComposerAttachments.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// 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";
import { cn } from "@/lib/cn";
import { pasteSummary, type Attachment } from "@/lib/composer-attachments";

export function ComposerAttachments({
items,
onRemove,
}: {
items: Attachment[];
onRemove: (id: string) => void;
}) {
if (!items.length) return null;
return (
<div className="mb-2 flex flex-wrap gap-2">
{items.map((a) => (
<PasteChip key={a.id} item={a} onRemove={() => onRemove(a.id)} />
))}
</div>
);
}

function PasteChip({ item, onRemove }: { item: Attachment; onRemove: () => void }) {
const { text } = item;
return (
<div
title={text.slice(0, 4000)}
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>
<div className="mt-1 flex items-center gap-1">
<ClipboardPaste 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
</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"
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} />
</button>
</div>
);
}
74 changes: 74 additions & 0 deletions src/lib/composer-attachments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Text pasted into the composer that is too long to live in the input.
// It rides along as a chip and folds back into the message on send —
// nothing here reaches the server, so every driver still sees a prompt.
export type Attachment = {
kind: "paste";
id: string;
text: string;
size: number;
lines: number;
};

export function isAttachment(value: unknown): value is Attachment {
if (!value || typeof value !== "object") return false;
const attachment = value as Partial<Attachment>;
return (
attachment.kind === "paste" &&
typeof attachment.id === "string" &&
typeof attachment.text === "string" &&
typeof attachment.size === "number" &&
Number.isFinite(attachment.size) &&
attachment.size >= 0 &&
typeof attachment.lines === "number" &&
Number.isInteger(attachment.lines) &&
attachment.lines >= 1
);
}

/** Past this, a paste stops reading as typing and becomes an attachment.
* Long-but-narrow (a stack trace, a log) counts by line, not just chars. */
export const PASTE_CHARS = 900;
export const PASTE_LINES = 12;

export function isLongPaste(text: string): boolean {
return text.length >= PASTE_CHARS || countLines(text) >= PASTE_LINES;
}

export function countLines(text: string): number {
return text.split("\n").length;
}

export function pasteAttachment(text: string): Attachment {
const id = globalThis.crypto?.randomUUID?.() ?? `a${Math.random().toString(36).slice(2)}`;
// measured once, here: a chip re-renders on every keystroke in the
// composer, and encoding half a megabyte each time would be felt
return { kind: "paste", id, text, size: byteLength(text), lines: countLines(text) };
}

/** What the paste actually weighs — String#length counts UTF-16 units, so
* it reads a third under on accented text and half under on CJK. */
export function byteLength(text: string): number {
return new TextEncoder().encode(text).length;
}

/** "12 lines, 3.4 KB" — what the chip says under the preview. */
export function pasteSummary(a: { lines: number; size: number }): string {
return `${a.lines} lines, ${formatSize(a.size)}`;
}

export function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

/** The prompt the bot receives: what was typed, then one block per paste.
* Tagged blocks rather than fences — pasted code and markdown carry fences
* of their own, and nesting them loses the boundary. */
export function composeMessage(text: string, attachments: Attachment[]): string {
const parts = [text.trim()];
attachments.forEach((a, i) => {
parts.push(`<pasted-text index="${i + 1}">\n${a.text}\n</pasted-text>`);
});
return parts.filter(Boolean).join("\n\n");
}
Loading
Loading