| author | |
| committer | |
| log | 652515a349741d234111b85f27597be3265d1ffc |
| tree | ecaec83e4cf64966569ebb96ccd79067865c7e27 |
| parent | 044ea8e347d980a215057c325de8335d38f0f9aa |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 145 | expect(html).not.toContain("Image unavailable"); |
| 146 | 146 | }); |
| 147 | 147 | |
| 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 | "", | |
| 155 | "", | |
| 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 | 167 | it("normalizes a drive-absolute src in raw image HTML", () => { |
| 149 | 168 | const html = render(String.raw`<img src="D:\screens\workspace-image.svg" alt="raw">`); |
| 150 | 169 |
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+136| ... | ... | @@ -12,6 +12,7 @@ import { |
| 12 | 12 | shouldPreserveAssistantLineBreaks, |
| 13 | 13 | type MessagesTimelineRow, |
| 14 | 14 | workEntryDisplayLabel, |
| 15 | workEntryIsVisibleInGroup, | |
| 15 | 16 | } from "./MessagesTimeline.logic"; |
| 16 | 17 | |
| 17 | 18 | describe("expanded tool group scrolling", () => { |
| ... | ... | @@ -471,6 +472,22 @@ describe("resolveAssistantMessageCopyState", () => { |
| 471 | 472 | }); |
| 472 | 473 | }); |
| 473 | 474 | |
| 475 | describe("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 | ||
| 474 | 491 | describe("deriveMessagesTimelineRows", () => { |
| 475 | 492 | it("only enables assistant copy for the terminal assistant message in a turn", () => { |
| 476 | 493 | const rows = deriveMessagesTimelineRows({ |
| ... | ... | @@ -834,6 +851,125 @@ describe("deriveMessagesTimelineRows", () => { |
| 834 | 851 | }); |
| 835 | 852 | }); |
| 836 | 853 | |
| 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 | 973 | it("folds all assistant messages before the terminal message", () => { |
| 838 | 974 | const timelineEntries = [ |
| 839 | 975 | { |
apps/web/src/components/chat/MessagesTimeline.logic.ts+24-2| ... | ... | @@ -9,6 +9,7 @@ import { |
| 9 | 9 | summarizeToolGroup, |
| 10 | 10 | toolGroupAction, |
| 11 | 11 | toolGroupSummaryKind, |
| 12 | workEntryViewedImagePath, | |
| 12 | 13 | type ToolGroupSummaryKind, |
| 13 | 14 | } from "@t3tools/client-runtime/work-log/presentation"; |
| 14 | 15 | export { |
| ... | ... | @@ -89,6 +90,15 @@ export function liveWorkEntryLabel( |
| 89 | 90 | return workEntryDisplayLabel(entry, workspaceRoot); |
| 90 | 91 | } |
| 91 | 92 | |
| 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 | */ | |
| 98 | function workEntryRendersImagePreview(entry: WorkLogEntry): boolean { | |
| 99 | return workEntryViewedImagePath(entry) !== null; | |
| 100 | } | |
| 101 | ||
| 92 | 102 | export function workEntryIsVisibleInGroup( |
| 93 | 103 | entry: WorkLogEntry, |
| 94 | 104 | expandedToolGroupEntry = false, |
| ... | ... | @@ -97,6 +107,9 @@ export function workEntryIsVisibleInGroup( |
| 97 | 107 | (expandedToolGroupEntry && |
| 98 | 108 | (entry.toolLifecycleStatus === "inProgress" || |
| 99 | 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 | 113 | !workEntryIndicatesToolNeutralStatus(entry) |
| 101 | 114 | ); |
| 102 | 115 | } |
| ... | ... | @@ -591,6 +604,9 @@ function deriveTurnFolds(input: { |
| 591 | 604 | if (entry.kind === "work" && entry.entry.agentSpawn !== undefined) { |
| 592 | 605 | continue; |
| 593 | 606 | } |
| 607 | if (entry.kind === "work" && workEntryRendersImagePreview(entry.entry)) { | |
| 608 | continue; | |
| 609 | } | |
| 594 | 610 | hiddenEntryIds.add(entry.id); |
| 595 | 611 | } |
| 596 | 612 | if (hiddenEntryIds.size === 0) { |
| ... | ... | @@ -787,7 +803,8 @@ export function deriveMessagesTimelineRows(input: { |
| 787 | 803 | !entryBelongsToActiveTurn(entry, index) || |
| 788 | 804 | entry.kind !== "work" || |
| 789 | 805 | entry.entry.agentSpawn !== undefined || |
| 790 | entry.entry.tone === "error" | |
| 806 | entry.entry.tone === "error" || | |
| 807 | workEntryRendersImagePreview(entry.entry) | |
| 791 | 808 | ) { |
| 792 | 809 | break; |
| 793 | 810 | } |
| ... | ... | @@ -890,7 +907,11 @@ export function deriveMessagesTimelineRows(input: { |
| 890 | 907 | } |
| 891 | 908 | |
| 892 | 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 | 915 | nextRows.push({ |
| 895 | 916 | kind: "work", |
| 896 | 917 | id: timelineEntry.id, |
| ... | ... | @@ -909,6 +930,7 @@ export function deriveMessagesTimelineRows(input: { |
| 909 | 930 | nextEntry.kind !== "work" || |
| 910 | 931 | nextEntry.entry.agentSpawn !== undefined || |
| 911 | 932 | nextEntry.entry.tone === "error" || |
| 933 | workEntryRendersImagePreview(nextEntry.entry) || | |
| 912 | 934 | activeWorkEntryIds.has(nextEntry.id) || |
| 913 | 935 | collapsedEntryIds.has(nextEntry.id) || |
| 914 | 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 | 3159 | </span> |
| 3160 | 3160 | </div> |
| 3161 | 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 | 3179 | {expanded && canExpand && expandedBody ? ( |
| 3163 | 3180 | <div |
| 3164 | 3181 | className="mt-1 ms-7 cursor-default border-s border-border/45 ps-3 pt-0.5" |
| 3165 | 3182 | onClick={stopRowToggle} |
| 3166 | 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 | 3185 | <pre className={toolCallExpandedBodyClassName}>{expandedBody}</pre> |
| 3182 | 3186 | </div> |
| 3183 | 3187 | ) : null} |