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
24 changes: 24 additions & 0 deletions .github/fork-features.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2962,6 +2962,30 @@ features:
- https://github.com/pingdotgg/t3code/pull/15894
retire_when: Upstream or Clerk retries a failed headless Clerk load and bounds stalled Clerk requests on mobile.

- id: mobile-message-image-grid
title: Mobile messages show several images as a grid
status: review-needed
prs: [1175]
invariants:
- A message with one image keeps the large single-image layout; two or more images show as a two-column grid of square tiles, capped at 240px each, that fills the bubble or assistant content width.
- The grid shows at most four tiles; when there are more images the fourth tile is dimmed with a "+N" count of the rest, and tapping any tile opens the full gallery at that image, paging through every image in the message.
- Sent, pending (still uploading), and assistant messages use the same grid, so a pending bubble keeps its layout when the send confirms.
- Non-image file attachments follow the images in a stacked list; files the message text references inline are left out of that list because they already show as chips in the text.
- Images the message text references inline stay in the grid instead of a separate row of small thumbnails.
rationale: Upstream #27373 moves ordinary user message images out of the bubble into a collage that shows every image (two side by side, three in a 3:2 layout, four or more in 96px rows of two), with no tile limit or overflow count; agent-folded and guest messages still show images the text references as 96px thumbnails, and assistant images still stack full width. Upstream web shows user images in a two-column grid. Compared with upstream revision 976a23161d3eeb2738a66993a40e7dd9aa98229a on November 1, 2026; the fork has not taken #27373 yet.
implementation_paths:
- apps/mobile/src/features/threads/ThreadFeed.tsx
- apps/mobile/src/features/threads/messageImageGrid.ts
upstream_paths:
- apps/mobile/src/features/threads/ThreadFeed.tsx
tests:
- apps/mobile/src/features/threads/messageImageGrid.test.ts
upstream:
status: partial
tracking:
- https://github.com/pingdotgg/t3code/pull/27373
retire_when: Upstream mobile keeps messages with many images compact, with a tile limit and overflow count, for sent, pending, and assistant messages, including images the text references.

