| 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,6 +104,7 @@ import { |
| 104 | deriveWorkLogEntries, | 104 | deriveWorkLogEntries, |
| 105 | hasActionableProposedPlan, | 105 | hasActionableProposedPlan, |
| 106 | isLatestTurnSettled, | 106 | isLatestTurnSettled, |
| 107 | selectHandoffImageResources, | ||
| 107 | type TimelineEntriesProjection, | 108 | type TimelineEntriesProjection, |
| 108 | } from "../session-logic"; | 109 | } from "../session-logic"; |
| 109 | import { type LegendListRef } from "@legendapp/list/react"; | 110 | import { type LegendListRef } from "@legendapp/list/react"; |
| ... | @@ -2763,35 +2764,20 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -2763,35 +2764,20 @@ export default function ChatView(props: ChatViewProps) { |
| 2763 | }, | 2764 | }, |
| 2764 | [activeThreadRef, downloadFileAttachment], | 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 | const serverAttachmentResources = useMemo( | 2767 | const serverAttachmentResources = useMemo( |
| 2778 | () => | 2768 | () => selectHandoffImageResources(serverMessages, attachmentPreviewHandoffByMessageId), |
| 2779 | serverAttachmentIds.map((attachmentId) => ({ | 2769 | [serverMessages, attachmentPreviewHandoffByMessageId], |
| 2780 | _tag: "attachment" as const, | ||
| 2781 | attachmentId, | ||
| 2782 | })), | ||
| 2783 | [serverAttachmentIds], | ||
| 2784 | ); | 2770 | ); |
| 2785 | const serverAttachmentUrls = useAssetUrls(environmentId, serverAttachmentResources); | 2771 | const serverAttachmentUrls = useAssetUrls(environmentId, serverAttachmentResources); |
| 2786 | const serverAttachmentUrlById = useMemo( | 2772 | const serverAttachmentUrlById = useMemo( |
| 2787 | () => | 2773 | () => |
| 2788 | new Map( | 2774 | new Map( |
| 2789 | serverAttachmentIds.flatMap((attachmentId, index) => { | 2775 | serverAttachmentResources.flatMap((resource, index) => { |
| 2790 | const url = serverAttachmentUrls[index]; | 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 | const displayServerMessages = useMemo<ReadonlyArray<ChatMessage>>(() => { | 2782 | const displayServerMessages = useMemo<ReadonlyArray<ChatMessage>>(() => { |
| 2797 | if (!serverMessages) return []; | 2783 | if (!serverMessages) return []; |
apps/web/src/components/chat/MessagesTimeline.tsx+19-2| ... | @@ -52,7 +52,9 @@ import { | ... | @@ -52,7 +52,9 @@ import { |
| 52 | import { FileDiff } from "@pierre/diffs/react"; | 52 | import { FileDiff } from "@pierre/diffs/react"; |
| 53 | import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider"; | 53 | import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider"; |
| 54 | import { | 54 | import { |
| 55 | createMessageAttachmentPreviewProjector, | ||
| 55 | deriveTimelineEntries, | 56 | deriveTimelineEntries, |
| 57 | selectMessageImageResources, | ||
| 56 | workEntryDisplayIndicatesToolFailure, | 58 | workEntryDisplayIndicatesToolFailure, |
| 57 | workEntrySignalsSevereFailure, | 59 | workEntrySignalsSevereFailure, |
| 58 | workLogEntryIsToolLike, | 60 | workLogEntryIsToolLike, |
| ... | @@ -99,7 +101,7 @@ import { | ... | @@ -99,7 +101,7 @@ import { |
| 99 | ZapIcon, | 101 | ZapIcon, |
| 100 | } from "lucide-react"; | 102 | } from "lucide-react"; |
| 101 | import { Button } from "../ui/button"; | 103 | import { Button } from "../ui/button"; |
| 102 | import { useAssetUrlRefresh, useAssetUrlState } from "../../assets/assetUrls"; | 104 | import { useAssetUrlRefresh, useAssetUrls, useAssetUrlState } from "../../assets/assetUrls"; |
| 103 | import { MediaVideoPlayer } from "../media/MediaVideoPlayer"; | 105 | import { MediaVideoPlayer } from "../media/MediaVideoPlayer"; |
| 104 | import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area"; | 106 | import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area"; |
| 105 | import { | 107 | import { |
| ... | @@ -1227,9 +1229,24 @@ function UserVideoAttachment({ file }: { readonly file: ChatFileAttachment }) { | ... | @@ -1227,9 +1229,24 @@ function UserVideoAttachment({ file }: { readonly file: ChatFileAttachment }) { |
| 1227 | 1229 | ||
| 1228 | function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) { | 1230 | function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) { |
| 1229 | const ctx = use(TimelineRowCtx); | 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 | // The attachment union has an open member, so guards (not literal type | 1247 | // The attachment union has an open member, so guards (not literal type |
| 1231 | // comparisons) split it. Unknown types render as inert rows below the files. | 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 | const userFiles = (row.message.attachments ?? []).filter(isFileAttachment); | 1250 | const userFiles = (row.message.attachments ?? []).filter(isFileAttachment); |
| 1234 | const userVideos = userFiles.filter(isVideoAttachment); | 1251 | const userVideos = userFiles.filter(isVideoAttachment); |
| 1235 | const otherUserFiles = userFiles.filter((file) => !isVideoAttachment(file)); | 1252 | const otherUserFiles = userFiles.filter((file) => !isVideoAttachment(file)); |
apps/web/src/session-logic.test.ts+124| ... | @@ -21,6 +21,8 @@ import { | ... | @@ -21,6 +21,8 @@ import { |
| 21 | findLatestProposedPlan, | 21 | findLatestProposedPlan, |
| 22 | hasActionableProposedPlan, | 22 | hasActionableProposedPlan, |
| 23 | isLatestTurnSettled, | 23 | isLatestTurnSettled, |
| 24 | selectHandoffImageResources, | ||
| 25 | selectMessageImageResources, | ||
| 24 | workEntryIndicatesToolFailure, | 26 | workEntryIndicatesToolFailure, |
| 25 | workEntryIndicatesToolNeutralStatus, | 27 | workEntryIndicatesToolNeutralStatus, |
| 26 | workEntryIndicatesToolSuccess, | 28 | workEntryIndicatesToolSuccess, |
| ... | @@ -2075,6 +2077,128 @@ describe("deriveWorkLogEntries", () => { | ... | @@ -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 | describe("deriveTimelineEntries", () => { | 2202 | describe("deriveTimelineEntries", () => { |
| 2079 | const streamingMessage = { | 2203 | const streamingMessage = { |
| 2080 | id: MessageId.make("streaming-message"), | 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,6 +12,7 @@ import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-lo |
| 12 | import { | 12 | import { |
| 13 | ApprovalRequestId, | 13 | ApprovalRequestId, |
| 14 | isToolLifecycleItemType, | 14 | isToolLifecycleItemType, |
| 15 | type AssetResource, | ||
| 15 | type OrchestrationLatestTurn, | 16 | type OrchestrationLatestTurn, |
| 16 | type OrchestrationThreadActivity, | 17 | type OrchestrationThreadActivity, |
| 17 | type OrchestrationProposedPlanId, | 18 | type OrchestrationProposedPlanId, |
| ... | @@ -24,14 +25,15 @@ import { | ... | @@ -24,14 +25,15 @@ import { |
| 24 | type TurnId, | 25 | type TurnId, |
| 25 | } from "@t3tools/contracts"; | 26 | } from "@t3tools/contracts"; |
| 26 | 27 | ||
| 27 | import type { | 28 | import { |
| 28 | ChatAttachment, | 29 | isImageAttachment, |
| 29 | ChatMessage, | 30 | type ChatAttachment, |
| 30 | ProposedPlan, | 31 | type ChatMessage, |
| 31 | SessionPhase, | 32 | type ProposedPlan, |
| 32 | Thread, | 33 | type SessionPhase, |
| 33 | ThreadSession, | 34 | type Thread, |
| 34 | TurnDiffSummary, | 35 | type ThreadSession, |
| 36 | type TurnDiffSummary, | ||
| 35 | } from "./types"; | 37 | } from "./types"; |
| 36 | 38 | ||
| 37 | export type ProviderPickerKind = ProviderDriverKind; | 39 | export type ProviderPickerKind = ProviderDriverKind; |
| ... | @@ -1883,6 +1885,43 @@ function mergeTimelineEntrySuffix( | ... | @@ -1883,6 +1885,43 @@ function mergeTimelineEntrySuffix( |
| 1883 | return merged; | 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 | /** Own one mapper per preview stage. Immutable messages retain unchanged preview objects. */ | 1925 | /** Own one mapper per preview stage. Immutable messages retain unchanged preview objects. */ |
| 1887 | export function createMessageAttachmentPreviewProjector() { | 1926 | export function createMessageAttachmentPreviewProjector() { |
| 1888 | const attachmentsBySource = new WeakMap< | 1927 | const attachmentsBySource = new WeakMap< |