| author | |
| committer | |
| log | 15eda897d37aad232fca5089f5eb6ef7b726c31c |
| tree | 434de06d2fc2e87cb85ecc65bbf7d37c17d449d4 |
| parent | 6365919f2e5bcfb4fa4020b95e19af26ae40979f |
| signature | Signed by PGP key B5690EEEBB952194 |
4 files changed, 196 insertions(+), 30 deletions(-)
apps/web/src/components/ChatView.tsx+6-20| ... | ... | @@ -104,6 +104,7 @@ import { |
| 104 | 104 | deriveWorkLogEntries, |
| 105 | 105 | hasActionableProposedPlan, |
| 106 | 106 | isLatestTurnSettled, |
| 107 | selectHandoffImageResources, | |
| 107 | 108 | type TimelineEntriesProjection, |
| 108 | 109 | } from "../session-logic"; |
| 109 | 110 | import { type LegendListRef } from "@legendapp/list/react"; |
| ... | ... | @@ -2763,35 +2764,20 @@ export default function ChatView(props: ChatViewProps) { |
| 2763 | 2764 | }, |
| 2764 | 2765 | [activeThreadRef, downloadFileAttachment], |
| 2765 | 2766 | ); |
| 2766 | const serverAttachmentIds = useMemo(() => { | |
| 2767 | const attachmentIds = new Set<string>(); | |
| 2768 | for (const message of serverMessages ?? []) { | |
| 2769 | for (const attachment of message.attachments ?? []) { | |
| 2770 | if (isImageAttachment(attachment)) { | |
| 2771 | attachmentIds.add(attachment.id); | |
| 2772 | } | |
| 2773 | } | |
| 2774 | } | |
| 2775 | return [...attachmentIds]; | |
| 2776 | }, [serverMessages]); | |
| 2777 | 2767 | const serverAttachmentResources = useMemo( |
| 2778 | () => | |
| 2779 | serverAttachmentIds.map((attachmentId) => ({ | |
| 2780 | _tag: "attachment" as const, | |
| 2781 | attachmentId, | |
| 2782 | })), | |
| 2783 | [serverAttachmentIds], | |
| 2768 | () => selectHandoffImageResources(serverMessages, attachmentPreviewHandoffByMessageId), | |
| 2769 | [serverMessages, attachmentPreviewHandoffByMessageId], | |
| 2784 | 2770 | ); |
| 2785 | 2771 | const serverAttachmentUrls = useAssetUrls(environmentId, serverAttachmentResources); |
| 2786 | 2772 | const serverAttachmentUrlById = useMemo( |
| 2787 | 2773 | () => |
| 2788 | 2774 | new Map( |
| 2789 | serverAttachmentIds.flatMap((attachmentId, index) => { | |
| 2775 | serverAttachmentResources.flatMap((resource, index) => { | |
| 2790 | 2776 | const url = serverAttachmentUrls[index]; |
| 2791 | return url ? [[attachmentId, url] as const] : []; | |
| 2777 | return url ? [[resource.attachmentId, url] as const] : []; | |
| 2792 | 2778 | }), |
| 2793 | 2779 | ), |
| 2794 | [serverAttachmentIds, serverAttachmentUrls], | |
| 2780 | [serverAttachmentResources, serverAttachmentUrls], | |
| 2795 | 2781 | ); |
| 2796 | 2782 | const displayServerMessages = useMemo<ReadonlyArray<ChatMessage>>(() => { |
| 2797 | 2783 | if (!serverMessages) return []; |
apps/web/src/components/chat/MessagesTimeline.tsx+19-2| ... | ... | @@ -52,7 +52,9 @@ import { |
| 52 | 52 | import { FileDiff } from "@pierre/diffs/react"; |
| 53 | 53 | import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider"; |
| 54 | 54 | import { |
| 55 | createMessageAttachmentPreviewProjector, | |
| 55 | 56 | deriveTimelineEntries, |
| 57 | selectMessageImageResources, | |
| 56 | 58 | workEntryDisplayIndicatesToolFailure, |
| 57 | 59 | workEntrySignalsSevereFailure, |
| 58 | 60 | workLogEntryIsToolLike, |
| ... | ... | @@ -99,7 +101,7 @@ import { |
| 99 | 101 | ZapIcon, |
| 100 | 102 | } from "lucide-react"; |
| 101 | 103 | import { Button } from "../ui/button"; |
| 102 | import { useAssetUrlRefresh, useAssetUrlState } from "../../assets/assetUrls"; | |
| 104 | import { useAssetUrlRefresh, useAssetUrls, useAssetUrlState } from "../../assets/assetUrls"; | |
| 103 | 105 | import { MediaVideoPlayer } from "../media/MediaVideoPlayer"; |
| 104 | 106 | import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area"; |
| 105 | 107 | import { |
| ... | ... | @@ -1227,9 +1229,24 @@ function UserVideoAttachment({ file }: { readonly file: ChatFileAttachment }) { |
| 1227 | 1229 | |
| 1228 | 1230 | function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) { |
| 1229 | 1231 | const ctx = use(TimelineRowCtx); |
| 1232 | const resources = useMemo( | |
| 1233 | () => selectMessageImageResources(row.message.attachments), | |
| 1234 | [row.message.attachments], | |
| 1235 | ); | |
| 1236 | const previewUrls = useAssetUrls(ctx.activeThreadEnvironmentId, resources); | |
| 1237 | const [projectPreviews] = useState(createMessageAttachmentPreviewProjector); | |
| 1238 | const messageWithPreviews = useMemo(() => { | |
| 1239 | const urlsById = new Map( | |
| 1240 | resources.flatMap((resource, index) => { | |
| 1241 | const url = previewUrls[index]; | |
| 1242 | return url ? [[resource.attachmentId, url] as const] : []; | |
| 1243 | }), | |
| 1244 | ); | |
| 1245 | return projectPreviews(row.message, (attachment) => urlsById.get(attachment.id)); | |
| 1246 | }, [previewUrls, projectPreviews, resources, row.message]); | |
| 1230 | 1247 | // The attachment union has an open member, so guards (not literal type |
| 1231 | 1248 | // comparisons) split it. Unknown types render as inert rows below the files. |
| 1232 | const userImages = (row.message.attachments ?? []).filter(isImageAttachment); | |
| 1249 | const userImages = (messageWithPreviews.attachments ?? []).filter(isImageAttachment); | |
| 1233 | 1250 | const userFiles = (row.message.attachments ?? []).filter(isFileAttachment); |
| 1234 | 1251 | const userVideos = userFiles.filter(isVideoAttachment); |
| 1235 | 1252 | const otherUserFiles = userFiles.filter((file) => !isVideoAttachment(file)); |
apps/web/src/session-logic.test.ts+124| ... | ... | @@ -21,6 +21,8 @@ import { |
| 21 | 21 | findLatestProposedPlan, |
| 22 | 22 | hasActionableProposedPlan, |
| 23 | 23 | isLatestTurnSettled, |
| 24 | selectHandoffImageResources, | |
| 25 | selectMessageImageResources, | |
| 24 | 26 | workEntryIndicatesToolFailure, |
| 25 | 27 | workEntryIndicatesToolNeutralStatus, |
| 26 | 28 | workEntryIndicatesToolSuccess, |
| ... | ... | @@ -2075,6 +2077,128 @@ describe("deriveWorkLogEntries", () => { |
| 2075 | 2077 | }); |
| 2076 | 2078 | }); |
| 2077 | 2079 | |
| 2080 | describe("image asset requests", () => { | |
| 2081 | const image = { | |
| 2082 | type: "image" as const, | |
| 2083 | id: "image", | |
| 2084 | name: "image.png", | |
| 2085 | mimeType: "image/png", | |
| 2086 | sizeBytes: 42, | |
| 2087 | }; | |
| 2088 | const message = { | |
| 2089 | id: MessageId.make("image-message"), | |
| 2090 | role: "user" as const, | |
| 2091 | text: "Inspect these images", | |
| 2092 | turnId: null, | |
| 2093 | createdAt: "2026-09-04T00:00:00.000Z", | |
| 2094 | updatedAt: "2026-09-04T00:00:00.000Z", | |
| 2095 | streaming: false, | |
| 2096 | attachments: [image], | |
| 2097 | }; | |
| 2098 | ||
| 2099 | it("requests the whole row's gallery and crops without signing local preview IDs", () => { | |
| 2100 | const attachments = Object.freeze([ | |
| 2101 | image, | |
| 2102 | { ...image, id: "second" }, | |
| 2103 | { ...image, id: "crop", name: "preview-annotation-1.png" }, | |
| 2104 | { ...image, id: "local", previewUrl: "blob:local" }, | |
| 2105 | { ...image, id: "inline", previewUrl: "data:image/png;base64,AA==" }, | |
| 2106 | { ...image, id: "provided", previewUrl: "https://preview.test/image" }, | |
| 2107 | { ...image, type: "file" as const, id: "file", mimeType: "application/pdf" }, | |
| 2108 | { ...image, type: "future", id: "unknown" }, | |
| 2109 | image, | |
| 2110 | ]); | |
| 2111 | ||
| 2112 | expect(selectMessageImageResources(attachments)).toEqual([ | |
| 2113 | { _tag: "attachment", attachmentId: "image" }, | |
| 2114 | { _tag: "attachment", attachmentId: "second" }, | |
| 2115 | { _tag: "attachment", attachmentId: "crop" }, | |
| 2116 | { _tag: "attachment", attachmentId: "provided" }, | |
| 2117 | ]); | |
| 2118 | }); | |
| 2119 | ||
| 2120 | it("requests offscreen handoffs without signing the rest of the loaded history", () => { | |
| 2121 | const history = { | |
| 2122 | ...message, | |
| 2123 | id: MessageId.make("history"), | |
| 2124 | attachments: [{ ...image, id: "history-image" }], | |
| 2125 | }; | |
| 2126 | const offscreen = { | |
| 2127 | ...message, | |
| 2128 | id: MessageId.make("offscreen"), | |
| 2129 | attachments: [image, { ...image, id: "crop", name: "preview-annotation-1.png" }], | |
| 2130 | }; | |
| 2131 | const empty = { | |
| 2132 | ...message, | |
| 2133 | id: MessageId.make("empty"), | |
| 2134 | attachments: [{ ...image, id: "empty" }], | |
| 2135 | }; | |
| 2136 | const assistant = { | |
| 2137 | ...message, | |
| 2138 | id: MessageId.make("assistant"), | |
| 2139 | role: "assistant" as const, | |
| 2140 | attachments: [{ ...image, id: "assistant-image" }], | |
| 2141 | }; | |
| 2142 | expect( | |
| 2143 | selectHandoffImageResources([history, message, offscreen, empty, assistant], { | |
| 2144 | [message.id]: ["blob:message"], | |
| 2145 | [offscreen.id]: ["blob:offscreen", "blob:crop"], | |
| 2146 | [empty.id]: [], | |
| 2147 | [assistant.id]: ["blob:unused"], | |
| 2148 | }), | |
| 2149 | ).toEqual([ | |
| 2150 | { _tag: "attachment", attachmentId: "image" }, | |
| 2151 | { _tag: "attachment", attachmentId: "crop" }, | |
| 2152 | ]); | |
| 2153 | }); | |
| 2154 | ||
| 2155 | it("does not scan history when no handoff is pending", () => { | |
| 2156 | let reads = 0; | |
| 2157 | const messages = new Proxy([message], { | |
| 2158 | get(target, property, receiver) { | |
| 2159 | if (property === "0") reads += 1; | |
| 2160 | return Reflect.get(target, property, receiver); | |
| 2161 | }, | |
| 2162 | }); | |
| 2163 | const empty = selectHandoffImageResources(messages, {}); | |
| 2164 | expect(reads).toBe(0); | |
| 2165 | expect(empty).toHaveLength(0); | |
| 2166 | expect(selectHandoffImageResources(undefined, { missing: ["blob:missing"] })).toBe(empty); | |
| 2167 | expect(selectMessageImageResources(undefined)).toBe(empty); | |
| 2168 | }); | |
| 2169 | ||
| 2170 | it("hands signed URLs to a mounted row only after the local preview is released", () => { | |
| 2171 | const server = createMessageAttachmentPreviewProjector(); | |
| 2172 | const handoff = createMessageAttachmentPreviewProjector(); | |
| 2173 | const row = createMessageAttachmentPreviewProjector(); | |
| 2174 | const pending = handoff( | |
| 2175 | server(message, () => undefined), | |
| 2176 | () => "blob:pending", | |
| 2177 | ); | |
| 2178 | expect(selectMessageImageResources(pending.attachments)).toEqual([]); | |
| 2179 | expect(selectHandoffImageResources([message], { [message.id]: ["blob:pending"] })).toEqual([ | |
| 2180 | { _tag: "attachment", attachmentId: image.id }, | |
| 2181 | ]); | |
| 2182 | ||
| 2183 | const ready = server(message, () => "https://server.test/image"); | |
| 2184 | expect(selectMessageImageResources(handoff(ready, () => "blob:pending").attachments)).toEqual( | |
| 2185 | [], | |
| 2186 | ); | |
| 2187 | const released = server(message, () => undefined); | |
| 2188 | expect(selectMessageImageResources(released.attachments)).toEqual([ | |
| 2189 | { _tag: "attachment", attachmentId: image.id }, | |
| 2190 | ]); | |
| 2191 | const displayed = row(released, () => "https://server.test/image"); | |
| 2192 | expect(displayed).toEqual(ready); | |
| 2193 | expect(pending.attachments?.[0]).toMatchObject({ previewUrl: "blob:pending" }); | |
| 2194 | expect(row(released, () => "https://server.test/renewed").attachments?.[0]).toMatchObject({ | |
| 2195 | previewUrl: "https://server.test/renewed", | |
| 2196 | }); | |
| 2197 | expect(displayed.attachments?.[0]).toMatchObject({ previewUrl: "https://server.test/image" }); | |
| 2198 | expect(row(released, () => undefined)).toBe(message); | |
| 2199 | }); | |
| 2200 | }); | |
| 2201 | ||
| 2078 | 2202 | describe("deriveTimelineEntries", () => { |
| 2079 | 2203 | const streamingMessage = { |
| 2080 | 2204 | id: MessageId.make("streaming-message"), |
apps/web/src/session-logic.ts+47-8| ... | ... | @@ -12,6 +12,7 @@ import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-lo |
| 12 | 12 | import { |
| 13 | 13 | ApprovalRequestId, |
| 14 | 14 | isToolLifecycleItemType, |
| 15 | type AssetResource, | |
| 15 | 16 | type OrchestrationLatestTurn, |
| 16 | 17 | type OrchestrationThreadActivity, |
| 17 | 18 | type OrchestrationProposedPlanId, |
| ... | ... | @@ -24,14 +25,15 @@ import { |
| 24 | 25 | type TurnId, |
| 25 | 26 | } from "@t3tools/contracts"; |
| 26 | 27 | |
| 27 | import type { | |
| 28 | ChatAttachment, | |
| 29 | ChatMessage, | |
| 30 | ProposedPlan, | |
| 31 | SessionPhase, | |
| 32 | Thread, | |
| 33 | ThreadSession, | |
| 34 | TurnDiffSummary, | |
| 28 | import { | |
| 29 | isImageAttachment, | |
| 30 | type ChatAttachment, | |
| 31 | type ChatMessage, | |
| 32 | type ProposedPlan, | |
| 33 | type SessionPhase, | |
| 34 | type Thread, | |
| 35 | type ThreadSession, | |
| 36 | type TurnDiffSummary, | |
| 35 | 37 | } from "./types"; |
| 36 | 38 | |
| 37 | 39 | export type ProviderPickerKind = ProviderDriverKind; |
| ... | ... | @@ -1883,6 +1885,43 @@ function mergeTimelineEntrySuffix( |
| 1883 | 1885 | return merged; |
| 1884 | 1886 | } |
| 1885 | 1887 | |
| 1888 | type AttachmentResource = Extract<AssetResource, { readonly _tag: "attachment" }>; | |
| 1889 | const EMPTY_IMAGE_RESOURCES = Object.freeze<ReadonlyArray<AttachmentResource>>([]); | |
| 1890 | ||
| 1891 | /** A mounted row requests its stored images. Local previews keep their existing URLs. */ | |
| 1892 | export function selectMessageImageResources( | |
| 1893 | attachments: ChatMessage["attachments"], | |
| 1894 | ): ReadonlyArray<AttachmentResource> { | |
| 1895 | const attachmentIds = new Set<string>(); | |
| 1896 | for (const attachment of attachments ?? []) { | |
| 1897 | if (!isImageAttachment(attachment)) continue; | |
| 1898 | const previewUrl = attachment.previewUrl; | |
| 1899 | if (previewUrl?.startsWith("blob:") || previewUrl?.startsWith("data:")) continue; | |
| 1900 | attachmentIds.add(attachment.id); | |
| 1901 | } | |
| 1902 | return attachmentIds.size === 0 | |
| 1903 | ? EMPTY_IMAGE_RESOURCES | |
| 1904 | : Array.from(attachmentIds, (attachmentId) => ({ _tag: "attachment", attachmentId })); | |
| 1905 | } | |
| 1906 | ||
| 1907 | /** Handoffs need server URLs even while their message rows are unmounted. */ | |
| 1908 | export function selectHandoffImageResources( | |
| 1909 | messages: ReadonlyArray<ChatMessage> | undefined, | |
| 1910 | handoffs: Readonly<Record<string, ReadonlyArray<string>>>, | |
| 1911 | ): ReadonlyArray<AttachmentResource> { | |
| 1912 | if (Object.keys(handoffs).length === 0) return EMPTY_IMAGE_RESOURCES; | |
| 1913 | const attachmentIds = new Set<string>(); | |
| 1914 | for (const message of messages ?? []) { | |
| 1915 | if (message.role !== "user" || !handoffs[message.id]?.length) continue; | |
| 1916 | for (const attachment of message.attachments ?? []) { | |
| 1917 | if (isImageAttachment(attachment)) attachmentIds.add(attachment.id); | |
| 1918 | } | |
| 1919 | } | |
| 1920 | return attachmentIds.size === 0 | |
| 1921 | ? EMPTY_IMAGE_RESOURCES | |
| 1922 | : Array.from(attachmentIds, (attachmentId) => ({ _tag: "attachment", attachmentId })); | |
| 1923 | } | |
| 1924 | ||
| 1886 | 1925 | /** Own one mapper per preview stage. Immutable messages retain unchanged preview objects. */ |
| 1887 | 1926 | export function createMessageAttachmentPreviewProjector() { |
| 1888 | 1927 | const attachmentsBySource = new WeakMap< |