authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-03 02:33:43-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-03 02:33:43-07:00
log652515a349741d234111b85f27597be3265d1ffc
treeecaec83e4cf64966569ebb96ccd79067865c7e27
parent044ea8e347d980a215057c325de8335d38f0f9aa
signature Signed by PGP key B5690EEEBB952194

fix(web): render assistant images inline in chat (#9126)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

4 files changed, 196 insertions(+), 15 deletions(-)

apps/web/src/components/ChatMarkdown.workspace-images.test.tsx+19
...@@ -145,6 +145,25 @@ describe("ChatMarkdown workspace images", () => {...@@ -145,6 +145,25 @@ describe("ChatMarkdown workspace images", () => {
145 expect(html).not.toContain("Image unavailable");145 expect(html).not.toContain("Image unavailable");
146 });146 });
147147
148 it("loads a POSIX absolute path and file URI through a signed asset URL", () => {
149 const html = renderToStaticMarkup(
150 <ChatMarkdown
151 cwd="/workspace/project"
152 threadRef={threadRef}
153 text={[
154 "![absolute](/tmp/embed-test/2.png)",
155 "![file URL](file:///tmp/embed-test/5.png)",
156 ].join("\n\n")}
157 />,
158 );
159
160 expect(testState.resources).toEqual([
161 { _tag: "media-file", threadId: threadRef.threadId, path: "/tmp/embed-test/2.png" },
162 { _tag: "media-file", threadId: threadRef.threadId, path: "/tmp/embed-test/5.png" },
163 ]);
164 expect(html).not.toContain("Image unavailable");
165 });
166
148 it("normalizes a drive-absolute src in raw image HTML", () => {167 it("normalizes a drive-absolute src in raw image HTML", () => {
149 const html = render(String.raw`<img src="D:\screens\workspace-image.svg" alt="raw">`);168 const html = render(String.raw`<img src="D:\screens\workspace-image.svg" alt="raw">`);
150169
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+136
...@@ -12,6 +12,7 @@ import {...@@ -12,6 +12,7 @@ import {
12 shouldPreserveAssistantLineBreaks,12 shouldPreserveAssistantLineBreaks,
13 type MessagesTimelineRow,13 type MessagesTimelineRow,
14 workEntryDisplayLabel,14 workEntryDisplayLabel,
15 workEntryIsVisibleInGroup,
15} from "./MessagesTimeline.logic";16} from "./MessagesTimeline.logic";
1617
17describe("expanded tool group scrolling", () => {18describe("expanded tool group scrolling", () => {
...@@ -471,6 +472,22 @@ describe("resolveAssistantMessageCopyState", () => {...@@ -471,6 +472,22 @@ describe("resolveAssistantMessageCopyState", () => {
471 });472 });
472});473});
473474
475describe("workEntryIsVisibleInGroup", () => {
476 it("keeps an in-progress image preview row visible outside a group", () => {
477 const entry = {
478 id: "work-image",
479 createdAt: "2026-01-01T00:00:04Z",
480 turnId: "turn-1" as never,
481 label: "Image view",
482 detail: "screenshots/result.png",
483 itemType: "image_view" as const,
484 tone: "tool" as const,
485 toolLifecycleStatus: "inProgress" as const,
486 };
487 expect(workEntryIsVisibleInGroup(entry, false)).toBe(true);
488 });
489});
490
474describe("deriveMessagesTimelineRows", () => {491describe("deriveMessagesTimelineRows", () => {
475 it("only enables assistant copy for the terminal assistant message in a turn", () => {492 it("only enables assistant copy for the terminal assistant message in a turn", () => {
476 const rows = deriveMessagesTimelineRows({493 const rows = deriveMessagesTimelineRows({
...@@ -834,6 +851,125 @@ describe("deriveMessagesTimelineRows", () => {...@@ -834,6 +851,125 @@ describe("deriveMessagesTimelineRows", () => {
834 });851 });
835 });852 });
836853
854 it("keeps an image preview row visible after the turn settles", () => {
855 const timelineEntries = [
856 {
857 id: "work-command-entry",
858 kind: "work" as const,
859 createdAt: "2026-01-01T00:00:02Z",
860 entry: {
861 id: "work-command",
862 createdAt: "2026-01-01T00:00:02Z",
863 turnId: "turn-1" as never,
864 label: "Ran command",
865 tone: "tool" as const,
866 },
867 },
868 {
869 id: "work-image-entry",
870 kind: "work" as const,
871 createdAt: "2026-01-01T00:00:04Z",
872 entry: {
873 id: "work-image",
874 createdAt: "2026-01-01T00:00:04Z",
875 turnId: "turn-1" as never,
876 label: "Image view",
877 detail: "screenshots/result.png",
878 itemType: "image_view" as const,
879 tone: "tool" as const,
880 },
881 },
882 {
883 id: "assistant-final-entry",
884 kind: "message" as const,
885 createdAt: "2026-01-01T00:00:06Z",
886 message: {
887 id: "assistant-final" as never,
888 role: "assistant" as const,
889 text: "Here is the screenshot.",
890 turnId: "turn-1" as never,
891 createdAt: "2026-01-01T00:00:06Z",
892 updatedAt: "2026-01-01T00:00:07Z",
893 streaming: false,
894 },
895 },
896 ];
897
898 const rows = deriveMessagesTimelineRows({
899 timelineEntries,
900 isWorking: false,
901 activeTurnStartedAt: null,
902 turnDiffSummaryByAssistantMessageId: new Map(),
903 revertTurnCountByUserMessageId: new Map(),
904 });
905
906 // The command row folds, the image row stays as its own visible row rather
907 // than collapsing into a tool group summary.
908 expect(rows.map((row) => row.id)).toEqual([
909 "turn-fold:turn-1",
910 "work-image-entry",
911 "assistant-final-entry",
912 ]);
913 expect(rows.find((row) => row.id === "work-image-entry")?.kind).toBe("work");
914 });
915
916 it("keeps a trailing image preview row visible while the turn is still working", () => {
917 const rows = deriveMessagesTimelineRows({
918 timelineEntries: [
919 {
920 id: "user-entry",
921 kind: "message" as const,
922 createdAt: "2026-01-01T00:00:00Z",
923 message: {
924 id: "user-1" as never,
925 role: "user" as const,
926 text: "show me the result",
927 turnId: null,
928 createdAt: "2026-01-01T00:00:00Z",
929 updatedAt: "2026-01-01T00:00:00Z",
930 streaming: false,
931 },
932 },
933 {
934 id: "work-command-entry",
935 kind: "work" as const,
936 createdAt: "2026-01-01T00:00:02Z",
937 entry: {
938 id: "work-command",
939 createdAt: "2026-01-01T00:00:02Z",
940 turnId: "turn-1" as never,
941 label: "Ran command",
942 tone: "tool" as const,
943 },
944 },
945 {
946 id: "work-image-entry",
947 kind: "work" as const,
948 createdAt: "2026-01-01T00:00:04Z",
949 entry: {
950 id: "work-image",
951 createdAt: "2026-01-01T00:00:04Z",
952 turnId: "turn-1" as never,
953 label: "Image view",
954 detail: "screenshots/result.png",
955 itemType: "image_view" as const,
956 tone: "tool" as const,
957 },
958 },
959 ],
960 isWorking: true,
961 activeTurnStartedAt: "2026-01-01T00:00:01Z",
962 turnDiffSummaryByAssistantMessageId: new Map(),
963 revertTurnCountByUserMessageId: new Map(),
964 });
965
966 // The live activity row must not swallow the image: it renders only a
967 // label, which would hide the image until the turn settles.
968 const imageRow = rows.find((row) => row.id === "work-image-entry");
969 expect(imageRow?.kind).toBe("work");
970 expect(rows.some((row) => row.kind === "work-live")).toBe(false);
971 });
972
837 it("folds all assistant messages before the terminal message", () => {973 it("folds all assistant messages before the terminal message", () => {
838 const timelineEntries = [974 const timelineEntries = [
839 {975 {
apps/web/src/components/chat/MessagesTimeline.logic.ts+24-2
...@@ -9,6 +9,7 @@ import {...@@ -9,6 +9,7 @@ import {
9 summarizeToolGroup,9 summarizeToolGroup,
10 toolGroupAction,10 toolGroupAction,
11 toolGroupSummaryKind,11 toolGroupSummaryKind,
12 workEntryViewedImagePath,
12 type ToolGroupSummaryKind,13 type ToolGroupSummaryKind,
13} from "@t3tools/client-runtime/work-log/presentation";14} from "@t3tools/client-runtime/work-log/presentation";
14export {15export {
...@@ -89,6 +90,15 @@ export function liveWorkEntryLabel(...@@ -89,6 +90,15 @@ export function liveWorkEntryLabel(
89 return workEntryDisplayLabel(entry, workspaceRoot);90 return workEntryDisplayLabel(entry, workspaceRoot);
90}91}
9192
93/**
94 * Rows that preview an image the agent viewed or produced. They stay out of
95 * tool groups and out of the settled-turn fold: the image is the answer the
96 * user asked for, not tool noise to hide behind "Worked for ...".
97 */
98function workEntryRendersImagePreview(entry: WorkLogEntry): boolean {
99 return workEntryViewedImagePath(entry) !== null;
100}
101
92export function workEntryIsVisibleInGroup(102export function workEntryIsVisibleInGroup(
93 entry: WorkLogEntry,103 entry: WorkLogEntry,
94 expandedToolGroupEntry = false,104 expandedToolGroupEntry = false,
...@@ -97,6 +107,9 @@ export function workEntryIsVisibleInGroup(...@@ -97,6 +107,9 @@ export function workEntryIsVisibleInGroup(
97 (expandedToolGroupEntry &&107 (expandedToolGroupEntry &&
98 (entry.toolLifecycleStatus === "inProgress" ||108 (entry.toolLifecycleStatus === "inProgress" ||
99 entry.sourceActivityKind === "task.progress")) ||109 entry.sourceActivityKind === "task.progress")) ||
110 // An image row stands alone outside any group, so the neutral filter
111 // would leave an empty gap while its tool is still in progress.
112 workEntryRendersImagePreview(entry) ||
100 !workEntryIndicatesToolNeutralStatus(entry)113 !workEntryIndicatesToolNeutralStatus(entry)
101 );114 );
102}115}
...@@ -591,6 +604,9 @@ function deriveTurnFolds(input: {...@@ -591,6 +604,9 @@ function deriveTurnFolds(input: {
591 if (entry.kind === "work" && entry.entry.agentSpawn !== undefined) {604 if (entry.kind === "work" && entry.entry.agentSpawn !== undefined) {
592 continue;605 continue;
593 }606 }
607 if (entry.kind === "work" && workEntryRendersImagePreview(entry.entry)) {
608 continue;
609 }
594 hiddenEntryIds.add(entry.id);610 hiddenEntryIds.add(entry.id);
595 }611 }
596 if (hiddenEntryIds.size === 0) {612 if (hiddenEntryIds.size === 0) {
...@@ -787,7 +803,8 @@ export function deriveMessagesTimelineRows(input: {...@@ -787,7 +803,8 @@ export function deriveMessagesTimelineRows(input: {
787 !entryBelongsToActiveTurn(entry, index) ||803 !entryBelongsToActiveTurn(entry, index) ||
788 entry.kind !== "work" ||804 entry.kind !== "work" ||
789 entry.entry.agentSpawn !== undefined ||805 entry.entry.agentSpawn !== undefined ||
790 entry.entry.tone === "error"806 entry.entry.tone === "error" ||
807 workEntryRendersImagePreview(entry.entry)
791 ) {808 ) {
792 break;809 break;
793 }810 }
...@@ -890,7 +907,11 @@ export function deriveMessagesTimelineRows(input: {...@@ -890,7 +907,11 @@ export function deriveMessagesTimelineRows(input: {
890 }907 }
891908
892 if (timelineEntry.kind === "work") {909 if (timelineEntry.kind === "work") {
893 if (timelineEntry.entry.agentSpawn !== undefined || timelineEntry.entry.tone === "error") {910 if (
911 timelineEntry.entry.agentSpawn !== undefined ||
912 timelineEntry.entry.tone === "error" ||
913 workEntryRendersImagePreview(timelineEntry.entry)
914 ) {
894 nextRows.push({915 nextRows.push({
895 kind: "work",916 kind: "work",
896 id: timelineEntry.id,917 id: timelineEntry.id,
...@@ -909,6 +930,7 @@ export function deriveMessagesTimelineRows(input: {...@@ -909,6 +930,7 @@ export function deriveMessagesTimelineRows(input: {
909 nextEntry.kind !== "work" ||930 nextEntry.kind !== "work" ||
910 nextEntry.entry.agentSpawn !== undefined ||931 nextEntry.entry.agentSpawn !== undefined ||
911 nextEntry.entry.tone === "error" ||932 nextEntry.entry.tone === "error" ||
933 workEntryRendersImagePreview(nextEntry.entry) ||
912 activeWorkEntryIds.has(nextEntry.id) ||934 activeWorkEntryIds.has(nextEntry.id) ||
913 collapsedEntryIds.has(nextEntry.id) ||935 collapsedEntryIds.has(nextEntry.id) ||
914 foldsByAnchorEntryId.has(nextEntry.id)936 foldsByAnchorEntryId.has(nextEntry.id)
apps/web/src/components/chat/MessagesTimeline.tsx+17-13
...@@ -3159,25 +3159,29 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {...@@ -3159,25 +3159,29 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
3159 </span>3159 </span>
3160 </div>3160 </div>
3161 </div>3161 </div>
3162 {viewedImage && threadRef ? (
3163 <div
3164 className="mt-1 ms-7 cursor-default"
3165 onClick={stopRowToggle}
3166 onPointerDown={stopRowToggle}
3167 >
3168 <ChatMarkdownAssetImage
3169 environmentId={threadRef.environmentId}
3170 resource={viewedImage.resource}
3171 alt={viewedImage.alt}
3172 srcFragment={viewedImage.srcFragment}
3173 workspaceRoot={workspaceRoot}
3174 style={{ maxHeight: "16rem" }}
3175 onImageExpand={onImageExpand}
3176 />
3177 </div>
3178 ) : null}
3162 {expanded && canExpand && expandedBody ? (3179 {expanded && canExpand && expandedBody ? (
3163 <div3180 <div
3164 className="mt-1 ms-7 cursor-default border-s border-border/45 ps-3 pt-0.5"3181 className="mt-1 ms-7 cursor-default border-s border-border/45 ps-3 pt-0.5"
3165 onClick={stopRowToggle}3182 onClick={stopRowToggle}
3166 onPointerDown={stopRowToggle}3183 onPointerDown={stopRowToggle}
3167 >3184 >
3168 {viewedImage && threadRef ? (
3169 <div className="mb-1.5">
3170 <ChatMarkdownAssetImage
3171 environmentId={threadRef.environmentId}
3172 resource={viewedImage.resource}
3173 alt={viewedImage.alt}
3174 srcFragment={viewedImage.srcFragment}
3175 workspaceRoot={workspaceRoot}
3176 style={{ maxHeight: "16rem" }}
3177 onImageExpand={onImageExpand}
3178 />
3179 </div>
3180 ) : null}
3181 <pre className={toolCallExpandedBodyClassName}>{expandedBody}</pre>3185 <pre className={toolCallExpandedBodyClassName}>{expandedBody}</pre>
3182 </div>3186 </div>
3183 ) : null}3187 ) : null}