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", () => {
145145 expect(html).not.toContain("Image unavailable");
146146 });
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
148167 it("normalizes a drive-absolute src in raw image HTML", () => {
149168 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 {
1212 shouldPreserveAssistantLineBreaks,
1313 type MessagesTimelineRow,
1414 workEntryDisplayLabel,
15 workEntryIsVisibleInGroup,
1516} from "./MessagesTimeline.logic";
1617
1718describe("expanded tool group scrolling", () => {
......@@ -471,6 +472,22 @@ describe("resolveAssistantMessageCopyState", () => {
471472 });
472473});
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
474491describe("deriveMessagesTimelineRows", () => {
475492 it("only enables assistant copy for the terminal assistant message in a turn", () => {
476493 const rows = deriveMessagesTimelineRows({
......@@ -834,6 +851,125 @@ describe("deriveMessagesTimelineRows", () => {
834851 });
835852 });
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
837973 it("folds all assistant messages before the terminal message", () => {
838974 const timelineEntries = [
839975 {
apps/web/src/components/chat/MessagesTimeline.logic.ts+24-2
......@@ -9,6 +9,7 @@ import {
99 summarizeToolGroup,
1010 toolGroupAction,
1111 toolGroupSummaryKind,
12 workEntryViewedImagePath,
1213 type ToolGroupSummaryKind,
1314} from "@t3tools/client-runtime/work-log/presentation";
1415export {
......@@ -89,6 +90,15 @@ export function liveWorkEntryLabel(
8990 return workEntryDisplayLabel(entry, workspaceRoot);
9091}
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
92102export function workEntryIsVisibleInGroup(
93103 entry: WorkLogEntry,
94104 expandedToolGroupEntry = false,
......@@ -97,6 +107,9 @@ export function workEntryIsVisibleInGroup(
97107 (expandedToolGroupEntry &&
98108 (entry.toolLifecycleStatus === "inProgress" ||
99109 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) ||
100113 !workEntryIndicatesToolNeutralStatus(entry)
101114 );
102115}
......@@ -591,6 +604,9 @@ function deriveTurnFolds(input: {
591604 if (entry.kind === "work" && entry.entry.agentSpawn !== undefined) {
592605 continue;
593606 }
607 if (entry.kind === "work" && workEntryRendersImagePreview(entry.entry)) {
608 continue;
609 }
594610 hiddenEntryIds.add(entry.id);
595611 }
596612 if (hiddenEntryIds.size === 0) {
......@@ -787,7 +803,8 @@ export function deriveMessagesTimelineRows(input: {
787803 !entryBelongsToActiveTurn(entry, index) ||
788804 entry.kind !== "work" ||
789805 entry.entry.agentSpawn !== undefined ||
790 entry.entry.tone === "error"
806 entry.entry.tone === "error" ||
807 workEntryRendersImagePreview(entry.entry)
791808 ) {
792809 break;
793810 }
......@@ -890,7 +907,11 @@ export function deriveMessagesTimelineRows(input: {
890907 }
891908
892909 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 ) {
894915 nextRows.push({
895916 kind: "work",
896917 id: timelineEntry.id,
......@@ -909,6 +930,7 @@ export function deriveMessagesTimelineRows(input: {
909930 nextEntry.kind !== "work" ||
910931 nextEntry.entry.agentSpawn !== undefined ||
911932 nextEntry.entry.tone === "error" ||
933 workEntryRendersImagePreview(nextEntry.entry) ||
912934 activeWorkEntryIds.has(nextEntry.id) ||
913935 collapsedEntryIds.has(nextEntry.id) ||
914936 foldsByAnchorEntryId.has(nextEntry.id)
apps/web/src/components/chat/MessagesTimeline.tsx+17-13
......@@ -3159,25 +3159,29 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
31593159 </span>
31603160 </div>
31613161 </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}
31623179 {expanded && canExpand && expandedBody ? (
31633180 <div
31643181 className="mt-1 ms-7 cursor-default border-s border-border/45 ps-3 pt-0.5"
31653182 onClick={stopRowToggle}
31663183 onPointerDown={stopRowToggle}
31673184 >
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}
31813185 <pre className={toolCallExpandedBodyClassName}>{expandedBody}</pre>
31823186 </div>
31833187 ) : null}