From f623ec9bee5a4062b4009437c8f43aad2f79dcc3 Mon Sep 17 00:00:00 2001 From: Zack Story Date: Sat, 1 Aug 2026 15:36:03 -0700 Subject: [PATCH 1/4] feat(web): open messages in right panel surfaces --- apps/web/src/components/ChatView.tsx | 18 ++++++++ apps/web/src/components/RightPanelTabs.tsx | 15 ++++++- .../src/components/chat/MessageSurface.tsx | 41 +++++++++++++++++ .../src/components/chat/MessagesTimeline.tsx | 35 ++++++++++++--- .../chat/OpenMessageSurfaceButton.tsx | 26 +++++++++++ apps/web/src/rightPanelStore.test.ts | 20 ++++++++- apps/web/src/rightPanelStore.ts | 45 ++++++++++++++++--- 7 files changed, 187 insertions(+), 13 deletions(-) create mode 100644 apps/web/src/components/chat/MessageSurface.tsx create mode 100644 apps/web/src/components/chat/OpenMessageSurfaceButton.tsx diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 2b9eda1a787..d96f6204c3b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -139,6 +139,7 @@ import { usePreviewMiniPlayerStore, } from "../previewMiniPlayerStore"; import { RightPanelTabs } from "./RightPanelTabs"; +import { MessageSurface } from "./chat/MessageSurface"; import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; @@ -2386,6 +2387,16 @@ function ChatViewContent(props: ChatViewProps) { deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries), [activeThread?.proposedPlans, timelineMessages, workLogEntries], ); + const activeSurfaceMessage = useMemo(() => { + if (activeRightPanelSurface?.kind !== "message") return null; + const entry = timelineEntries.find( + (candidate) => + candidate.kind === "message" && + candidate.message.id === activeRightPanelSurface.messageId && + candidate.message.role !== "system", + ); + return entry?.kind === "message" ? entry.message : null; + }, [activeRightPanelSurface, timelineEntries]); const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState(null); const draftHeroDockRequested = activeThreadKey !== null && dockedDraftHeroThreadKey === activeThreadKey; @@ -5703,6 +5714,13 @@ function ChatViewContent(props: ChatViewProps) { timestampFormat={timestampFormat} mode="embedded" /> + ) : activeRightPanelSurface?.kind === "message" ? ( + ) : (activeRightPanelSurface?.kind === "files" || activeRightPanelSurface?.kind === "file") && activeProject && activeWorkspaceRoot ? ( diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index fe652b6fde7..a86488c7e96 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -1,6 +1,15 @@ import type { ContextMenuItem, PreviewSessionSnapshot } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; -import { ClipboardList, FileDiff, Files, Globe2, Plus, TerminalSquare, X } from "lucide-react"; +import { + ClipboardList, + FileDiff, + Files, + Globe2, + MessageSquare, + Plus, + TerminalSquare, + X, +} from "lucide-react"; import { type MouseEvent as ReactMouseEvent, type ReactElement, @@ -198,6 +207,8 @@ function surfaceTitle( return "Files"; case "file": return surface.relativePath.slice(surface.relativePath.lastIndexOf("/") + 1); + case "message": + return "Message"; case "terminal": return ( terminalLabelsById.get(surface.activeTerminalId) ?? @@ -264,6 +275,8 @@ function SurfaceIcon({ ); case "terminal": return ; + case "message": + return ; case "plan": return ; } diff --git a/apps/web/src/components/chat/MessageSurface.tsx b/apps/web/src/components/chat/MessageSurface.tsx new file mode 100644 index 00000000000..2037347f7be --- /dev/null +++ b/apps/web/src/components/chat/MessageSurface.tsx @@ -0,0 +1,41 @@ +import type { ScopedThreadRef, ServerProviderSkill } from "@t3tools/contracts"; + +import { deriveDisplayedUserMessageState } from "~/lib/terminalContext"; +import type { ChatMessage } from "~/types"; +import ChatMarkdown from "../ChatMarkdown"; + +export function MessageSurface({ + message, + threadRef, + cwd, + skills, +}: { + message: ChatMessage | null; + threadRef: ScopedThreadRef; + cwd: string | undefined; + skills: ReadonlyArray>; +}) { + if (!message) { + return ( +
+
+

Message unavailable

+

+ This message is no longer in the thread. +

+
+
+ ); + } + + const text = + message.role === "user" ? deriveDisplayedUserMessageState(message.text).copyText : message.text; + + return ( +
+
+ +
+
+ ); +} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index a429b54deaf..653b4c6f8e7 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -64,6 +64,7 @@ import { ProposedPlanCard } from "./ProposedPlanCard"; import { ChangedFilesCard } from "./ChangedFilesTree"; import { shouldAutoExpandChangedFiles } from "./changedFilesPresentation"; import { MessageCopyButton } from "./MessageCopyButton"; +import { OpenMessageSurfaceButton } from "./OpenMessageSurfaceButton"; import { computeStableMessagesTimelineRows, deriveMessagesTimelineRows, @@ -97,6 +98,7 @@ import { } from "~/lib/previewAnnotation"; import { cn } from "~/lib/utils"; import { useUiStateStore } from "~/uiStateStore"; +import { useRightPanelStore } from "~/rightPanelStore"; import { type TimestampFormat } from "@t3tools/contracts/settings"; import { formatChatTimestampTooltip, formatShortTimestamp } from "../../timestampFormat"; @@ -133,6 +135,7 @@ interface TimelineRowSharedState { onRevertUserMessage: (messageId: MessageId) => void; onImageExpand: (preview: ExpandedImagePreview) => void; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; + onOpenMessageSurface: (messageId: MessageId) => void; onToggleTurnFold: (turnId: TurnId) => void; onToggleWorkGroup: (groupId: string, anchorElement?: HTMLElement) => void; } @@ -223,6 +226,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const [expandedTurnIds, setExpandedTurnIds] = useState>(new Set()); const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState>(new Set()); const [minimapStripMap] = useState(() => new Map()); + const threadRef = useMemo(() => parseScopedThreadKey(routeThreadKey), [routeThreadKey]); + const onOpenMessageSurface = useCallback( + (messageId: MessageId) => { + if (!threadRef) return; + useRightPanelStore.getState().openMessage(threadRef, messageId); + }, + [threadRef], + ); const onToggleTurnFold = useCallback((turnId: TurnId) => { setExpandedTurnIds((existing) => { @@ -419,7 +430,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ () => ({ timestampFormat, routeThreadKey, - threadRef: parseScopedThreadKey(routeThreadKey), + threadRef, markdownCwd, resolvedTheme, workspaceRoot, @@ -428,12 +439,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onRevertUserMessage, onImageExpand, onOpenTurnDiff, + onOpenMessageSurface, onToggleTurnFold, onToggleWorkGroup, }), [ timestampFormat, routeThreadKey, + threadRef, markdownCwd, resolvedTheme, workspaceRoot, @@ -442,6 +455,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onRevertUserMessage, onImageExpand, onOpenTurnDiff, + onOpenMessageSurface, onToggleTurnFold, onToggleWorkGroup, ], @@ -961,7 +975,7 @@ function UserTimelineRow({ row }: { row: Extract {canRevertAgentWork && } {displayedUserMessage.copyText && ( - + )} @@ -1037,7 +1051,7 @@ function AssistantTimelineRow({ row }: { row: Extract {row.showAssistantMeta ? (
- + {!row.message.streaming && ( }) { +function AssistantMessageActions({ row }: { row: Extract }) { const assistantCopyState = resolveAssistantMessageCopyState({ text: row.message.text ?? null, showCopyButton: row.showAssistantCopyButton, @@ -1068,7 +1082,18 @@ function AssistantCopyButton({ row }: { row: Extract; + return ; +} + +function MessageActions({ messageId, text }: { messageId: MessageId; text: string }) { + const ctx = use(TimelineRowCtx); + + return ( +
+ + ctx.onOpenMessageSurface(messageId)} /> +
+ ); } function ProposedPlanTimelineRow({ diff --git a/apps/web/src/components/chat/OpenMessageSurfaceButton.tsx b/apps/web/src/components/chat/OpenMessageSurfaceButton.tsx new file mode 100644 index 00000000000..f30d3dac36c --- /dev/null +++ b/apps/web/src/components/chat/OpenMessageSurfaceButton.tsx @@ -0,0 +1,26 @@ +import { PanelRightIcon } from "lucide-react"; + +import { Button } from "../ui/button"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; + +export function OpenMessageSurfaceButton({ onClick }: { onClick: () => void }) { + return ( + + + } + > + + + Open as surface + + ); +} diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts index c7457cfd304..ce8695e1e43 100644 --- a/apps/web/src/rightPanelStore.test.ts +++ b/apps/web/src/rightPanelStore.test.ts @@ -1,5 +1,5 @@ import { scopeThreadRef } from "@t3tools/client-runtime/environment"; -import { type EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { type EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; import { beforeEach, describe, expect, it } from "vite-plus/test"; import { @@ -142,6 +142,24 @@ describe("rightPanelStore", () => { }); }); + it("stores message surfaces by id", () => { + const firstMessageId = MessageId.make("message-1"); + const secondMessageId = MessageId.make("message-2"); + + useRightPanelStore.getState().openMessage(refA, firstMessageId); + useRightPanelStore.getState().openMessage(refA, firstMessageId); + useRightPanelStore.getState().openMessage(refA, secondMessageId); + + expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ + isOpen: true, + activeSurfaceId: "message:message-2", + surfaces: [ + { id: "message:message-1", kind: "message", messageId: firstMessageId }, + { id: "message:message-2", kind: "message", messageId: secondMessageId }, + ], + }); + }); + it("replaces the standalone explorer with peer file surfaces", () => { useRightPanelStore.getState().open(refA, "files"); useRightPanelStore.getState().openFile(refA, "src/index.ts"); diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index 70d163306cc..78a030212fa 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -5,16 +5,25 @@ * surface descriptors and the active surface, while each feature continues to * own its durable resource state. Browser surfaces point at preview tab ids, * terminal surfaces point at terminal session ids, file surfaces point at - * workspace paths, and diff/plan/files remain singleton surfaces. + * workspace paths, message surfaces point at message ids, and diff/plan/files + * remain singleton surfaces. */ import { scopedThreadKey } from "@t3tools/client-runtime/environment"; -import type { ScopedThreadRef } from "@t3tools/contracts"; +import type { MessageId, ScopedThreadRef } from "@t3tools/contracts"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { resolveStorage } from "./lib/storage"; -export const RIGHT_PANEL_KINDS = ["plan", "diff", "files", "file", "preview", "terminal"] as const; +export const RIGHT_PANEL_KINDS = [ + "plan", + "diff", + "files", + "file", + "message", + "preview", + "terminal", +] as const; export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number]; export type RightPanelSurface = @@ -37,6 +46,11 @@ export type RightPanelSurface = revealLine: number | null; revealRequestId: number; } + | { + id: `message:${string}`; + kind: "message"; + messageId: MessageId; + } | { id: "plan"; kind: "plan" }; const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2"; @@ -50,9 +64,13 @@ export interface ThreadRightPanelState { interface RightPanelStoreState { byThreadKey: Record; - open: (ref: ScopedThreadRef, kind: Exclude) => void; + open: ( + ref: ScopedThreadRef, + kind: Exclude, + ) => void; openBrowser: (ref: ScopedThreadRef, tabId: string | null) => void; openFile: (ref: ScopedThreadRef, relativePath: string, line?: number) => void; + openMessage: (ref: ScopedThreadRef, messageId: MessageId) => void; openTerminal: (ref: ScopedThreadRef, terminalId: string) => void; splitTerminal: ( ref: ScopedThreadRef, @@ -72,7 +90,10 @@ interface RightPanelStoreState { show: (ref: ScopedThreadRef) => void; close: (ref: ScopedThreadRef) => void; toggleVisibility: (ref: ScopedThreadRef) => void; - toggle: (ref: ScopedThreadRef, kind: Exclude) => void; + toggle: ( + ref: ScopedThreadRef, + kind: Exclude, + ) => void; removeThread: (ref: ScopedThreadRef) => void; } @@ -83,7 +104,7 @@ const EMPTY_THREAD_STATE: ThreadRightPanelState = { }; const singletonSurface = ( - kind: Exclude, + kind: Exclude, ): RightPanelSurface => { switch (kind) { case "diff": @@ -120,6 +141,12 @@ const terminalSurface = (terminalId: string): RightPanelSurface => ({ activeTerminalId: terminalId, }); +const messageSurface = (messageId: MessageId): RightPanelSurface => ({ + id: `message:${messageId}`, + kind: "message", + messageId, +}); + const upsertSurface = ( current: ThreadRightPanelState, surface: RightPanelSurface, @@ -286,6 +313,12 @@ export const useRightPanelStore = create()( }; }), })), + openMessage: (ref, messageId) => + set((state) => ({ + byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => + upsertSurface(current, messageSurface(messageId)), + ), + })), openTerminal: (ref, terminalId) => set((state) => ({ byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => From daa8674b1b4fbf3cd983272d663746776a0521e3 Mon Sep 17 00:00:00 2001 From: Zack Story Date: Sat, 1 Aug 2026 16:30:51 -0700 Subject: [PATCH 2/4] fix(web): hide injected context in message surfaces --- apps/web/src/components/chat/MessageSurface.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/MessageSurface.tsx b/apps/web/src/components/chat/MessageSurface.tsx index 2037347f7be..d6482463b19 100644 --- a/apps/web/src/components/chat/MessageSurface.tsx +++ b/apps/web/src/components/chat/MessageSurface.tsx @@ -29,7 +29,9 @@ export function MessageSurface({ } const text = - message.role === "user" ? deriveDisplayedUserMessageState(message.text).copyText : message.text; + message.role === "user" + ? deriveDisplayedUserMessageState(message.text).visibleText + : message.text; return (
From 008baf4f2571bd5a210a4d2973f9349bba7c6225 Mon Sep 17 00:00:00 2001 From: Zack Story Date: Sat, 1 Aug 2026 20:44:15 -0700 Subject: [PATCH 3/4] fix(web): show attachments in message surfaces --- apps/web/src/components/ChatView.tsx | 1 + .../chat/MessageImageAttachments.tsx | 48 +++++++++++++++ .../components/chat/MessageSurface.test.tsx | 60 +++++++++++++++++++ .../src/components/chat/MessageSurface.tsx | 9 +++ .../src/components/chat/MessagesTimeline.tsx | 39 ++---------- 5 files changed, 124 insertions(+), 33 deletions(-) create mode 100644 apps/web/src/components/chat/MessageImageAttachments.tsx create mode 100644 apps/web/src/components/chat/MessageSurface.test.tsx diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d96f6204c3b..b7d37372b50 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5720,6 +5720,7 @@ function ChatViewContent(props: ChatViewProps) { threadRef={activeThreadRef} cwd={gitCwd ?? undefined} skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS} + onImageExpand={onExpandTimelineImage} /> ) : (activeRightPanelSurface?.kind === "files" || activeRightPanelSurface?.kind === "file") && activeProject && diff --git a/apps/web/src/components/chat/MessageImageAttachments.tsx b/apps/web/src/components/chat/MessageImageAttachments.tsx new file mode 100644 index 00000000000..5d2b016d071 --- /dev/null +++ b/apps/web/src/components/chat/MessageImageAttachments.tsx @@ -0,0 +1,48 @@ +import { cn } from "~/lib/utils"; +import type { ChatImageAttachment } from "~/types"; +import { buildExpandedImagePreview, type ExpandedImagePreview } from "./ExpandedImagePreview"; + +export function MessageImageAttachments({ + images, + onImageExpand, + className, +}: { + images: ReadonlyArray; + onImageExpand: (preview: ExpandedImagePreview) => void; + className?: string; +}) { + if (images.length === 0) return null; + + return ( +
+ {images.map((image) => ( +
+ {image.previewUrl ? ( + + ) : ( +
+ {image.name} +
+ )} +
+ ))} +
+ ); +} diff --git a/apps/web/src/components/chat/MessageSurface.test.tsx b/apps/web/src/components/chat/MessageSurface.test.tsx new file mode 100644 index 00000000000..4c877045c31 --- /dev/null +++ b/apps/web/src/components/chat/MessageSurface.test.tsx @@ -0,0 +1,60 @@ +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import type { ChatMessage } from "~/types"; +import { MessageSurface } from "./MessageSurface"; + +vi.mock("../ChatMarkdown", () => ({ + default: ({ text }: { text: string }) =>
{text}
, +})); + +describe("MessageSurface", () => { + it("renders user attachments without exposing injected context", () => { + const message: ChatMessage = { + id: MessageId.make("message-1"), + role: "user", + text: [ + "Review this screenshot.", + "", + "", + "- Terminal 1:", + " hidden output", + "", + ].join("\n"), + attachments: [ + { + type: "image", + id: "attachment-1", + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 1, + previewUrl: "data:image/png;base64,iVBORw0KGgo=", + }, + ], + turnId: null, + streaming: false, + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-01T00:00:00.000Z", + }; + + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('alt="screenshot.png"'); + expect(markup).toContain("Review this screenshot."); + expect(markup).not.toContain("terminal_context"); + expect(markup).not.toContain("hidden output"); + }); +}); diff --git a/apps/web/src/components/chat/MessageSurface.tsx b/apps/web/src/components/chat/MessageSurface.tsx index d6482463b19..1e670d79077 100644 --- a/apps/web/src/components/chat/MessageSurface.tsx +++ b/apps/web/src/components/chat/MessageSurface.tsx @@ -3,17 +3,21 @@ import type { ScopedThreadRef, ServerProviderSkill } from "@t3tools/contracts"; import { deriveDisplayedUserMessageState } from "~/lib/terminalContext"; import type { ChatMessage } from "~/types"; import ChatMarkdown from "../ChatMarkdown"; +import type { ExpandedImagePreview } from "./ExpandedImagePreview"; +import { MessageImageAttachments } from "./MessageImageAttachments"; export function MessageSurface({ message, threadRef, cwd, skills, + onImageExpand, }: { message: ChatMessage | null; threadRef: ScopedThreadRef; cwd: string | undefined; skills: ReadonlyArray>; + onImageExpand: (preview: ExpandedImagePreview) => void; }) { if (!message) { return ( @@ -36,6 +40,11 @@ export function MessageSurface({ return (
+
diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 653b4c6f8e7..a05ae2af371 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -64,6 +64,7 @@ import { ProposedPlanCard } from "./ProposedPlanCard"; import { ChangedFilesCard } from "./ChangedFilesTree"; import { shouldAutoExpandChangedFiles } from "./changedFilesPresentation"; import { MessageCopyButton } from "./MessageCopyButton"; +import { MessageImageAttachments } from "./MessageImageAttachments"; import { OpenMessageSurfaceButton } from "./OpenMessageSurfaceButton"; import { computeStableMessagesTimelineRows, @@ -905,39 +906,11 @@ function UserTimelineRow({ row }: { row: Extract
- {regularImages.length > 0 && ( -
- {regularImages.map((image: NonNullable[number]) => ( -
- {image.previewUrl ? ( - - ) : ( -
- {image.name} -
- )} -
- ))} -
- )} + {previewAnnotations.map((annotation, index) => ( Date: Sat, 1 Aug 2026 20:56:18 -0700 Subject: [PATCH 4/4] Revert "fix(web): show attachments in message surfaces" This reverts commit 008baf4f2571bd5a210a4d2973f9349bba7c6225. --- apps/web/src/components/ChatView.tsx | 1 - .../chat/MessageImageAttachments.tsx | 48 --------------- .../components/chat/MessageSurface.test.tsx | 60 ------------------- .../src/components/chat/MessageSurface.tsx | 9 --- .../src/components/chat/MessagesTimeline.tsx | 39 ++++++++++-- 5 files changed, 33 insertions(+), 124 deletions(-) delete mode 100644 apps/web/src/components/chat/MessageImageAttachments.tsx delete mode 100644 apps/web/src/components/chat/MessageSurface.test.tsx diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b7d37372b50..d96f6204c3b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5720,7 +5720,6 @@ function ChatViewContent(props: ChatViewProps) { threadRef={activeThreadRef} cwd={gitCwd ?? undefined} skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS} - onImageExpand={onExpandTimelineImage} /> ) : (activeRightPanelSurface?.kind === "files" || activeRightPanelSurface?.kind === "file") && activeProject && diff --git a/apps/web/src/components/chat/MessageImageAttachments.tsx b/apps/web/src/components/chat/MessageImageAttachments.tsx deleted file mode 100644 index 5d2b016d071..00000000000 --- a/apps/web/src/components/chat/MessageImageAttachments.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { cn } from "~/lib/utils"; -import type { ChatImageAttachment } from "~/types"; -import { buildExpandedImagePreview, type ExpandedImagePreview } from "./ExpandedImagePreview"; - -export function MessageImageAttachments({ - images, - onImageExpand, - className, -}: { - images: ReadonlyArray; - onImageExpand: (preview: ExpandedImagePreview) => void; - className?: string; -}) { - if (images.length === 0) return null; - - return ( -
- {images.map((image) => ( -
- {image.previewUrl ? ( - - ) : ( -
- {image.name} -
- )} -
- ))} -
- ); -} diff --git a/apps/web/src/components/chat/MessageSurface.test.tsx b/apps/web/src/components/chat/MessageSurface.test.tsx deleted file mode 100644 index 4c877045c31..00000000000 --- a/apps/web/src/components/chat/MessageSurface.test.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { scopeThreadRef } from "@t3tools/client-runtime/environment"; -import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it, vi } from "vite-plus/test"; - -import type { ChatMessage } from "~/types"; -import { MessageSurface } from "./MessageSurface"; - -vi.mock("../ChatMarkdown", () => ({ - default: ({ text }: { text: string }) =>
{text}
, -})); - -describe("MessageSurface", () => { - it("renders user attachments without exposing injected context", () => { - const message: ChatMessage = { - id: MessageId.make("message-1"), - role: "user", - text: [ - "Review this screenshot.", - "", - "", - "- Terminal 1:", - " hidden output", - "", - ].join("\n"), - attachments: [ - { - type: "image", - id: "attachment-1", - name: "screenshot.png", - mimeType: "image/png", - sizeBytes: 1, - previewUrl: "data:image/png;base64,iVBORw0KGgo=", - }, - ], - turnId: null, - streaming: false, - createdAt: "2026-08-01T00:00:00.000Z", - updatedAt: "2026-08-01T00:00:00.000Z", - }; - - const markup = renderToStaticMarkup( - , - ); - - expect(markup).toContain('alt="screenshot.png"'); - expect(markup).toContain("Review this screenshot."); - expect(markup).not.toContain("terminal_context"); - expect(markup).not.toContain("hidden output"); - }); -}); diff --git a/apps/web/src/components/chat/MessageSurface.tsx b/apps/web/src/components/chat/MessageSurface.tsx index 1e670d79077..d6482463b19 100644 --- a/apps/web/src/components/chat/MessageSurface.tsx +++ b/apps/web/src/components/chat/MessageSurface.tsx @@ -3,21 +3,17 @@ import type { ScopedThreadRef, ServerProviderSkill } from "@t3tools/contracts"; import { deriveDisplayedUserMessageState } from "~/lib/terminalContext"; import type { ChatMessage } from "~/types"; import ChatMarkdown from "../ChatMarkdown"; -import type { ExpandedImagePreview } from "./ExpandedImagePreview"; -import { MessageImageAttachments } from "./MessageImageAttachments"; export function MessageSurface({ message, threadRef, cwd, skills, - onImageExpand, }: { message: ChatMessage | null; threadRef: ScopedThreadRef; cwd: string | undefined; skills: ReadonlyArray>; - onImageExpand: (preview: ExpandedImagePreview) => void; }) { if (!message) { return ( @@ -40,11 +36,6 @@ export function MessageSurface({ return (
-
diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index a05ae2af371..653b4c6f8e7 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -64,7 +64,6 @@ import { ProposedPlanCard } from "./ProposedPlanCard"; import { ChangedFilesCard } from "./ChangedFilesTree"; import { shouldAutoExpandChangedFiles } from "./changedFilesPresentation"; import { MessageCopyButton } from "./MessageCopyButton"; -import { MessageImageAttachments } from "./MessageImageAttachments"; import { OpenMessageSurfaceButton } from "./OpenMessageSurfaceButton"; import { computeStableMessagesTimelineRows, @@ -906,11 +905,39 @@ function UserTimelineRow({ row }: { row: Extract
- + {regularImages.length > 0 && ( +
+ {regularImages.map((image: NonNullable[number]) => ( +
+ {image.previewUrl ? ( + + ) : ( +
+ {image.name} +
+ )} +
+ ))} +
+ )} {previewAnnotations.map((annotation, index) => (