Skip to content
Open
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
13 changes: 8 additions & 5 deletions apps/web/src/chat/ChatView.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type {
AskUserQuestionResult,
ImageContent,
PromptHit,
SlashCommandInfo,
TemplateInfo,
Expand Down Expand Up @@ -45,7 +44,7 @@ import { stripFrontmatter } from "./templateText";
import { useModelCatalog } from "./useModelCatalog";
import "./tools/register"; // side-effect: register the built-in pi tool renderers (bash/read/edit/write)
import { ChatTurnView } from "./turns";
import type { ChatTurn } from "./types";
import type { ChatAttachment, ChatTurn } from "./types";
import { useChatScroll } from "./useChatScroll";
import { useChatTodos } from "./useChatTodos";
import { useHistorySearch } from "./useHistorySearch";
Expand Down Expand Up @@ -359,8 +358,10 @@ export default function ChatView({
.catch(() => {});
};

const onSubmit = (text: string, images: ImageContent[], behavior: SubmitBehavior) => {
if (text) useAppStore.getState().appendUserMessage(sessionId, text);
const onSubmit = (text: string, attachments: ChatAttachment[], behavior: SubmitBehavior) => {
if (text || attachments.length > 0)
useAppStore.getState().appendUserMessage(sessionId, text, attachments);
const images = attachments.map((a) => a.content);
const params = { sessionId, text, ...(images.length > 0 ? { images } : {}) };
const method =
behavior === "steer"
Expand Down Expand Up @@ -629,7 +630,9 @@ export default function ChatView({
data={rows}
context={listContext}
components={CHAT_LIST_COMPONENTS}
className="min-h-0 flex-1"
// `overflow-x-hidden` on the scroller: the chat only ever scrolls vertically — wide
// content (code, diffs) scrolls inside its own block, never the whole transcript.
className="min-h-0 flex-1 overflow-x-hidden"
// Any chat opens at the latest message (a fresh mount would otherwise land mid-transcript);
// the jump-to-message deep link overrides post-mount with its centered scrollToIndex.
initialTopMostItemIndex={{ index: Math.max(rows.length - 1, 0), align: "end" }}
Expand Down
52 changes: 22 additions & 30 deletions apps/web/src/chat/Composer.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,4 @@
import type {
ImageContent,
SlashCommandInfo,
ThinkingLevel,
WireModel,
} from "@thinkrail/contracts";
import type { SlashCommandInfo, ThinkingLevel, WireModel } from "@thinkrail/contracts";
import { ArrowUp, FileIcon, FolderIcon, History, Sparkles, Square, X } from "lucide-react";
import {
type ClipboardEvent,
Expand All @@ -17,6 +12,7 @@ import {
useRef,
useState,
} from "react";
import { type AttachedImage, fileToAttachedImage } from "./imageAttachment";
import { ModelSelector } from "./ModelSelector";
import {
SlashCommandMenu,
Expand All @@ -33,6 +29,7 @@ import {
stripUntouchedSlots,
} from "./slotSession";
import { ThinkingSelector } from "./ThinkingSelector";
import type { ChatAttachment } from "./types";

/** How a submit is delivered: a fresh turn, an interrupt, or a queued message after the current turn. */
export type SubmitBehavior = "send" | "steer" | "followUp";
Expand All @@ -44,26 +41,10 @@ export interface MentionCandidate {
kind: "file" | "dir";
}

interface PendingImage {
interface PendingImage extends AttachedImage {
id: string;
content: ImageContent;
}

function fileToImageContent(file: File): Promise<ImageContent> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(reader.error ?? new Error("failed to read image"));
reader.onload = () => {
const result = String(reader.result);
const comma = result.indexOf(",");
resolve({
type: "image",
data: comma >= 0 ? result.slice(comma + 1) : result,
mimeType: file.type || "image/png",
});
};
reader.readAsDataURL(file);
});
/** The picked file's name — the chip label (pi's `ImageContent` itself carries no filename). */
name: string;
}

/** The token (non-whitespace run) ending at the caret — drives `@`-mention completion. */
Expand Down Expand Up @@ -161,7 +142,7 @@ interface ComposerProps {
onSlashActive: (active: boolean) => void;
onSelectModel: (model: WireModel) => void;
onSelectThinking: (level: ThinkingLevel) => void;
onSubmit: (text: string, images: ImageContent[], behavior: SubmitBehavior) => void;
onSubmit: (text: string, attachments: ChatAttachment[], behavior: SubmitBehavior) => void;
onAbort: () => void;
/** Opens the history-recall overlay (`ChatView` seeds it with the current draft) — the history button
* and the shell's global `Ctrl+R`, via the `openHistory` handle. Optional so a standalone/storybook-style
Expand Down Expand Up @@ -344,7 +325,7 @@ export const Composer = forwardRef<ComposerHandle, ComposerProps>(function Compo
if (!text && images.length === 0) return;
onSubmit(
text,
images.map((i) => i.content),
images.map(({ name, content }) => ({ name, content })),
behavior,
);
onChange("");
Expand Down Expand Up @@ -426,10 +407,17 @@ export const Composer = forwardRef<ComposerHandle, ComposerProps>(function Compo
const addFiles = async (files: File[]) => {
const picked = files.filter((f) => f.type.startsWith("image/"));
if (picked.length === 0) return;
const contents = await Promise.all(picked.map(fileToImageContent));
// Downscaled at attach time (≤1568px long edge) — an oversized image in history 400s every
// subsequent turn once the provider's many-image cap kicks in. See imageAttachment.ts.
const attached = await Promise.all(picked.map(fileToAttachedImage));
setImages((prev) => [
...prev,
...contents.map((content) => ({ id: crypto.randomUUID(), content })),
...attached.map((a, i) => ({
id: crypto.randomUUID(),
// A clipboard paste often arrives as a generic "image.png" — still better than a mime type.
name: picked[i]?.name || "image",
...a,
})),
]);
};

Expand Down Expand Up @@ -657,9 +645,13 @@ export const Composer = forwardRef<ComposerHandle, ComposerProps>(function Compo
{images.map((img) => (
<span
key={img.id}
data-testid="composer-image"
data-width={img.width}
data-height={img.height}
className="flex items-center gap-xs rounded-[var(--radius-sm)] border border-border-default bg-container-elevated-bg px-sm py-xs text-text-default tr-text-metadata"
>
<FileIcon className="size-3" /> {img.content.mimeType}
<FileIcon className="size-3" /> {img.name}
{img.width && img.height ? ` · ${img.width}×${img.height}` : null}
<button
type="button"
aria-label="Remove image"
Expand Down
16 changes: 14 additions & 2 deletions apps/web/src/chat/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,14 @@ from their `toolCall` args and reply through **`ChatActions`** (see below). Work
transcript's last message is in view without scrolling.
- **Composer & chrome** — `Composer` (prompt field + send/steer/followUp/abort, `@`-mentions, `/`
commands + template **slot sessions** (Tab-through placeholders — see the Template slots bullet
below), image paste/drop, `openHistory` on its imperative handle → `onHistoryOpen`) plus its props-driven **slash-completion
below), image paste/drop — routed through **`imageAttachment.ts`**: `fileToAttachedImage` decodes in
the browser and downscales anything over a **1568px long edge** (`fitWithin`; Claude's standard-tier
edge — an oversized image in history 400s every later turn once the provider's >20-image 2000px cap
kicks in, and pi's own resizer is deliberately off server-side), within-bounds images pass through
byte-identical, undecodable files fall back to raw (the server's `imageGuard` extension is the second
line of defense), and the pending chip shows `mime · W×H` (`composer-image` testid +
`data-width`/`data-height` — the `e2e/composer-images.spec.ts` hooks) — and `openHistory` on its
imperative handle → `onHistoryOpen`) plus its props-driven **slash-completion
primitive** (filter/menu/caret + Up/Down, Enter/Tab, Escape), reused by `panels/NewWorkspaceDialog` so
the two inputs cannot drift; `HistoryOverlay` (the history-recall/search overlay `Composer` opens —
presentational, driven entirely by `useHistorySearch.ts`'s state + callbacks, plus a **Save as
Expand Down Expand Up @@ -286,7 +293,12 @@ from their `toolCall` args and reply through **`ChatActions`** (see below). Work
`AgentSession.prompt()` substitutes args into `expandedText` before persisting the `role: "user"`
message, so a `session.getMessages` re-fetch (a reload, or reopening from history) shows the expanded
text. The one nuance: the web client's own immediate bubble is an **optimistic echo**
(`ChatView.onSubmit` → `appendUserMessage`, store-only, appended *before* the transport call resolves) —
(`ChatView.onSubmit` → `appendUserMessage`, store-only, appended *before* the transport call resolves;
attached images ride along as content blocks so the bubble shows them — `UserTurn` renders image blocks
as compact "attached file" chips above the text (no inline preview; click opens the image in a dialog,
the diagram-fullscreen pattern). The chip label is the picked file's name, carried on the echo turn as
`attachmentNames` (UI-side only — pi's `ImageContent` has no filename), index-aligned with the image
blocks; a hydrated turn has no names and falls back to mime-type labels) —
it shows exactly what was typed (the raw command) until a re-fetch replaces it with pi's real persisted
record. **The `/` menu merge**
(`ChatView`): pi's `commands` snapshot (`session.getCommands`, frozen at session-create time) minus its
Expand Down
41 changes: 41 additions & 0 deletions apps/web/src/chat/imageAttachment.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, expect, test } from "bun:test";
import { fitWithin, MAX_ATTACHMENT_EDGE } from "./imageAttachment";

// The pure dimension math behind the composer's attach-time downscale (TASK-image-attachment-downscale):
// images are capped at MAX_ATTACHMENT_EDGE (1568px — Claude's own standard-tier long edge; anything
// larger is downsampled provider-side anyway and only risks the 2000px many-image 400). The browser
// decode/re-encode half lives in `fileToAttachedImage` and is exercised end-to-end by
// `e2e/composer-images.spec.ts` (bun's DOM has no real image codec).

describe("fitWithin", () => {
test("returns the input unchanged when both edges are within the limit", () => {
expect(fitWithin(800, 600, 1568)).toEqual({ width: 800, height: 600 });
expect(fitWithin(1568, 1568, 1568)).toEqual({ width: 1568, height: 1568 });
});

test("scales a landscape image down to the long edge, preserving aspect", () => {
expect(fitWithin(3136, 1568, 1568)).toEqual({ width: 1568, height: 784 });
expect(fitWithin(4000, 3000, 1568)).toEqual({ width: 1568, height: 1176 });
});

test("scales a portrait image down to the long edge, preserving aspect", () => {
expect(fitWithin(1568, 3136, 1568)).toEqual({ width: 784, height: 1568 });
expect(fitWithin(3000, 4000, 1568)).toEqual({ width: 1176, height: 1568 });
});

test("rounds fractional results to whole pixels", () => {
const { width, height } = fitWithin(3023, 1701, 1568);
expect(width).toBe(1568);
expect(height).toBe(882); // 1701 * (1568 / 3023) = 882.16…
});

test("never collapses an extreme aspect ratio to zero", () => {
const { width, height } = fitWithin(100_000, 10, 1568);
expect(width).toBe(1568);
expect(height).toBeGreaterThanOrEqual(1);
});

test("the exported default edge is Claude's 1568px standard-tier long edge", () => {
expect(MAX_ATTACHMENT_EDGE).toBe(1568);
});
});
96 changes: 96 additions & 0 deletions apps/web/src/chat/imageAttachment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// Attach-time image downscale (TASK-image-attachment-downscale). Pasted/dropped images are decoded and
// resized in the browser BEFORE they become ImageContent: pi's own resizer is deliberately disabled
// server-side (`images.autoResize:false` — its photon/WASM codec can't ship in the single-file binary),
// so anything the composer lets through goes to the provider verbatim. Anthropic rejects a side over
// 8000px — and over 2000px once a request carries more than 20 images — and because history is re-sent
// every turn, ONE oversized attachment bricks the whole chat. Capping at 1568px (Claude's standard-tier
// long edge, beyond which it downsamples anyway) stays under every limit while losing nothing the model
// would actually see. The server-side `imageGuard` extension is the second line of defense for images
// that predate this or arrive by other routes.

import type { ImageContent } from "@thinkrail/contracts";

/** Claude's standard-tier long-edge; larger images are downsampled provider-side anyway. */
export const MAX_ATTACHMENT_EDGE = 1568;

/** A composer attachment: the wire content plus the pixel size of what will actually be sent.
* `width`/`height` are undefined only when the browser couldn't decode the file (sent raw as-is). */
export interface AttachedImage {
content: ImageContent;
width?: number;
height?: number;
}

/** Aspect-preserving fit of `width`×`height` into a `maxEdge` square: unchanged when already within,
* else scaled so the long edge equals `maxEdge` (rounded, floored at 1px). */
export function fitWithin(
width: number,
height: number,
maxEdge: number,
): { width: number; height: number } {
const longEdge = Math.max(width, height);
if (longEdge <= maxEdge) return { width, height };
const scale = maxEdge / longEdge;
return {
width: Math.max(1, Math.round(width * scale)),
height: Math.max(1, Math.round(height * scale)),
};
}

/** Mime types canvas.toDataURL can (re-)encode; anything else re-encodes as PNG when resized. */
const CANVAS_ENCODABLE = new Set(["image/png", "image/jpeg", "image/webp"]);

function fileToRawContent(file: File): Promise<ImageContent> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(reader.error ?? new Error("failed to read image"));
reader.onload = () => {
const result = String(reader.result);
const comma = result.indexOf(",");
resolve({
type: "image",
data: comma >= 0 ? result.slice(comma + 1) : result,
mimeType: file.type || "image/png",
});
};
reader.readAsDataURL(file);
});
}

function dataUrlToContent(dataUrl: string): ImageContent {
const comma = dataUrl.indexOf(",");
const mimeType = /^data:([^;,]+)/.exec(dataUrl)?.[1] ?? "image/png";
return { type: "image", data: comma >= 0 ? dataUrl.slice(comma + 1) : dataUrl, mimeType };
}

/**
* Turn a picked/pasted/dropped file into a composer attachment, downscaled to MAX_ATTACHMENT_EDGE when
* its long edge exceeds it. Within-bounds images pass through byte-identical (no re-encode, no quality
* loss); a resized one re-encodes in its own format when canvas supports it, else PNG. A file the
* browser can't decode falls back to the raw bytes — attaching must never fail here; the server-side
* guard still protects the session.
*/
export async function fileToAttachedImage(file: File): Promise<AttachedImage> {
let bitmap: ImageBitmap;
try {
bitmap = await createImageBitmap(file);
} catch {
return { content: await fileToRawContent(file) };
}
try {
const { width, height } = fitWithin(bitmap.width, bitmap.height, MAX_ATTACHMENT_EDGE);
if (width === bitmap.width && height === bitmap.height) {
return { content: await fileToRawContent(file), width, height };
}
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) return { content: await fileToRawContent(file) };
ctx.drawImage(bitmap, 0, 0, width, height);
const mimeType = CANVAS_ENCODABLE.has(file.type) ? file.type : "image/png";
return { content: dataUrlToContent(canvas.toDataURL(mimeType)), width, height };
} finally {
bitmap.close();
}
}
22 changes: 22 additions & 0 deletions apps/web/src/chat/rows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,22 @@ function user(id: string, timestamp = 0): ChatTurn {
return { kind: "user", id, message: { role: "user", content: "hi", timestamp } } as ChatTurn;
}

function userWithAttachment(id: string, names: string[]): ChatTurn {
return {
kind: "user",
id,
message: {
role: "user",
content: [
{ type: "text", text: "hi" },
...names.map(() => ({ type: "image" as const, data: "AA==", mimeType: "image/png" })),
],
timestamp: 0,
},
attachmentNames: names,
} as ChatTurn;
}

function assistant(
id: string,
blocks: Block[],
Expand Down Expand Up @@ -116,6 +132,12 @@ describe("deriveRows grouping", () => {
expect(rows[4]?.id).toBe("q1");
});

test("a user turn's attachmentNames pass through to its row (echo-only; hydrated turns carry none)", () => {
const rows = deriveRows([userWithAttachment("u1", ["shot.png"]), user("u2")], {}, false);
expect(rows[0]?.kind === "user" ? rows[0].attachmentNames : null).toEqual(["shot.png"]);
expect(rows[1]?.kind === "user" ? "attachmentNames" in rows[1] : null).toBe(false);
});

test("non-assistant turns (user/system/error/retry) break runs and map 1:1", () => {
const turns: ChatTurn[] = [
user("u1"),
Expand Down
9 changes: 7 additions & 2 deletions apps/web/src/chat/rows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export type ActivityStep =
* so they double as Virtuoso item keys and fold-state cache keys.
*/
export type ChatRow =
| { kind: "user"; id: string; message: UserMessage }
| { kind: "user"; id: string; message: UserMessage; attachmentNames?: string[] }
| { kind: "system"; id: string; text: string }
| { kind: "error"; id: string; text: string }
| {
Expand Down Expand Up @@ -128,7 +128,12 @@ export function deriveRows(
flushRun();
switch (turn.kind) {
case "user":
rows.push({ kind: "user", id: turn.id, message: turn.message });
rows.push({
kind: "user",
id: turn.id,
message: turn.message,
...(turn.attachmentNames ? { attachmentNames: turn.attachmentNames } : {}),
});
break;
case "system":
rows.push({ kind: "system", id: turn.id, text: turn.text });
Expand Down
Loading
Loading