Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
38 changes: 36 additions & 2 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import {
collectComposerContextReferences,
replaceComposerContextReferences,
formatComposerContextReference,
isAttachmentOnlyBootstrapText,
} from "@t3tools/shared/composerContextReferences";
import { ComposerContextSheet } from "../../components/ComposerContextSheet";
import { writeComposerContextClipboard } from "../../lib/composerContextClipboard";
Expand Down Expand Up @@ -1361,6 +1362,8 @@ function renderFeedEntry(
if (isUser) {
const enterAnimated = isFreshTimestamp(message.createdAt);
const intentBadge = resolveUserMessageIntentBadge(message.inputIntent);
// The bootstrap prompt is for the agent, not the reader: the bubble shows only the attachments.
const attachmentOnly = isAttachmentOnlyBootstrapText(message.text);
const referenceIds = new Set(
collectComposerContextReferences(message.text).map((reference) => reference.contextId),
);
Expand Down Expand Up @@ -1467,7 +1470,13 @@ function renderFeedEntry(
})}
</View>
) : null}
{message.text.trim().length > 0 ? (
{attachmentOnly ? (
<AttachmentOnlyCaption
label={attachments.every(isImageAttachment) ? "Image only" : "Attachment only"}
instruction={message.text.trim()}
/>
) : null}
{message.text.trim().length > 0 && !attachmentOnly ? (
<MarkdownImageAvailableWidthContext
value={props.userBubbleMaxWidth - USER_BUBBLE_HORIZONTAL_PADDING * 2}
>
Expand Down Expand Up @@ -1529,7 +1538,7 @@ function renderFeedEntry(
<SymbolView name="pencil" size={14} tintColor={iconSubtleColor} />
</Pressable>
) : null}
{presentation.text.trim().length > 0 ? (
{presentation.text.trim().length > 0 && !attachmentOnly ? (
<UserMessageCopyButton
text={presentation.text}
onCopy={
Expand Down Expand Up @@ -1694,6 +1703,31 @@ type UserMessageContentProps = {
readonly renderImage: MarkdownImageRenderer;
};

/** Sent with no text: a quiet caption stands in for the agent's bootstrap prompt, and a tap reveals it. */
function AttachmentOnlyCaption(props: { readonly label: string; readonly instruction: string }) {
const [expanded, setExpanded] = useState(false);
return (
<View className="gap-1">
<Pressable
accessibilityRole="button"
accessibilityLabel={expanded ? "Hide agent instruction" : props.label}
accessibilityState={{ expanded }}
hitSlop={8}
onPress={() => setExpanded((value) => !value)}
>
<Text className="text-xs italic text-user-bubble-foreground-muted">
{expanded ? "Hide" : props.label}
</Text>
</Pressable>
{expanded ? (
<Text className="text-xs leading-relaxed text-user-bubble-foreground-muted">
{props.instruction}
</Text>
) : null}
</View>
);
}

function UserMessageContent(props: UserMessageContentProps) {
const [selected, setSelected] = useState<{ contextId: string; label: string } | null>(null);
const navigation = useNavigation();
Expand Down
75 changes: 74 additions & 1 deletion apps/web/src/components/chat/MessagesTimeline.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { EnvironmentId, MessageId, RunId, ScheduledTaskId } from "@t3tools/contracts";
import {
ChatAttachmentId,
EnvironmentId,
MessageId,
RunId,
ScheduledTaskId,
} from "@t3tools/contracts";
import type { LegendListRef } from "@legendapp/list/react";
import { createRef, Profiler, type ComponentProps, type ReactElement } from "react";
import { describe, expect, it, vi } from "vite-plus/test";
Expand All @@ -9,6 +15,7 @@ import { act, render } from "../../../test/render";
import { MessagesTimeline } from "./MessagesTimeline";
import { remarkThoughtPreview } from "./TimelineWorkEntryRow.logic";
import { parseMarkdownWithMath } from "@t3tools/shared/markdownMath";
import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "@t3tools/shared/composerContextReferences";
import { collectThreadFindRanges } from "./threadFindHighlights";

const environmentQuery = vi.hoisted(() => ({
Expand Down Expand Up @@ -310,6 +317,72 @@ describe("MessagesTimeline automation prompts", () => {
});
});

describe("MessagesTimeline attachment-only messages", () => {
const photo = {
type: "image" as const,
id: ChatAttachmentId.make("attachment-1"),
name: "photo.png",
mimeType: "image/png",
sizeBytes: 1024,
previewUrl:
"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='240' height='180'><rect width='240' height='180' fill='%23888'/></svg>",
};

it("stands in for the bootstrap prompt with a caption that reveals it on click", async () => {
setup();
await using screen = await renderTimeline([
message(ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, { attachments: [photo] }),
]);
expect(screen.text()).not.toContain("without additional text");
expect(screen.container.querySelector("[data-user-message-body]")).toBeNull();
expect(screen.buttons(/^Copy message$/i)).toHaveLength(0);
expect(screen.buttons(/^Copy prompt link$/)).toHaveLength(0);

const caption = screen.button("Image only");
expect(caption.getAttribute("aria-expanded")).toBe("false");
const photoRight = () => screen.container.querySelector("img")?.getBoundingClientRect().right;
const rightBefore = photoRight();
expect(rightBefore).toBeDefined();
await click(caption);
// The bubble grows with the text, but the photo stays where it was.
expect(photoRight()).toBe(rightBefore);
expect(screen.text()).toContain(ATTACHMENT_ONLY_BOOTSTRAP_PROMPT);
await click(screen.button("Hide"));
expect(screen.text()).not.toContain("without additional text");
expect(screen.buttons("Image only")).toHaveLength(1);
});

it("labels a message without any image as attachment only", async () => {
setup();
await using screen = await renderTimeline([
message(ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, {
attachments: [
{
type: "file" as const,
id: ChatAttachmentId.make("attachment-video"),
name: "clip.mp4",
mimeType: "video/mp4",
sizeBytes: 1024,
},
],
}),
]);
expect(screen.buttons("Attachment only")).toHaveLength(1);
expect(screen.buttons("Image only")).toHaveLength(0);
});

it("keeps a message that adds text to the bootstrap prompt as a full bubble", async () => {
setup();
await using screen = await renderTimeline([
message(`${ATTACHMENT_ONLY_BOOTSTRAP_PROMPT} Also check the colors.`, {
attachments: [photo],
}),
]);
expect(screen.text()).not.toContain("Sent without text");
expect(screen.text()).toContain("Also check the colors.");
});
});

describe("compact reasoning math", () => {
it("keeps formulas while flattening compact reasoning previews", () => {
const tree = parseMarkdownWithMath("## Reasoning\n\nUse **norm** $|x|$ and \\[x^2\\]");
Expand Down
61 changes: 56 additions & 5 deletions apps/web/src/components/chat/TimelineUserMessageRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,10 @@ import {
resolveUserMessageContext,
selectedMessageContextFragment,
} from "~/lib/composerContextRecords";
import { collectComposerContextReferences } from "@t3tools/shared/composerContextReferences";
import {
collectComposerContextReferences,
isAttachmentOnlyBootstrapText,
} from "@t3tools/shared/composerContextReferences";
import {
COMPOSER_CONTEXT_CLIPBOARD_MIME,
encodeComposerContextClipboardHtml,
Expand Down Expand Up @@ -273,6 +276,9 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
);
const userMessage = resolveUserMessagePresentation(row.message);
const resolvedContext = resolveUserMessageContext(row.message);
// The bootstrap prompt is for the agent, not the reader: the bubble shows only the attachments.
const attachmentOnly = isAttachmentOnlyBootstrapText(resolvedContext.text);
const bodyText = attachmentOnly ? "" : resolvedContext.text;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const previewImages = userImages.filter((image) => image.name.startsWith("preview-annotation-"));
const revertTurnCount = row.revertTurnCount;
const chippedAttachmentIds = new Set(
Expand All @@ -290,7 +296,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
resolvedContext.text,
(reference) => reference.label,
);
const promptLink = promptLinkFor(plainText);
const promptLink = attachmentOnly ? null : promptLinkFor(plainText);
const contextClipboardFragment =
resolvedContext.records.length === 0
? null
Expand Down Expand Up @@ -409,7 +415,17 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
<div className="relative max-w-[80%] rounded-2xl bg-message p-3 text-message-foreground">
<MessageAuthorHeading>You</MessageAuthorHeading>
{(regularImages.length > 0 || userVideos.length > 0) && (
<div className="mb-2 grid max-w-[210px] grid-cols-2 gap-2">
<div
className={cn(
"mb-2 grid gap-2",
// A lone photo sent without text fills its bubble instead of leaving half of it empty.
// It sits at the bubble's right edge so opening the caption grows the bubble leftward
// without moving the photo.
attachmentOnly && regularImages.length + userVideos.length === 1
? "ms-auto max-w-[240px] grid-cols-1"
: "max-w-[210px] grid-cols-2",
)}
>
{regularImages.map((image) => (
<div
key={image.id}
Expand Down Expand Up @@ -513,9 +529,19 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
))}
</div>
) : null}
{attachmentOnly ? (
<AttachmentOnlyCaption
label={
userVideos.length + unchippedFiles.length + unknownAttachments.length > 0
? "Attachment only"
: "Image only"
}
instruction={resolvedContext.text}
/>
) : null}
<div onCopyCapture={onBodyCopyCapture}>
<CollapsibleUserMessageBody
text={resolvedContext.text}
text={bodyText}
renderContextReference={renderContextReference}
skills={ctx.skills}
markdownCwd={ctx.markdownCwd}
Expand All @@ -537,7 +563,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
{typeof revertTurnCount === "number" && (
<RevertUserMessageButton turnCount={revertTurnCount} messageId={row.message.id} />
)}
{resolvedContext.text && (
{bodyText && (
<MessageCopyButton
text={contextClipboardFragment ? resolvedContext.text : plainText}
{...(contextClipboardFragment
Expand All @@ -563,6 +589,31 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
);
}

/** Sent with no text: a quiet caption stands in for the agent's bootstrap prompt, and a click reveals it. */
function AttachmentOnlyCaption({
label,
instruction,
}: {
readonly label: string;
readonly instruction: string;
}) {
const [expanded, setExpanded] = useState(false);
return (
<div className="text-xs text-muted-foreground" data-user-message-attachment-only="true">
<button
type="button"
aria-expanded={expanded}
data-scroll-anchor-ignore
onClick={() => setExpanded((value) => !value)}
className="cursor-pointer rounded-sm italic hover:underline focus-visible:outline-2 focus-visible:outline-ring"
>
{expanded ? "Hide" : label}
</button>
{expanded ? <p className="mt-1 leading-relaxed">{instruction}</p> : null}
</div>
);
}

function UserMessageIntentMarker({
intent,
}: {
Expand Down
11 changes: 11 additions & 0 deletions packages/shared/src/composerContextReferences.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { describe, expect, it } from "vite-plus/test";
import {
collectComposerContextReferences,
formatComposerContextHref,
ATTACHMENT_ONLY_BOOTSTRAP_PROMPT,
isAttachmentOnlyBootstrapText,
isImageReferencesOnly,
parseComposerContextHref,
projectComposerContextForProvider,
Expand Down Expand Up @@ -81,6 +83,15 @@ describe("labels and reference links", () => {
expect(isImageReferencesOnly(`${image} ${file}`)).toBe(false);
expect(isImageReferencesOnly(" ")).toBe(false);
});

it("recognizes the stored bootstrap prompt for an attachment-only message", () => {
expect(isAttachmentOnlyBootstrapText(ATTACHMENT_ONLY_BOOTSTRAP_PROMPT)).toBe(true);
expect(isAttachmentOnlyBootstrapText(` ${ATTACHMENT_ONLY_BOOTSTRAP_PROMPT}\n`)).toBe(true);
expect(isAttachmentOnlyBootstrapText(`${ATTACHMENT_ONLY_BOOTSTRAP_PROMPT} look at this`)).toBe(
false,
);
expect(isAttachmentOnlyBootstrapText("")).toBe(false);
});
});

describe("provider projection", () => {
Expand Down
9 changes: 9 additions & 0 deletions packages/shared/src/composerContextReferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,15 @@ export function collectComposerContextReferences(
export const ATTACHMENT_ONLY_BOOTSTRAP_PROMPT =
"[User attached one or more files without additional text. Respond using the conversation context and the attached files.]";

/**
* True when a stored user message is only the bootstrap prompt T3 Code sends in place of
* empty text. Clients hide that prompt in the chat and show a status line instead; the
* message itself, and what the agent received, stay untouched.
*/
export function isAttachmentOnlyBootstrapText(text: string): boolean {
return text.trim() === ATTACHMENT_ONLY_BOOTSTRAP_PROMPT;
}

export function isImageReferencesOnly(text: string): boolean {
const references = collectComposerContextReferences(text);
return (
Expand Down
Loading