}>;
+ const html = await items[0]!.data["text/html"]!.text();
+ expect(html).toContain(rich);
+ expect(html).toContain("data-t3-context-fragment=");
+ });
+
it("reports unavailable clipboard support with structural context", async () => {
vi.stubGlobal("window", {});
vi.stubGlobal("navigator", {});
diff --git a/apps/web/src/hooks/useCopyToClipboard.ts b/apps/web/src/hooks/useCopyToClipboard.ts
index f2ee1af0799f..15fd455c75d7 100644
--- a/apps/web/src/hooks/useCopyToClipboard.ts
+++ b/apps/web/src/hooks/useCopyToClipboard.ts
@@ -116,7 +116,12 @@ export async function writeTextToClipboard(
if (contextFragment)
extraFlavors = {
...extraFlavors,
- "text/html": encodeComposerContextClipboardHtml(value, contextFragment),
+ // A caller that already built rich HTML keeps it; the escaped `` is only a fallback.
+ "text/html": encodeComposerContextClipboardHtml(
+ value,
+ contextFragment,
+ extraFlavors?.["text/html"],
+ ),
};
if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) {
diff --git a/apps/web/src/lib/composerContextRecords.test.ts b/apps/web/src/lib/composerContextRecords.test.ts
index 21d0bf355b55..e5a83030a5fa 100644
--- a/apps/web/src/lib/composerContextRecords.test.ts
+++ b/apps/web/src/lib/composerContextRecords.test.ts
@@ -1,5 +1,7 @@
import {
+ type ComposerContextId,
EnvironmentId,
+ MessageId,
OrchestrationMessageContext,
ThreadId,
type PreviewAnnotationPayload,
@@ -26,6 +28,7 @@ import {
previewAnnotationContextRecord,
previewAnnotationFromRecord,
resolveUserMessageContext,
+ selectedMessageContextFragment,
reviewCommentContextLabel,
reviewCommentContextRecord,
reviewCommentFromRecord,
@@ -483,6 +486,26 @@ describe("composerContextRecords", () => {
expect(isSameComposerContextPayload(first, second)).toBe(false);
});
+ it("treats an imported legacy id and its canonical reconstruction as the same excerpt", () => {
+ const draft = {
+ id: "term-1",
+ threadId: ThreadId.make("t"),
+ createdAt: "2026-01-01T00:00:00.000Z",
+ terminalId: "default",
+ terminalLabel: "Terminal 1",
+ lineStart: 1,
+ lineEnd: 2,
+ text: "A",
+ };
+ const canonical = terminalContextRecord(draft);
+ // An import carries the id it was sent with; the draft rebuilds the folded
+ // canonical form. Same payload either way, so no duplicate entry may form.
+ const imported = { ...canonical, contextId: "legacy_terminal_1" as ComposerContextId };
+
+ expect(isSameComposerContextPayload(canonical, imported)).toBe(true);
+ expect(isSameComposerContextPayload(canonical, { ...canonical, text: "B" })).toBe(false);
+ });
+
it("compares nested annotation element and source payloads", () => {
const base = previewAnnotationContextRecord(annotation);
expect(isSameComposerContextPayload(base, { ...base, label: "Different display label" })).toBe(
@@ -673,7 +696,7 @@ describe("producer ids that do not fit the grammar", () => {
text: "",
diff: "",
});
- expect(record.contextId).toMatch(/^review-comment_pull-request-finding-42-[0-9a-f]{8}$/);
+ expect(record.contextId).toMatch(/^review-comment_pull-request-finding-42-[0-9a-f]{16}$/);
expect(
resolveUserMessageContext({
text: `[b.ts L1](t3-context://v1/review-comment/${record.contextId})`,
@@ -682,3 +705,54 @@ describe("producer ids that do not fit the grammar", () => {
).toBe(true);
});
});
+
+describe("selectedMessageContextFragment", () => {
+ const terminal = terminalContextRecord({
+ id: "term-1",
+ threadId: ThreadId.make("t"),
+ createdAt: "2026-01-01T00:00:00.000Z",
+ terminalId: "default",
+ terminalLabel: "Terminal 1",
+ lineStart: 1,
+ lineEnd: 1,
+ text: "A",
+ });
+ const review = reviewCommentContextRecord({
+ id: "rc-1",
+ sectionId: "s",
+ sectionTitle: "t",
+ filePath: "a/b.ts",
+ startIndex: 3,
+ endIndex: 3,
+ rangeLabel: "L4",
+ text: "",
+ diff: "",
+ });
+ const input = {
+ records: [terminal, review],
+ environmentId: EnvironmentId.make("env"),
+ threadId: ThreadId.make("t"),
+ messageId: MessageId.make("msg-1"),
+ };
+
+ it("carries only records for chips inside the selection", () => {
+ const fragment = selectedMessageContextFragment({
+ ...input,
+ markdown: `see [b.ts L4](t3-context://v1/review-comment/${review.contextId})`,
+ });
+
+ expect(fragment).toContain(review.contextId);
+ expect(fragment).not.toContain(terminal.contextId);
+ });
+
+ it("returns null when no selected chip has backing records", () => {
+ expect(selectedMessageContextFragment({ ...input, markdown: "just prose" })).toBeNull();
+ expect(
+ selectedMessageContextFragment({
+ ...input,
+ records: [],
+ markdown: `[b.ts L4](t3-context://v1/review-comment/${review.contextId})`,
+ }),
+ ).toBeNull();
+ });
+});
diff --git a/apps/web/src/lib/composerContextRecords.ts b/apps/web/src/lib/composerContextRecords.ts
index 8234ad360646..de8bda09254f 100644
--- a/apps/web/src/lib/composerContextRecords.ts
+++ b/apps/web/src/lib/composerContextRecords.ts
@@ -5,9 +5,11 @@ import {
import type {
ComposerContextId,
ComposerContextRecord,
+ EnvironmentId,
FileContextRecord,
ImageContextRecord,
KnownComposerContextRecord,
+ MessageId,
OrchestrationMessageContext,
PreviewAnnotationContextRecord,
PreviewAnnotationPayload,
@@ -16,7 +18,11 @@ import type {
ThreadId,
} from "@t3tools/contracts";
import { upgradeLegacyContextMessage } from "@t3tools/shared/composerContextLegacy";
-import { sanitizeComposerContextLabel } from "@t3tools/shared/composerContextReferences";
+import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard";
+import {
+ collectComposerContextReferences,
+ sanitizeComposerContextLabel,
+} from "@t3tools/shared/composerContextReferences";
import {
type ComposerContextReference,
@@ -340,6 +346,35 @@ export interface ResolvedUserMessageContext {
recordsById: ReadonlyMap;
}
+/**
+ * The clipboard fragment behind a timeline message selection: only records for
+ * chips actually inside the selection travel, so copying prose next to an
+ * image never starts importing that image somewhere else. Returns null when no
+ * selected chip has backing records.
+ */
+export function selectedMessageContextFragment(input: {
+ readonly markdown: string;
+ readonly records: ReadonlyArray;
+ readonly environmentId: EnvironmentId;
+ readonly threadId?: ThreadId;
+ readonly messageId: MessageId;
+}): string | null {
+ const selectedIds = new Set(
+ collectComposerContextReferences(input.markdown).map((occurrence) => occurrence.contextId),
+ );
+ const records = input.records.filter((record) => selectedIds.has(record.contextId));
+ if (records.length === 0) return null;
+ return encodeComposerContextFragment({
+ version: 1,
+ source: {
+ environmentId: input.environmentId,
+ ...(input.threadId ? { threadId: input.threadId } : {}),
+ messageId: input.messageId,
+ },
+ records,
+ });
+}
+
/** A message's canonical text plus records; old messages are upgraded in memory on read. */
export function resolveUserMessageContext(message: {
text: string;
@@ -451,7 +486,10 @@ export function isSameComposerContextPayload(
): boolean {
if (left.kind !== right.kind) return false;
const stableKey = (record: ComposerContextRecord) => {
- const { label: _label, ...rest } = record;
+ // Labels are display text and context ids are folded producer ids: neither
+ // distinguishes excerpts, so an imported legacy id must still match the
+ // canonical id reconstructed for the same payload instead of duplicating it.
+ const { label: _label, contextId: _contextId, ...rest } = record;
const sortDeep = (value: unknown): unknown => {
if (Array.isArray(value)) return value.map(sortDeep);
if (value === null || typeof value !== "object") return value;
diff --git a/apps/web/src/lib/composerContextReferences.test.ts b/apps/web/src/lib/composerContextReferences.test.ts
index b4fc74e3793d..6214294fe17c 100644
--- a/apps/web/src/lib/composerContextReferences.test.ts
+++ b/apps/web/src/lib/composerContextReferences.test.ts
@@ -60,9 +60,18 @@ describe("toComposerContextId", () => {
it("keeps ids that already fit the grammar and folds the rest deterministically", () => {
expect(toComposerContextId("file-comment-1700-1")).toBe("file-comment-1700-1");
const folded = toComposerContextId("pull-request-selection:src/a.ts:4-9");
- expect(folded).toMatch(/^pull-request-selection-src-a-ts-4-9-[0-9a-f]{8}$/);
+ expect(folded).toMatch(/^pull-request-selection-src-a-ts-4-9-[0-9a-f]{16}$/);
expect(toComposerContextId("pull-request-selection:src/a.ts:4-9")).toBe(folded);
expect(toComposerContextId("pull-request-selection:src/b.ts:4-9")).not.toBe(folded);
- expect(toComposerContextId("::")).toMatch(/^ctx-[0-9a-f]{8}$/);
+ expect(toComposerContextId("::")).toMatch(/^ctx-[0-9a-f]{16}$/);
+ });
+ it("tells apart producer ids that agree past the slug's truncation point", () => {
+ // The slug keeps 48 characters, so only the digest distinguishes these two.
+ const shared = `pull-request-finding:${"a".repeat(60)}`;
+ const first = toComposerContextId(`${shared}:1`);
+ const second = toComposerContextId(`${shared}:2`);
+
+ expect(first).not.toBe(second);
+ expect(first.length).toBeLessThanOrEqual(128);
});
});
diff --git a/apps/web/src/lib/composerContextReferences.ts b/apps/web/src/lib/composerContextReferences.ts
index 09b0559adc70..6ca28fc37f11 100644
--- a/apps/web/src/lib/composerContextReferences.ts
+++ b/apps/web/src/lib/composerContextReferences.ts
@@ -19,13 +19,21 @@ export interface ComposerContextReference {
const CONTEXT_ID_PATTERN = /^[a-z0-9_-]{1,128}$/i;
-function fnv1a32(value: string): string {
- let hash = 0x811c9dc5;
+/**
+ * Two independent FNV-1a passes, one forward and one with a different offset basis over the
+ * reversed input. 64 bits of digest, because the slug in front of it is truncated: two long
+ * producer ids that agree on their first 48 characters are told apart by this alone.
+ */
+function fnv1a64(value: string): string {
+ let forward = 0x811c9dc5;
+ let reverse = 0x9dc5811c;
for (let index = 0; index < value.length; index += 1) {
- hash ^= value.charCodeAt(index);
- hash = Math.imul(hash, 0x01000193) >>> 0;
+ forward ^= value.charCodeAt(index);
+ forward = Math.imul(forward, 0x01000193) >>> 0;
+ reverse ^= value.charCodeAt(value.length - 1 - index);
+ reverse = Math.imul(reverse, 0x01000193) >>> 0;
}
- return hash.toString(16).padStart(8, "0");
+ return `${forward.toString(16).padStart(8, "0")}${reverse.toString(16).padStart(8, "0")}`;
}
/**
@@ -40,7 +48,7 @@ export function toComposerContextId(producerId: string): ComposerContextId {
.replace(/[^a-z0-9_-]+/gi, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 48);
- return `${slug || "ctx"}-${fnv1a32(producerId)}` as ComposerContextId;
+ return `${slug || "ctx"}-${fnv1a64(producerId)}` as ComposerContextId;
}
/** Raw producer IDs are always scoped, even when they already start with the kind name. */
diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts
index 48291e981bb0..41ed6560fe5c 100644
--- a/apps/web/src/types.ts
+++ b/apps/web/src/types.ts
@@ -65,22 +65,15 @@ export function isImageAttachment(attachment: ChatAttachment): attachment is Cha
}
export function isFileAttachment(attachment: ChatAttachment): attachment is ChatFileAttachment {
- return attachment.type === "file";
+ // Disjoint from `isImageAttachment` on purpose: a legacy `file` carrying an image reads as a
+ // picture, and callers filter both sets independently, so overlap renders it twice.
+ return attachment.type === "file" && !isImageAttachment(attachment);
}
export function isVideoAttachment(attachment: ChatFileAttachment): boolean {
return videoMimeType(attachment) !== null;
}
-export function isBrowserPreviewAttachment(attachment: ChatFileAttachment): boolean {
- const mimeType = attachment.mimeType.split(";", 1)[0]?.trim().toLowerCase();
- return (
- /\.(?:html?|pdf)$/i.test(attachment.name) ||
- mimeType === "application/pdf" ||
- mimeType === "text/html"
- );
-}
-
export interface ChatMessage extends Omit {
readonly attachments?: ReadonlyArray | undefined;
}
diff --git a/docs/user/composer.md b/docs/user/composer.md
index eacefb61dfa2..dc341afc72d6 100644
--- a/docs/user/composer.md
+++ b/docs/user/composer.md
@@ -126,8 +126,8 @@ comment, picked element, or preview annotation to open its full details. Chips r
excerpt, Terminal 1 lines 3-4" and similar to screen readers.
A pull request appears as its icon and number. Its color reflects whether it was open, draft,
-merged, or closed when it was attached. Hover it to see the title and branches, or select it to
-open that pull request beside the current thread. On web and desktop, type `#` to browse the newest
+merged, or closed when it was attached. Select it to inspect the captured title and branches,
+then choose **Open pull request** to visit the pull request. On web and desktop, type `#` to browse the newest
pull requests in the current project's repository. Continue typing digits to filter the recent list
by any part of its pull request numbers. A complete number is also resolved directly, even when that
pull request is older than the recent list. Type a single word after `#` to search pull requests in
@@ -149,7 +149,21 @@ Markdown with a link in place of each chip. Older messages that were sent before
show their context. Stashing a prompt keeps its chips and what they point to; restoring brings
them back.
-On mobile, chips in sent messages read as their labels.
+On mobile, tap a chip to inspect its content. File references open the current file; attached
+files show the copy that was attached to the message.
+
+## Attached files
+
+Select a file chip in your draft or a sent message to preview it. Code and JSON use syntax
+highlighting; Markdown, HTML, CSV, and TSV offer rendered and raw views. Audio files have
+playback controls. Large text files show a limited preview; save the file to read it in full.
+
+On web and desktop, files open beside the conversation with the same controls as a workspace
+file: a header row with the view toggle, **Copy contents** and **Save file**. On mobile, documents
+open in the same file screen as workspace files; its menu holds **Copy contents**, **Save or
+share** and **Open in file viewer**. Pictures, videos and PDFs keep their native viewers, and
+other document formats such as Word or Pages open in the device's own viewer when it has one.
+If nothing on the device can show a format, save or share it to open it elsewhere.
## Images and videos in messages
@@ -177,4 +191,4 @@ file to source view to read its markup; a link to a specific line opens source
automatically. HTML previews cannot access your T3 Code session.
On mobile, select a PDF attachment or link to open it. iOS uses the native viewer;
-Android opens the system chooser.
+Android opens a compatible installed file viewer.
diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json
index 2a5eb2c7fe93..bf2f9c1be3bd 100644
--- a/packages/client-runtime/package.json
+++ b/packages/client-runtime/package.json
@@ -246,6 +246,10 @@
"./thread-pull-request-compatibility": {
"types": "./src/threadPullRequestCompatibility.ts",
"default": "./src/threadPullRequestCompatibility.ts"
+ },
+ "./file-preview": {
+ "types": "./src/filePreview.ts",
+ "default": "./src/filePreview.ts"
}
},
"scripts": {
diff --git a/packages/client-runtime/src/filePreview.test.ts b/packages/client-runtime/src/filePreview.test.ts
new file mode 100644
index 000000000000..5177e24b372c
--- /dev/null
+++ b/packages/client-runtime/src/filePreview.test.ts
@@ -0,0 +1,67 @@
+import { describe, expect, it } from "vite-plus/test";
+import { FILE_TEXT_PREVIEW_MAX_BYTES } from "@t3tools/shared/filePreview";
+import { readFilePreviewResponse } from "./filePreview.ts";
+
+describe("readFilePreviewResponse", () => {
+ it("cancels a pending read when its preview closes", async () => {
+ const controller = new AbortController();
+ let cancelled = false;
+ const body = new ReadableStream({
+ cancel() {
+ cancelled = true;
+ },
+ });
+ const result = readFilePreviewResponse(new Response(body), controller.signal);
+ controller.abort();
+ await expect(result).rejects.toThrow();
+ expect(cancelled).toBe(true);
+ });
+ it("supports native AbortSignal implementations without throwIfAborted", async () => {
+ const signal = new AbortController().signal;
+ Object.defineProperty(signal, "throwIfAborted", { value: undefined });
+ await expect(readFilePreviewResponse(new Response("native text"), signal)).resolves.toEqual({
+ text: "native text",
+ truncated: false,
+ });
+ });
+ it("reads text and rejects unsuccessful responses", async () => {
+ const signal = new AbortController().signal;
+ await expect(readFilePreviewResponse(new Response('{"ok":true}'), signal)).resolves.toEqual({
+ text: '{"ok":true}',
+ truncated: false,
+ });
+ await expect(
+ readFilePreviewResponse(new Response(null, { status: 403 }), signal),
+ ).rejects.toThrow("could not be loaded");
+ });
+ it("cancels a streamed error body instead of leaving it open", async () => {
+ const signal = new AbortController().signal;
+ let cancelled = false;
+ const body = new ReadableStream({
+ cancel() {
+ cancelled = true;
+ },
+ });
+
+ await expect(
+ readFilePreviewResponse(new Response(body, { status: 500 }), signal),
+ ).rejects.toThrow("could not be loaded");
+ expect(cancelled).toBe(true);
+ });
+
+ it("stops consuming an unbounded response and cancels its stream", async () => {
+ let cancelled = false;
+ const body = new ReadableStream({
+ pull(controller) {
+ controller.enqueue(new Uint8Array(FILE_TEXT_PREVIEW_MAX_BYTES).fill(97));
+ },
+ cancel() {
+ cancelled = true;
+ },
+ });
+ const result = await readFilePreviewResponse(new Response(body), new AbortController().signal);
+ expect(result.text.length).toBe(FILE_TEXT_PREVIEW_MAX_BYTES);
+ expect(result.truncated).toBe(true);
+ expect(cancelled).toBe(true);
+ });
+});
diff --git a/packages/client-runtime/src/filePreview.ts b/packages/client-runtime/src/filePreview.ts
new file mode 100644
index 000000000000..433e253d185c
--- /dev/null
+++ b/packages/client-runtime/src/filePreview.ts
@@ -0,0 +1,44 @@
+import { decodeFilePreviewText, FILE_TEXT_PREVIEW_MAX_BYTES } from "@t3tools/shared/filePreview";
+
+/** Consume only a bounded prefix, even when a host ignores the requested HTTP range. */
+export async function readFilePreviewResponse(
+ response: Pick,
+ signal: AbortSignal,
+) {
+ if (!response.ok) {
+ // A streamed error body holds the connection open until GC otherwise.
+ void response.body?.cancel().catch(() => undefined);
+ throw new Error("The file could not be loaded. Reconnect and try again.");
+ }
+ if (signal.aborted) throw new Error("Preview cancelled.");
+ const limit = FILE_TEXT_PREVIEW_MAX_BYTES + 1;
+ const reader = response.body?.getReader();
+ if (!reader) throw new Error("Streaming file previews are unavailable in this runtime.");
+ const cancel = () => {
+ void reader.cancel().catch(() => undefined);
+ };
+ signal.addEventListener("abort", cancel, { once: true });
+ const chunks: Uint8Array[] = [];
+ let length = 0;
+ try {
+ while (length < limit) {
+ if (signal.aborted) throw new Error("Preview cancelled.");
+ const next = await reader.read();
+ if (next.done) break;
+ const chunk = next.value.subarray(0, limit - length);
+ chunks.push(chunk);
+ length += chunk.length;
+ }
+ } finally {
+ signal.removeEventListener("abort", cancel);
+ await reader.cancel();
+ }
+ if (signal.aborted) throw new Error("Preview cancelled.");
+ const bytes = new Uint8Array(length);
+ let offset = 0;
+ for (const chunk of chunks) {
+ bytes.set(chunk, offset);
+ offset += chunk.length;
+ }
+ return decodeFilePreviewText(bytes);
+}
diff --git a/packages/contracts/src/assets.ts b/packages/contracts/src/assets.ts
index 26638832ca05..6447aab22585 100644
--- a/packages/contracts/src/assets.ts
+++ b/packages/contracts/src/assets.ts
@@ -23,6 +23,12 @@ export const AssetResource = Schema.Union([
threadId: ThreadId,
path: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)),
}),
+ // A workspace file named by a draft that has no thread yet. The draft names
+ // its workspace root explicitly instead of resolving one from a thread.
+ Schema.TaggedStruct("draft-workspace-file", {
+ cwd: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)),
+ path: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)),
+ }),
Schema.TaggedStruct("attachment", {
attachmentId: TrimmedNonEmptyString.check(Schema.isMaxLength(256)),
/** Display name and mime from the `ChatAttachment` the caller holds. The
@@ -174,8 +180,10 @@ export class AssetPreviewTypeValidationError extends Schema.TaggedError {
).toBeNull();
expect(decodeComposerContextClipboardHtml("Ordinary clipboard
")).toBeNull();
});
+ it("accepts a large non-ASCII fragment through the HTML attribute", () => {
+ // `encodeURIComponent` expands each CJK code unit to nine characters, so a fragment well
+ // inside the character limit still produces a much longer attribute.
+ const raw = JSON.stringify({
+ version: 1,
+ source: { environmentId: "env-1" },
+ records: [
+ {
+ version: 1,
+ kind: "terminal",
+ contextId: "terminal",
+ label: "ビルド",
+ terminalId: "main",
+ terminalLabel: "Main",
+ lineStart: 1,
+ lineEnd: 1,
+ text: "日本語".repeat(21_000),
+ },
+ ],
+ });
+
+ const html = encodeComposerContextClipboardHtml("output", raw);
+ // The attribute runs to nearly nine characters per code unit. A bound derived from the
+ // fragment limit has to allow that, or a fragment that encoded cleanly is rejected on the
+ // way back in. This ratio is what the decoder's own bound is sized against.
+ expect(html.length).toBeGreaterThan(raw.length * 8);
+ expect(decodeComposerContextClipboardHtml(html)).toEqual(JSON.parse(raw));
+ });
+
it("round-trips a fragment and drops records it cannot decode", () => {
const encoded = encodeComposerContextFragment({
version: 1,
diff --git a/packages/shared/src/composerContextClipboard.ts b/packages/shared/src/composerContextClipboard.ts
index 77797d0ae34e..654ea2f28681 100644
--- a/packages/shared/src/composerContextClipboard.ts
+++ b/packages/shared/src/composerContextClipboard.ts
@@ -18,6 +18,9 @@ export function encodeComposerContextFragment(
}
/** HTML is the portable flavor shared by browsers and native system clipboards. */
+/** Room for the wrapper element, its attribute name, and the copied HTML itself. */
+const HTML_WRAPPER_SLACK_CHARS = 4096;
+
export function encodeComposerContextClipboardHtml(
text: string,
fragment: string,
@@ -32,7 +35,9 @@ export function encodeComposerContextClipboardHtml(
export function decodeComposerContextClipboardHtml(
html: string | null | undefined,
): ComposerContextClipboardFragment | null {
- if (!html || html.length > MAX_FRAGMENT_CHARS * 4) return null;
+ // `encodeURIComponent` expands one non-ASCII code unit to up to nine characters, so a
+ // fragment just under the limit must still survive the round trip through the attribute.
+ if (!html || html.length > MAX_FRAGMENT_CHARS * 9 + HTML_WRAPPER_SLACK_CHARS) return null;
const encoded = /data-t3-context-fragment=["']([^"']+)["']/.exec(html)?.[1];
if (!encoded) return null;
try {
diff --git a/packages/shared/src/composerContextLegacySend.test.ts b/packages/shared/src/composerContextLegacySend.test.ts
index 1f88e36fb8f7..a6c43904278b 100644
--- a/packages/shared/src/composerContextLegacySend.test.ts
+++ b/packages/shared/src/composerContextLegacySend.test.ts
@@ -125,4 +125,30 @@ describe("serializeLegacyContextMessage", () => {
it("keeps prose without context untouched", () => {
expect(serializeLegacyContextMessage({ text: "just prose", records: [] })).toBe("just prose");
});
+
+ it("appends review comments the text never referenced", () => {
+ const legacy = serializeLegacyContextMessage({ text: "just prose", records: [review] });
+
+ expect(legacy).toContain("Why this branch?");
+ const upgraded = upgradeLegacyContextMessage(legacy);
+ expect(upgraded.records).toHaveLength(1);
+ expect(upgraded.records[0]).toMatchObject({
+ kind: "review-comment",
+ filePath: "a/b.ts",
+ text: "Why this branch?",
+ });
+ });
+
+ it("numbers terminal output within the declared line range", () => {
+ const ranged = {
+ ...terminal,
+ lineStart: 3,
+ lineEnd: 4,
+ text: "boom\nagain\n",
+ };
+ const legacy = serializeLegacyContextMessage({ text: "look", records: [ranged] });
+
+ expect(legacy).toContain("3 | boom\n 4 | again");
+ expect(legacy).not.toContain("5 |");
+ });
});
diff --git a/packages/shared/src/composerContextLegacySend.ts b/packages/shared/src/composerContextLegacySend.ts
index a3a859925be8..de80d242705b 100644
--- a/packages/shared/src/composerContextLegacySend.ts
+++ b/packages/shared/src/composerContextLegacySend.ts
@@ -42,6 +42,9 @@ export function serializeLegacyContextMessage(input: {
...input.records
.filter((record) => record.kind === "preview-annotation")
.map((record) => `\n${renderPreviewBody(record)}\n`),
+ ...input.records
+ .filter((record) => record.kind === "review-comment" && !used.has(record.contextId))
+ .map(renderReviewComment),
].filter((block) => block.length > 0);
// Review comments already inlined their payload above; anything else unreferenced still ships
@@ -81,6 +84,8 @@ function renderTerminalEntry(record: ComposerContextRecord): string {
: `lines ${record.lineStart}-${record.lineEnd}`;
const body = record.text
.split("\n")
+ // A trailing newline would otherwise number a line past the range the label declares.
+ .slice(0, record.lineEnd - record.lineStart + 1)
.map((line, index) => `${record.lineStart + index} | ${line}`)
.join("\n");
return `- ${record.terminalLabel} ${range}:\n${indent(body)}`;
diff --git a/packages/shared/src/composerContextReferences.ts b/packages/shared/src/composerContextReferences.ts
index 36966148153b..23993cb9d407 100644
--- a/packages/shared/src/composerContextReferences.ts
+++ b/packages/shared/src/composerContextReferences.ts
@@ -74,6 +74,9 @@ export function collectComposerContextReferences(
text: string,
): ComposerContextReferenceOccurrence[] {
const occurrences: ComposerContextReferenceOccurrence[] = [];
+ // No link can match without the protocol prefix; skip the scan entirely on
+ // plain prose so long messages never pay for a regex walk per `[`.
+ if (!text.includes("](t3-context:")) return occurrences;
for (const match of text.matchAll(CONTEXT_LINK)) {
const parsed = parseComposerContextHref(match[3]!);
if (!parsed) continue;
diff --git a/packages/shared/src/delimitedPreview.test.ts b/packages/shared/src/delimitedPreview.test.ts
new file mode 100644
index 000000000000..303e9042855c
--- /dev/null
+++ b/packages/shared/src/delimitedPreview.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, it } from "vite-plus/test";
+import { filePreviewDelimiter, parseDelimitedPreview } from "./delimitedPreview.ts";
+
+describe("delimited file previews", () => {
+ it("respects a specific MIME type before the extension", () => {
+ expect(filePreviewDelimiter({ name: "data.CSV", mimeType: "text/plain" })).toBe(",");
+ expect(filePreviewDelimiter({ name: "data.csv", mimeType: "application/pdf" })).toBeNull();
+ });
+ it("preserves quoted separators, escaped quotes, blank cells and line breaks", () => {
+ expect(
+ parseDelimitedPreview('\ufeffname,notes,empty\r\n"A, B","say ""hi""\nagain",\r\n', ","),
+ ).toEqual({
+ rows: [
+ ["name", "notes", "empty"],
+ ["A, B", 'say "hi"\nagain', ""],
+ ],
+ truncated: false,
+ });
+ expect(parseDelimitedPreview("one\ttwo\n\tthree", "\t").rows).toEqual([
+ ["one", "two"],
+ ["", "three"],
+ ]);
+ });
+ it("bounds rows, columns and cell length and reports partial content", () => {
+ const table = parseDelimitedPreview(
+ Array.from({ length: 101 }, () =>
+ Array.from({ length: 31 }, () => "x".repeat(2001)).join(","),
+ ).join("\n"),
+ ",",
+ );
+ expect(table.truncated).toBe(true);
+ expect(table.rows).toHaveLength(100);
+ expect(table.rows[0]).toHaveLength(30);
+ expect(table.rows[0]?.[0]).toHaveLength(2000);
+ expect(parseDelimitedPreview('a,"unfinished', ",").truncated).toBe(true);
+ });
+});
diff --git a/packages/shared/src/delimitedPreview.ts b/packages/shared/src/delimitedPreview.ts
new file mode 100644
index 000000000000..d4e24409a06a
--- /dev/null
+++ b/packages/shared/src/delimitedPreview.ts
@@ -0,0 +1,54 @@
+/** A table preview is bounded independently of the source preview's byte limit. */
+export function filePreviewDelimiter(file: { name: string; mimeType?: string }): "," | "\t" | null {
+ const mime = file.mimeType?.split(";", 1)[0]?.trim().toLowerCase();
+ if (mime === "text/csv") return ",";
+ if (mime === "text/tab-separated-values") return "\t";
+ if (mime && mime !== "text/plain" && mime !== "application/octet-stream") return null;
+ if (/\.csv$/i.test(file.name)) return ",";
+ if (/\.tsv$/i.test(file.name)) return "\t";
+ return null;
+}
+
+/** Preserve quoted delimiters, escaped quotes and multiline cells; raw mode retains all text. */
+export function parseDelimitedPreview(text: string, delimiter: "," | "\t") {
+ const rows: string[][] = [];
+ let row: string[] = [];
+ let cell = "";
+ let quoted = false;
+ let truncated = false;
+ const endCell = () => {
+ if (row.length < 30) row.push(cell);
+ else truncated = true;
+ cell = "";
+ };
+ for (let index = text.charCodeAt(0) === 0xfeff ? 1 : 0; index < text.length; index++) {
+ const char = text[index];
+ if (char === '"') {
+ if (quoted && text[index + 1] === '"') {
+ if (cell.length < 2000) cell += '"';
+ else truncated = true;
+ index++;
+ continue;
+ }
+ if (quoted || cell === "") {
+ quoted = !quoted;
+ continue;
+ }
+ }
+ if (!quoted && (char === delimiter || char === "\n" || char === "\r")) {
+ endCell();
+ if (char !== delimiter) {
+ rows.push(row);
+ row = [];
+ if (char === "\r" && text[index + 1] === "\n") index++;
+ if (rows.length === 100) return { rows, truncated: truncated || index < text.length - 1 };
+ }
+ } else if (cell.length < 2000) cell += char;
+ else truncated = true;
+ }
+ if (cell.length || row.length) {
+ endCell();
+ rows.push(row);
+ }
+ return { rows, truncated: truncated || quoted };
+}
diff --git a/packages/shared/src/filePreview.test.ts b/packages/shared/src/filePreview.test.ts
index 4aa466159af9..34d619e7a782 100644
--- a/packages/shared/src/filePreview.test.ts
+++ b/packages/shared/src/filePreview.test.ts
@@ -1,6 +1,11 @@
import { describe, expect, it } from "vite-plus/test";
import {
+ filePreviewKind,
+ decodeFilePreviewText,
+ FILE_TEXT_PREVIEW_MAX_BYTES,
+ hostPreviewMimeTypeFromExtension,
+ isWorkspaceAudioPreviewPath,
isWorkspaceBrowserPreviewPath,
isWorkspaceImagePreviewPath,
isWorkspacePreviewEntryPath,
@@ -35,6 +40,14 @@ describe("workspace file previews", () => {
expect(isWorkspacePreviewEntryPath(path)).toBe(false);
},
);
+
+ it("serves audio in place from the host like video and browser documents", () => {
+ expect(isWorkspaceAudioPreviewPath("notes/recording.WAV")).toBe(true);
+ expect(isWorkspaceAudioPreviewPath("recording.wav.ts")).toBe(false);
+ expect(hostPreviewMimeTypeFromExtension(".m4a")).toBe("audio/mp4");
+ expect(hostPreviewMimeTypeFromExtension(".mp4")).toBe("video/mp4");
+ expect(hostPreviewMimeTypeFromExtension(".txt")).toBeNull();
+ });
});
describe("media path parsing", () => {
@@ -61,3 +74,33 @@ describe("media path parsing", () => {
expect(isWorkspaceVideoPreviewPath(source)).toBe(literalVideo);
});
});
+
+describe("attachment preview classification", () => {
+ it.each([
+ ["example.json", "application/octet-stream", "text"],
+ ["README.md", "text/plain", "markdown"],
+ ["component.tsx", "", "text"],
+ ["report.pdf", "application/pdf", "pdf"],
+ ["page.HTML", "", "html"],
+ ["recording.mp3", "", "audio"],
+ ["payload", "application/problem+json", "text"],
+ ["archive.zip", "application/zip", "unsupported"],
+ ["misleading.json", "application/pdf", "pdf"],
+ ["misleading.pdf", "application/zip", "unsupported"],
+ ])("classifies %s (%s) as %s", (name, mimeType, expected) => {
+ expect(filePreviewKind({ name, mimeType })).toBe(expected);
+ });
+ it("rejects binary and invalid UTF-8 data", () => {
+ expect(() => decodeFilePreviewText(new Uint8Array([65, 0, 66]))).toThrow("binary");
+ expect(() => decodeFilePreviewText(new Uint8Array([255]))).toThrow("UTF-8");
+ });
+ it("does not corrupt a multi-byte character at the preview boundary", () => {
+ const bytes = new Uint8Array(FILE_TEXT_PREVIEW_MAX_BYTES + 1).fill(97);
+ bytes[FILE_TEXT_PREVIEW_MAX_BYTES - 1] = 0xe2;
+ bytes[FILE_TEXT_PREVIEW_MAX_BYTES] = 0x82;
+ const preview = decodeFilePreviewText(bytes);
+ expect(preview.truncated).toBe(true);
+ expect(preview.text.endsWith("�")).toBe(false);
+ expect(preview.text.length).toBe(FILE_TEXT_PREVIEW_MAX_BYTES - 1);
+ });
+});
diff --git a/packages/shared/src/filePreview.ts b/packages/shared/src/filePreview.ts
index 8de95af7edec..da62d2e689ec 100644
--- a/packages/shared/src/filePreview.ts
+++ b/packages/shared/src/filePreview.ts
@@ -1,5 +1,76 @@
import { videoMimeType } from "./video.ts";
+export type FilePreviewKind =
+ | "image"
+ | "video"
+ | "audio"
+ | "pdf"
+ | "html"
+ | "markdown"
+ | "text"
+ | "unsupported";
+
+/** Content classification is identical for captured attachments and workspace references. */
+export function filePreviewKind(file: {
+ readonly name: string;
+ readonly mimeType?: string;
+}): FilePreviewKind {
+ const mime = file.mimeType?.split(";", 1)[0]?.trim().toLowerCase() ?? "";
+ const name = file.name.toLowerCase();
+ const extension = name.slice(name.lastIndexOf("."));
+ const generic = !mime || mime === "application/octet-stream" || mime === "text/plain";
+ if (mime === "application/pdf") return "pdf";
+ if (mime === "text/html") return "html";
+ if (mime === "text/markdown" || mime === "text/x-markdown") return "markdown";
+ if (mime.startsWith("image/")) return "image";
+ if (mime.startsWith("video/")) return "video";
+ if (mime.startsWith("audio/")) return "audio";
+ if (generic) {
+ if (extension === ".pdf") return "pdf";
+ if (/^\.html?$/.test(extension)) return "html";
+ if (/^\.(md|markdown|mdown|mkd|mdx)$/.test(extension)) return "markdown";
+ const media = mediaMimeTypeFromExtension(extension);
+ if (media?.startsWith("image/")) return "image";
+ if (media?.startsWith("video/")) return "video";
+ if (audioMimeTypeFromExtension(extension) !== null) return "audio";
+ if (
+ /^\.(txt|log|json|jsonc|jsonl|ndjson|yaml|yml|toml|ini|conf|config|env|csv|tsv|xml|css|scss|sass|less|js|jsx|mjs|cjs|ts|tsx|mts|cts|py|pyi|rb|go|rs|swift|kt|kts|java|c|h|cc|cpp|hpp|cs|php|sh|bash|zsh|fish|sql|graphql|gql|vue|svelte|r|lua|ex|exs|erl|hs|clj|dart|diff|patch|lock|properties|gradle)$/.test(
+ extension,
+ ) ||
+ /^(dockerfile|makefile|gemfile|rakefile|license|readme|\.gitignore|\.gitattributes|\.editorconfig|\.env)(\.|$)/i.test(
+ name.split(/[\\/]/).at(-1) ?? "",
+ )
+ )
+ return "text";
+ }
+ if (
+ mime.startsWith("text/") ||
+ /^(application\/(json|.*\+json|xml|.*\+xml|javascript|x-javascript|yaml|x-yaml|toml|sql))$/.test(
+ mime,
+ )
+ )
+ return "text";
+ return "unsupported";
+}
+
+export const FILE_TEXT_PREVIEW_MAX_BYTES = 1024 * 1024;
+
+/** Reject binary data rather than displaying replacement characters as a document. */
+export function decodeFilePreviewText(bytes: Uint8Array, truncated = false) {
+ const bounded = bytes.subarray(0, FILE_TEXT_PREVIEW_MAX_BYTES);
+ if (bounded.some((byte) => byte === 0))
+ throw new Error("This file contains binary data and cannot be shown as text.");
+ const decoder = new TextDecoder("utf-8", { fatal: true });
+ try {
+ return {
+ text: decoder.decode(bounded, { stream: truncated || bytes.length > bounded.length }),
+ truncated: truncated || bytes.length > bounded.length,
+ };
+ } catch {
+ throw new Error("This file is not UTF-8 text. Open it in another app to view its contents.");
+ }
+}
+
export const WORKSPACE_BROWSER_PREVIEW_EXTENSIONS = [".htm", ".html", ".pdf"] as const;
export const WORKSPACE_IMAGE_PREVIEW_EXTENSIONS = [
@@ -30,6 +101,24 @@ const BROWSER_MIME_TYPE_BY_EXTENSION = new Map([
[".pdf", "application/pdf"],
]);
+const AUDIO_MIME_TYPE_BY_EXTENSION = new Map([
+ [".mp3", "audio/mpeg"],
+ [".wav", "audio/wav"],
+ [".ogg", "audio/ogg"],
+ [".oga", "audio/ogg"],
+ [".flac", "audio/flac"],
+ [".aac", "audio/aac"],
+ [".m4a", "audio/mp4"],
+ [".opus", "audio/ogg"],
+ [".aiff", "audio/aiff"],
+]);
+
+/** Audio a player can request inline; the server serves these with byte ranges like video. */
+export function audioMimeTypeFromExtension(extension: string): string | null {
+ if (!/^\.[a-z0-9]+$/i.test(extension)) return null;
+ return AUDIO_MIME_TYPE_BY_EXTENSION.get(extension.toLowerCase()) ?? null;
+}
+
/** Classifies a literal filesystem extension, without URL decoding or suffix removal. */
export function mediaMimeTypeFromExtension(extension: string): string | null {
if (!/^\.[a-z0-9]+$/i.test(extension)) return null;
@@ -39,11 +128,12 @@ export function mediaMimeTypeFromExtension(extension: string): string | null {
);
}
-/** Files the server serves in place from anywhere on its host: media plus browser documents. */
+/** Files the server serves in place from anywhere on its host: media, audio and browser documents. */
export function hostPreviewMimeTypeFromExtension(extension: string): string | null {
if (!/^\.[a-z0-9]+$/i.test(extension)) return null;
return (
mediaMimeTypeFromExtension(extension) ??
+ audioMimeTypeFromExtension(extension) ??
BROWSER_MIME_TYPE_BY_EXTENSION.get(extension.toLowerCase()) ??
null
);
@@ -98,6 +188,11 @@ export function isWorkspaceVideoPreviewPath(path: string): boolean {
return videoMimeType({ name: path, mimeType: "" }) !== null;
}
+export function isWorkspaceAudioPreviewPath(path: string): boolean {
+ const extensionIndex = path.lastIndexOf(".");
+ return extensionIndex >= 0 && audioMimeTypeFromExtension(path.slice(extensionIndex)) !== null;
+}
+
export function isWorkspacePreviewEntryPath(path: string): boolean {
return isWorkspaceBrowserPreviewPath(path) || isWorkspaceImagePreviewPath(path);
}
diff --git a/packages/shared/src/image.ts b/packages/shared/src/image.ts
index bdd4a1d80899..8d3f8cb8c9ec 100644
--- a/packages/shared/src/image.ts
+++ b/packages/shared/src/image.ts
@@ -14,7 +14,7 @@ const IMAGE_MIME_TYPE_BY_EXTENSION = new Map([
const SUPPORTED_IMAGE_MIME_TYPES = new Set(IMAGE_MIME_TYPE_BY_EXTENSION.values());
/** What a picker writes when it did not recognize the file; the name is better evidence. */
-const GENERIC_MIME_TYPES = new Set([
+export const GENERIC_MIME_TYPES = new Set([
"application/octet-stream",
"binary/octet-stream",
"application/unknown",
diff --git a/packages/shared/src/video.test.ts b/packages/shared/src/video.test.ts
index b550033e2987..64e48351e66d 100644
--- a/packages/shared/src/video.test.ts
+++ b/packages/shared/src/video.test.ts
@@ -21,4 +21,9 @@ describe("videoMimeType", () => {
expect(videoMimeType({ name, mimeType: "application/octet-stream" })).toBeNull();
},
);
+
+ it("keeps a declared non-video MIME type instead of inferring from the extension", () => {
+ expect(videoMimeType({ name: "recording.mp4", mimeType: "application/pdf" })).toBeNull();
+ expect(videoMimeType({ name: "recording.mp4", mimeType: "image/png" })).toBeNull();
+ });
});
diff --git a/packages/shared/src/video.ts b/packages/shared/src/video.ts
index 291674c705ca..4e976da5dbad 100644
--- a/packages/shared/src/video.ts
+++ b/packages/shared/src/video.ts
@@ -1,3 +1,5 @@
+import { GENERIC_MIME_TYPES } from "./image.ts";
+
const VIDEO_MIME_TYPE_BY_EXTENSION = new Map([
["avi", "video/x-msvideo"],
["m4v", "video/mp4"],
@@ -17,6 +19,9 @@ export function videoMimeType(attachment: {
}): string | null {
const mimeType = attachment.mimeType.split(";", 1)[0]?.trim().toLowerCase() ?? "";
if (mimeType.startsWith("video/")) return mimeType;
+ // The name is only evidence when nothing recorded what this is. A definite type already
+ // answers the question, and a `.mp4` on a PDF must not override it.
+ if (mimeType !== "" && !GENERIC_MIME_TYPES.has(mimeType)) return null;
const dotIndex = attachment.name.lastIndexOf(".");
return dotIndex < 0
? null
diff --git a/vite.config.ts b/vite.config.ts
index bafc00e6e96d..e26d5d6e653e 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -142,6 +142,7 @@ export default defineConfig({
"apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx",
"apps/mobile/src/features/files/FileMarkdownPreview.tsx",
"apps/mobile/src/features/files/SourceFileSurface.tsx",
+ "apps/mobile/src/features/files/AttachmentFileScreen.tsx",
"apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx",
"apps/mobile/src/features/files/thread-file-navigator-pane.tsx",
"apps/mobile/src/features/home/HomeHeader.tsx",