- id: mobile-notification-types
title: Choose which agent alerts reach the phone
status: maintained
Expand Down
65 changes: 21 additions & 44 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ import {
ThreadId,
type ChatAttachment,
type ChatFileAttachment,
type ChatImageAttachment,
type EnvironmentId,
type MessageId,
type OrchestrationV2ProjectedTurnItem,
Expand All @@ -62,7 +61,6 @@ import { parseThreadLinkHref, resolveThreadLinkRef } from "@t3tools/shared/threa
import { PROMPT_LINK_MAX_PROMPT_LENGTH } from "@t3tools/shared/promptLinks";
import {
parseComposerContextHref,
collectComposerContextReferences,
replaceComposerContextReferences,
formatComposerContextReference,
} from "@t3tools/shared/composerContextReferences";
Expand All @@ -86,7 +84,6 @@ import {
splitCodexArtifactTemplateMarkdown,
} from "@t3tools/shared/codexMarkdownDirectives";
import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
import { imageMimeType } from "@t3tools/shared/image";
import { videoMimeType } from "@t3tools/shared/video";
import {
formatReviewFindingsCopyText,
Expand Down Expand Up @@ -136,6 +133,13 @@ import { useResolveClassNames } from "uniwind";
import { useFilePreviewGallery, FilePreviewGallery } from "../../components/FilePreviewGallery";
import { assetImageSource } from "../../lib/assetImageSource";
import { composerImageGallery } from "../../lib/composerImageGallery";
import {
IMAGE_GRID_GAP,
imageGridTileSize,
isImageAttachment,
messageImageLayout,
userMessageAttachmentLayout,
} from "./messageImageGrid";
import { messageImageGallery } from "./messageImageGallery";
import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
import { isPdfFile } from "../../lib/filePreview";
Expand Down Expand Up @@ -581,47 +585,35 @@ function MessageAttachmentImage(props: {
);
}

const IMAGE_GRID_GAP = 8;
// Phones fill the bubble; this only bounds tiles on tablets, where half a bubble is huge.
const IMAGE_GRID_MAX_TILE = 240;
const IMAGE_GRID_MAX_TILES = 4;

function imageGridTileSize(availableWidth: number) {
return Math.max(
0,
Math.min(IMAGE_GRID_MAX_TILE, Math.floor((availableWidth - IMAGE_GRID_GAP) / 2)),
);
}

// One image keeps its large single layout; several share a 2x2 grid of square tiles, with the
// rest counted on the last tile. The gallery a tile opens still pages through every image.
// The explicit width stops the wrapping row from stretching an auto-sized bubble to its max width.
// The gallery a tile opens still pages through every image, including the hidden ones.
function MessageImages<T extends { readonly id: string }>(props: {
readonly images: readonly T[];
readonly tileSize: number;
readonly radius: number;
readonly className?: string;
readonly renderImage: (image: T, tileSize: number | undefined) => ReactNode;
}) {
const { images, tileSize } = props;
if (images.length === 0) return null;
if (images.length === 1) return props.renderImage(images[0]!, undefined);
const hiddenCount = images.length - IMAGE_GRID_MAX_TILES;
const { tileSize } = props;
const layout = messageImageLayout(props.images);
if (layout.kind === "none") return null;
if (layout.kind === "single") return props.renderImage(layout.image, undefined);
const lastIndex = layout.tiles.length - 1;
return (
<View
className={cn("flex-row flex-wrap", props.className)}
style={{ width: tileSize * 2 + IMAGE_GRID_GAP, gap: IMAGE_GRID_GAP }}
>
{images.slice(0, IMAGE_GRID_MAX_TILES).map((image, index) =>
hiddenCount > 0 && index === IMAGE_GRID_MAX_TILES - 1 ? (
{layout.tiles.map((image, index) =>
layout.hiddenCount > 0 && index === lastIndex ? (
<View key={image.id}>
{props.renderImage(image, tileSize)}
<View
pointerEvents="none"
className="absolute inset-0 items-center justify-center bg-black/55"
style={{ borderRadius: props.radius }}
>
<Text className="font-t3-semibold text-2xl text-white">+{hiddenCount}</Text>
<Text className="font-t3-semibold text-2xl text-white">+{layout.hiddenCount}</Text>
</View>
</View>
) : (
Expand All @@ -632,10 +624,6 @@ function MessageImages<T extends { readonly id: string }>(props: {
);
}

function isImageAttachment(attachment: ChatAttachment): attachment is ChatImageAttachment {
return attachment.type === "image" || imageMimeType(attachment) !== null;
}

function isFileAttachment(attachment: ChatAttachment): attachment is ChatFileAttachment {
return attachment.type === "file";
}
Expand Down Expand Up @@ -1595,22 +1583,11 @@ function renderFeedEntry(
const foldExpanded = foldRowId !== null && (props.expandedWorkRows[foldRowId] ?? false);
const bubbleBodyVisible = foldRowId === null || foldExpanded;
const intent = resolveUserMessageIntent(message.inputIntent);
const referenceIds = new Set(
collectComposerContextReferences(message.text).map((reference) => reference.contextId),
);
const inlineAttachmentIds = new Set(
message.context?.records.flatMap((record) =>
"attachmentId" in record && referenceIds.has(record.contextId)
? [record.attachmentId]
: [],
),
);
// Images stay in the grid even when the text references them; other files referenced
// inline already show as chips in the text.
const gridImages = attachments.filter(isImageAttachment);
const listedAttachments = attachments.filter(
(attachment) => !isImageAttachment(attachment) && !inlineAttachmentIds.has(attachment.id),
);
const { gridImages, listedAttachments } = userMessageAttachmentLayout({
text: message.text,
context: message.context,
attachments,
});
const pendingImages =
entry.pendingMessage?.attachments.filter((attachment) => attachment.type === "image") ?? [];
// The bubble's px-3.5 padding; a grid wider than the bubble's inner width would wrap.
Expand Down
119 changes: 119 additions & 0 deletions apps/mobile/src/features/threads/messageImageGrid.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import type { ChatAttachment, OrchestrationMessageContext } from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";

import {
imageGridTileSize,
messageImageLayout,
userMessageAttachmentLayout,
} from "./messageImageGrid";

const image = (id: string) =>
({
type: "image",
id,
name: `${id}.png`,
mimeType: "image/png",
sizeBytes: 10,
}) as ChatAttachment;
const file = (id: string, mimeType = "application/pdf") =>
({ type: "file", id, name: `${id}.bin`, mimeType, sizeBytes: 10 }) as ChatAttachment;

const binding = (contextId: string, kind: "image" | "file", attachmentId: string) => ({
version: 1,
contextId,
label: attachmentId,
kind,
attachmentId,
name: attachmentId,
mimeType: kind === "image" ? "image/png" : "application/pdf",
sizeBytes: 10,
});
const context = (...records: ReturnType<typeof binding>[]) =>
({ version: 1, records }) as unknown as OrchestrationMessageContext;

const ids = (attachments: readonly ChatAttachment[]) => attachments.map((a) => a.id);

describe("messageImageLayout", () => {
it("keeps a single image in the large layout", () => {
expect(messageImageLayout([])).toEqual({ kind: "none" });
expect(messageImageLayout(["a"])).toEqual({ kind: "single", image: "a" });
});

it("shows two to four images as tiles with nothing hidden", () => {
expect(messageImageLayout(["a", "b"])).toEqual({
kind: "grid",
tiles: ["a", "b"],
hiddenCount: 0,
});
expect(messageImageLayout(["a", "b", "c", "d"])).toEqual({
kind: "grid",
tiles: ["a", "b", "c", "d"],
hiddenCount: 0,
});
});

it("shows the first four images and counts the rest", () => {
expect(messageImageLayout(["a", "b", "c", "d", "e"])).toEqual({
kind: "grid",
tiles: ["a", "b", "c", "d"],
hiddenCount: 1,
});
const many = Array.from({ length: 34 }, (_, index) => `img${index}`);
expect(messageImageLayout(many)).toMatchObject({
tiles: ["img0", "img1", "img2", "img3"],
hiddenCount: 30,
});
});
});

describe("imageGridTileSize", () => {
it("splits the width into two columns around the gap", () => {
expect(imageGridTileSize(300)).toBe(146);
expect(imageGridTileSize(301)).toBe(146);
});

it("caps tiles on wide layouts and never goes negative", () => {
expect(imageGridTileSize(1200)).toBe(240);
expect(imageGridTileSize(4)).toBe(0);
});
});

describe("userMessageAttachmentLayout", () => {
it("puts images in the grid and other files in the list, in order", () => {
const layout = userMessageAttachmentLayout({
text: "here",
attachments: [image("a"), file("f1"), image("b"), file("f2")],
});
expect(ids(layout.gridImages)).toEqual(["a", "b"]);
expect(ids(layout.listedAttachments)).toEqual(["f1", "f2"]);
});

it("treats a file with an image mime type as an image", () => {
const layout = userMessageAttachmentLayout({
text: "",
attachments: [file("shot", "image/jpeg"), file("doc")],
});
expect(ids(layout.gridImages)).toEqual(["shot"]);
expect(ids(layout.listedAttachments)).toEqual(["doc"]);
});

it("keeps images the text references in the grid", () => {
const layout = userMessageAttachmentLayout({
text: "Compare ![a.png](t3-context://v1/image/ctx_a) and ![b.png](t3-context://v1/image/ctx_b)",
context: context(binding("ctx_a", "image", "a"), binding("ctx_b", "image", "b")),
attachments: [image("a"), image("b"), image("c")],
});
expect(ids(layout.gridImages)).toEqual(["a", "b", "c"]);
expect(layout.listedAttachments).toEqual([]);
});

it("leaves files the text references out of the list, since they show as chips", () => {
const layout = userMessageAttachmentLayout({
text: "Read [spec.pdf](t3-context://v1/file/ctx_spec)",
context: context(binding("ctx_spec", "file", "spec"), binding("ctx_other", "file", "other")),
attachments: [file("spec"), file("other")],
});
// `other` has a context record but the text never references it, so it stays listed.
expect(ids(layout.listedAttachments)).toEqual(["other"]);
});
});
63 changes: 63 additions & 0 deletions apps/mobile/src/features/threads/messageImageGrid.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type {
ChatAttachment,
ChatImageAttachment,
OrchestrationMessageContext,
} from "@t3tools/contracts";
import { collectComposerContextReferences } from "@t3tools/shared/composerContextReferences";
import { imageMimeType } from "@t3tools/shared/image";

export const IMAGE_GRID_GAP = 8;
// Phones fill the bubble; this only bounds tiles on tablets, where half a bubble is huge.
const IMAGE_GRID_MAX_TILE = 240;
const IMAGE_GRID_MAX_TILES = 4;

export function imageGridTileSize(availableWidth: number) {
return Math.max(
0,
Math.min(IMAGE_GRID_MAX_TILE, Math.floor((availableWidth - IMAGE_GRID_GAP) / 2)),
);
}

export type MessageImageLayout<T> =
| { readonly kind: "none" }
| { readonly kind: "single"; readonly image: T }
| { readonly kind: "grid"; readonly tiles: readonly T[]; readonly hiddenCount: number };

// One image keeps its large single layout; several share a 2x2 grid of square tiles, with the
// rest counted on the last tile.
export function messageImageLayout<T>(images: readonly T[]): MessageImageLayout<T> {
if (images.length === 0) return { kind: "none" };
if (images.length === 1) return { kind: "single", image: images[0]! };
return {
kind: "grid",
tiles: images.slice(0, IMAGE_GRID_MAX_TILES),
hiddenCount: Math.max(0, images.length - IMAGE_GRID_MAX_TILES),
};
}

export function isImageAttachment(attachment: ChatAttachment): attachment is ChatImageAttachment {
return attachment.type === "image" || imageMimeType(attachment) !== null;
}

// Images stay in the grid even when the text references them; other files referenced inline
// already show as chips in the text, so only the unreferenced ones are listed below the grid.
export function userMessageAttachmentLayout(message: {
readonly text: string;
readonly context?: OrchestrationMessageContext | undefined;
readonly attachments: readonly ChatAttachment[];
}) {
const referenceIds = new Set(
collectComposerContextReferences(message.text).map((reference) => reference.contextId),
);
const inlineAttachmentIds = new Set(
message.context?.records.flatMap((record) =>
"attachmentId" in record && referenceIds.has(record.contextId) ? [record.attachmentId] : [],
),
);
return {
gridImages: message.attachments.filter(isImageAttachment),
listedAttachments: message.attachments.filter(
(attachment) => !isImageAttachment(attachment) && !inlineAttachmentIds.has(attachment.id),
),
};
}
Loading