History

fix: reconcile main's round-16 features after the rebase

Round-16 rebase onto main (5 commits, headlined by #8236 file attachments in the client). Main's upload-aware send path — capability probes, upload await/retry gating, uploaded-ref turn attachments with dataUrl fallback, draft release on success — is woven into the v2 dispatch flow, timeline user rows render file attachments as download links with the ChatView download handler, and the provider settings editor cleanup keeps the branch's environment-field rows. The codex feedback client flow stays unported, replay testkit configs gained main's environmentThemesDir, and the rpc/settings/docs unions carry both sides.

Julius Marminge committed 2 months ago

Commit e2bc2221 parent dad690613 files · +808 −192Browse files

Unified · Split

13 files
  1. apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.testkit.ts +3 −0
  2. apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts +3 −0
  3. apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts +3 −0
  4. apps/server/src/ws.ts +1 −0
  5. apps/web/src/components/ChatView.logic.test.ts +1 −0
  6. apps/web/src/components/ChatView.logic.ts +1 −0
  7. apps/web/src/components/ChatView.tsx +177 −22
  8. apps/web/src/components/chat/ChatComposer.tsx +279 −33
  9. apps/web/src/components/chat/MessagesTimeline.test.tsx +227 −0
  10. apps/web/src/components/chat/MessagesTimeline.tsx +68 −2
  11. apps/web/src/components/settings/ProviderInstanceCard.tsx +39 −135
  12. apps/web/src/types.ts +5 −0
  13. docs/README.md +1 −0
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.testkit.ts+3−0
@@ -74,6 +74,7 @@ export function makeReplayServerConfig( · 73 unmodified lines
7474 const providerLogsDir = path.join(logsDir, "provider");
7575 const terminalLogsDir = path.join(logsDir, "terminals");
7676 const attachmentsDir = path.join(stateDir, "attachments");
77+ const environmentThemesDir = path.join(stateDir, "themes");
7778 const worktreesDir = path.join(baseDir, "worktrees");
7879 const providerStatusCacheDir = path.join(baseDir, "caches");
7980
@@ -83,6 +84,7 @@ export function makeReplayServerConfig( · 3 unmodified lines
8384 providerLogsDir,
8485 terminalLogsDir,
8586 attachmentsDir,
87+ environmentThemesDir,
8688 worktreesDir,
8789 providerStatusCacheDir,
8890 ]) {
@@ -122,6 +124,7 @@ export function makeReplayServerConfig( · 33 unmodified lines
122124 providerStatusCacheDir,
123125 worktreesDir,
124126 attachmentsDir,
127+ environmentThemesDir,
125128 logsDir,
126129 serverLogPath: path.join(logsDir, "server.log"),
127130 serverTracePath: path.join(logsDir, "server.trace.ndjson"),
apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts+3−0
@@ -537,6 +537,7 @@ function makeReplayServerConfig( · 536 unmodified lines
537537 const providerLogsDir = path.join(logsDir, "provider");
538538 const terminalLogsDir = path.join(logsDir, "terminals");
539539 const attachmentsDir = path.join(stateDir, "attachments");
540+ const environmentThemesDir = path.join(stateDir, "themes");
540541 const worktreesDir = path.join(baseDir, "worktrees");
541542 const providerStatusCacheDir = path.join(baseDir, "caches");
542543 for (const directory of [
@@ -545,6 +546,7 @@ function makeReplayServerConfig( · 2 unmodified lines
545546 providerLogsDir,
546547 terminalLogsDir,
547548 attachmentsDir,
549+ environmentThemesDir,
548550 worktreesDir,
549551 providerStatusCacheDir,
550552 ]) {
@@ -583,6 +585,7 @@ function makeReplayServerConfig( · 32 unmodified lines
583585 providerStatusCacheDir,
584586 worktreesDir,
585587 attachmentsDir,
588+ environmentThemesDir,
586589 logsDir,
587590 serverLogPath: path.join(logsDir, "server.log"),
588591 serverTracePath: path.join(logsDir, "server.trace.ndjson"),
apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts+3−0
@@ -78,6 +78,7 @@ export function makeReplayServerConfig( · 77 unmodified lines
7878 const providerLogsDir = path.join(logsDir, "provider");
7979 const terminalLogsDir = path.join(logsDir, "terminals");
8080 const attachmentsDir = path.join(stateDir, "attachments");
81+ const environmentThemesDir = path.join(stateDir, "themes");
8182 const worktreesDir = path.join(baseDir, "worktrees");
8283 const providerStatusCacheDir = path.join(baseDir, "caches");
8384
@@ -87,6 +88,7 @@ export function makeReplayServerConfig( · 3 unmodified lines
8788 providerLogsDir,
8889 terminalLogsDir,
8990 attachmentsDir,
91+ environmentThemesDir,
9092 worktreesDir,
9193 providerStatusCacheDir,
9294 ]) {
@@ -126,6 +128,7 @@ export function makeReplayServerConfig( · 33 unmodified lines
126128 providerStatusCacheDir,
127129 worktreesDir,
128130 attachmentsDir,
131+ environmentThemesDir,
129132 logsDir,
130133 serverLogPath: path.join(logsDir, "server.log"),
131134 serverTracePath: path.join(logsDir, "server.trace.ndjson"),
apps/server/src/ws.ts+1−0
@@ -560,6 +560,7 @@ const makeWsRpcLayer = ( · 559 unmodified lines
560560 const projectService = yield* ProjectService.ProjectService;
561561 const checkpointDiffQuery = yield* CheckpointDiffQuery.CheckpointDiffQuery;
562562 const keybindings = yield* Keybindings.Keybindings;
563+ const environmentTheme = yield* EnvironmentTheme.EnvironmentThemeService;
563564 const externalLauncher = yield* ExternalLauncher.ExternalLauncher;
564565 const remoteOpenTargets = yield* RemoteOpenTargets.RemoteOpenTargets;
565566 const gitWorkflow = yield* GitWorkflowService.GitWorkflowService;
apps/web/src/components/ChatView.logic.test.ts+1−0
@@ -32,6 +32,7 @@ import { · 31 unmodified lines
3232 startNewThreadForProject,
3333 shouldShowBranchMismatchBanner,
3434 shouldShowComposerContextStrip,
35+ shouldShowPlanFollowUpPrompt,
3536 shouldWriteThreadErrorToCurrentServerThread,
3637 toolGroupConsumesUpwardNavigation,
3738 } from "./ChatView.logic";
apps/web/src/components/ChatView.logic.ts+1−0
@@ -8,6 +8,7 @@ import { · 7 unmodified lines
88 type MessageId,
99 type ModelSelection,
1010 type OrchestrationV2ProjectedTurnItem,
11+ type ProviderInteractionMode,
1112 type ProviderDriverKind,
1213 type ServerProvider,
1314 type ScopedProjectRef,
apps/web/src/components/ChatView.tsx+177−22
@@ -1,4 +1,6 @@
11 import {
2+ type ApprovalRequestId,
3+ type ChatFileAttachment,
24 DEFAULT_MODEL,
35 defaultInstanceIdForDriver,
46 type ChatAttachment as ContractChatAttachment,
@@ -18,6 +20,8 @@ import { · 13 unmodified lines
1820 type RunId,
1921 type RuntimeRequestId,
2022 type KeybindingCommand,
23+ OrchestrationThreadActivity,
24+ PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
2125 ProviderInteractionMode,
2226 ProviderDriverKind,
2327 RuntimeMode,
@@ -229,9 +233,12 @@ import { · 205 unmodified lines
229233 } from "../logicalProject";
230234 import { buildDraftThreadRouteParams, buildThreadRouteParams } from "../threadRoutes";
231235 import {
236+ beginBackgroundDraftSubmissionByRef,
237+ clearBackgroundDraftSubmissionByRef,
238+ composerDraftHasUserContent,
239+ type ComposerFileAttachment,
232240 type ComposerImageAttachment,
233241 type DraftThreadEnvMode,
234− composerDraftHasUserContent,
235242 useComposerDraftStore,
236243 DraftId,
237244 } from "../composerDraftStore";
@@ -360,6 +367,12 @@ import { · 122 unmodified lines
360367 } from "./ChatView.logic";
361368 import { useLocalStorage } from "~/hooks/useLocalStorage";
362369 import { useComposerHandleContext } from "../composerHandleContext";
370+import {
371+ awaitAttachmentUploads,
372+ getUploadedAttachments,
373+ releaseDraftAttachments,
374+ startAttachmentUpload,
375+} from "../lib/attachmentUploadQueue";
363376 import { sanitizeThreadErrorMessage } from "~/rpc/transportError";
364377 import { RightPanelSheet } from "./RightPanelSheet";
365378 import { previewEnvironment } from "../state/preview";
@@ -369,6 +382,7 @@ import { fileAttachmentCapabilityBlockReason } from "./chat/composerAttachmentFi · 3 unmodified lines
369382 import { assetEnvironment } from "../state/assets";
370383 import { readPreparedConnection } from "../state/session";
371384 import { useAtomCommand } from "../state/use-atom-command";
385+import { useAtomQueryRunner } from "../state/use-atom-query-runner";
372386 import { Button } from "./ui/button";
373387 import {
374388 AlertDialog,
@@ -392,10 +406,10 @@ import { · 17 unmodified lines
392406 } from "../versionSkew";
393407 import { useAssetUrls } from "../assets/assetUrls";
394408
395−const IMAGE_ONLY_BOOTSTRAP_PROMPT =
396− "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]";
397−// Never part of timeline row data — see cancelTimelineLiveFollowForUserNavigation.
398409 const TIMELINE_SCROLL_CANCEL_SENTINEL = Object.freeze({});
410+const ATTACHMENT_ONLY_BOOTSTRAP_PROMPT =
411+ "[User attached one or more files without additional text. Respond using the conversation context and the attached files.]";
412+const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];
399413 const EMPTY_PROVIDERS: ServerProvider[] = [];
400414 // During an active turn the thread's updatedAt advances several times per
401415 // second, and every server-side visit is a full command dispatch plus a
@@ -1293,6 +1307,9 @@ function ChatViewContent(props: ChatViewProps) { · 891 unmodified lines
12931307 reportFailure: false,
12941308 });
12951309 const startThreadTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false });
1310+ const createAttachmentAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
1311+ reportFailure: false,
1312+ });
12961313 const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, {
12971314 reportFailure: false,
12981315 });
@@ -1452,6 +1469,13 @@ function ChatViewContent(props: ChatViewProps) { · 153 unmodified lines
14521469 const composerActiveProvider = useComposerDraftStore(
14531470 (store) => store.getComposerDraft(composerDraftTarget)?.activeProvider ?? null,
14541471 );
1472+ const composerHasUnsentContent = useComposerDraftStore((store) =>
1473+ composerDraftHasUserContent(store.getComposerDraft(composerDraftTarget)),
1474+ );
1475+ const composerHasAttachments = useComposerDraftStore((store) => {
1476+ const draft = store.getComposerDraft(composerDraftTarget);
1477+ return (draft?.images.length ?? 0) > 0 || (draft?.files.length ?? 0) > 0;
1478+ });
14551479 const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt);
14561480 const addComposerDraftImages = useComposerDraftStore((store) => store.addImages);
14571481 const addComposerDraftFiles = useComposerDraftStore((store) => store.addFiles);
@@ -2278,6 +2302,12 @@ function ChatViewContent(props: ChatViewProps) { · 820 unmodified lines
22782302 const attachmentUploadsCapabilityKnown = attachmentEnvironmentConfig !== null;
22792303 const supportsAttachmentUploads =
22802304 attachmentEnvironmentConfig?.environment.capabilities.attachmentUploads === true;
2305+ const advertisedFileAttachmentBytes =
2306+ attachmentEnvironmentConfig?.environment.capabilities.fileAttachments?.maxUploadBytes ?? null;
2307+ const maxFileAttachmentBytes =
2308+ advertisedFileAttachmentBytes === null
2309+ ? null
2310+ : clampFileAttachmentUploadBytes(advertisedFileAttachmentBytes);
22812311 const versionMismatch = resolveServerConfigVersionMismatch(serverConfig);
22822312 const versionMismatchDismissKey =
22832313 versionMismatch && activeThread
@@ -2644,6 +2674,50 @@ function ChatViewContent(props: ChatViewProps) { · 360 unmodified lines
26442674 }
26452675 return [...attachmentIds];
26462676 }, [serverVisibleTurnItems]);
2677+ const downloadFileAttachment = useCallback(
2678+ async (attachment: ChatFileAttachment) => {
2679+ const connection = readPreparedConnection(environmentId);
2680+ if (!connection) {
2681+ toastManager.add({ type: "error", title: "The environment is not connected." });
2682+ return;
2683+ }
2684+
2685+ // fileName and mimeType ride in the signed claims so the download gets
2686+ // a real filename and Content-Type even when the anchor's `download`
2687+ // attribute is ignored (cross-origin environment servers).
2688+ const result = await createAttachmentAssetUrl({
2689+ environmentId,
2690+ input: {
2691+ resource: {
2692+ _tag: "attachment",
2693+ attachmentId: attachment.id,
2694+ fileName: attachment.name,
2695+ mimeType: attachment.mimeType,
2696+ },
2697+ },
2698+ });
2699+ if (result._tag === "Failure") {
2700+ const error = squashAtomCommandFailure(result);
2701+ toastManager.add({
2702+ type: "error",
2703+ title: `Could not download ${attachment.name}`,
2704+ description: error instanceof Error ? error.message : "The attachment is unavailable.",
2705+ });
2706+ return;
2707+ }
2708+
2709+ const url = resolveAssetUrl(connection.httpBaseUrl, result.value.relativeUrl);
2710+ if (!url) {
2711+ toastManager.add({ type: "error", title: `Could not download ${attachment.name}` });
2712+ return;
2713+ }
2714+ const anchor = document.createElement("a");
2715+ anchor.href = url;
2716+ anchor.download = attachment.name;
2717+ anchor.click();
2718+ },
2719+ [createAttachmentAssetUrl, environmentId],
2720+ );
26472721 const serverAttachmentIds = isServerThread ? committedServerAttachmentIds : EMPTY_ATTACHMENT_IDS;
26482722 const serverAttachmentResources = useMemo(
26492723 () => selectHandoffImageResources(serverMessages, attachmentPreviewHandoffByMessageId),
@@ -5619,7 +5693,7 @@ function ChatViewContent(props: ChatViewProps) { · 2969 unmodified lines
56195693 input: {
56205694 threadId: editingQueuedRun.threadId,
56215695 runId: editingQueuedRun.runId,
5622− text: editText.length === 0 ? IMAGE_ONLY_BOOTSTRAP_PROMPT : editText,
5696+ text: editText.length === 0 ? ATTACHMENT_ONLY_BOOTSTRAP_PROMPT : editText,
56235697 edit: {
56245698 messageId: editingQueuedRun.messageId,
56255699 attachments: [...editingQueuedRun.existingAttachments, ...uploads],
@@ -5774,23 +5848,98 @@ function ChatViewContent(props: ChatViewProps) { · 148 unmodified lines
57745848 effort: ctxSelectedPromptEffort,
57755849 text: messageTextForSend || ATTACHMENT_ONLY_BOOTSTRAP_PROMPT,
57765850 });
5851+ if (composerRef.current?.validateProviderInput(outgoingMessageText) === false) {
5852+ return;
5853+ }
5854+
5855+ const readLiveAttachmentCapabilities = () => {
5856+ const config = appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId) ?? null;
5857+ const liveSupportsAttachmentUploads =
5858+ config?.environment.capabilities.attachmentUploads === true;
5859+ return {
5860+ supportsAttachmentUploads: liveSupportsAttachmentUploads,
5861+ fileBlockReason: fileAttachmentCapabilityBlockReason({
5862+ files: composerFilesSnapshot,
5863+ attachmentUploadsCapabilityKnown: config !== null,
5864+ supportsAttachmentUploads: liveSupportsAttachmentUploads,
5865+ maxFileAttachmentBytes:
5866+ config?.environment.capabilities.fileAttachments?.maxUploadBytes ?? null,
5867+ }),
5868+ };
5869+ };
5870+
5871+ sendInFlightRef.current = true;
5872+ const attachmentCapabilitiesBeforeUpload = readLiveAttachmentCapabilities();
5873+ if (attachmentCapabilitiesBeforeUpload.fileBlockReason !== null) {
5874+ sendInFlightRef.current = false;
5875+ setThreadError(threadIdForSend, attachmentCapabilitiesBeforeUpload.fileBlockReason);
5876+ return;
5877+ }
5878+ const turnUsesAttachmentUploads =
5879+ composerFilesSnapshot.length > 0
5880+ ? attachmentCapabilitiesBeforeUpload.supportsAttachmentUploads
5881+ : supportsAttachmentUploads;
5882+ if (turnUsesAttachmentUploads && composerAttachmentsSnapshot.length > 0) {
5883+ for (const attachment of composerAttachmentsSnapshot) {
5884+ startAttachmentUpload({
5885+ environmentId,
5886+ image: attachment,
5887+ draftTarget: composerDraftTarget,
5888+ });
5889+ }
5890+ await awaitAttachmentUploads(composerAttachmentsSnapshot.map((attachment) => attachment.id));
5891+ const attachmentCapabilitiesAfterUpload = readLiveAttachmentCapabilities();
5892+ if (attachmentCapabilitiesAfterUpload.fileBlockReason !== null) {
5893+ sendInFlightRef.current = false;
5894+ setThreadError(threadIdForSend, attachmentCapabilitiesAfterUpload.fileBlockReason);
5895+ return;
5896+ }
5897+ if (getUploadedAttachments({ environmentId, images: composerAttachmentsSnapshot }) === null) {
5898+ sendInFlightRef.current = false;
5899+ setThreadError(threadIdForSend, "Retry or remove failed uploads before sending.");
5900+ return;
5901+ }
5902+ }
57775903 const turnAttachmentsPromise = Promise.all(
5778− composerImagesSnapshot.map(async (image) => ({
5779− type: "image" as const,
5780− name: image.name,
5781− mimeType: image.mimeType,
5782− sizeBytes: image.sizeBytes,
5783− dataUrl: await readFileAsDataUrl(image.file),
5784− })),
5904+ composerAttachmentsSnapshot.map(async (attachment) => {
5905+ if (turnUsesAttachmentUploads) {
5906+ const uploaded = getUploadedAttachments({ environmentId, images: [attachment] })?.[0];
5907+ if (!uploaded) {
5908+ throw new Error(`Attachment '${attachment.name}' did not finish uploading.`);
5909+ }
5910+ return uploaded;
5911+ }
5912+ if (attachment.type !== "image") {
5913+ throw new Error("This server does not support file attachments.");
5914+ }
5915+ return {
5916+ type: "image" as const,
5917+ name: attachment.name,
5918+ mimeType: attachment.mimeType,
5919+ sizeBytes: attachment.sizeBytes,
5920+ dataUrl: await readFileAsDataUrl(attachment.file),
5921+ };
5922+ }),
5923+ );
5924+ const optimisticAttachments = composerAttachmentsSnapshot.map((attachment) =>
5925+ attachment.type === "image"
5926+ ? {
5927+ type: "image" as const,
5928+ id: attachment.id,
5929+ name: attachment.name,
5930+ mimeType: attachment.mimeType,
5931+ sizeBytes: attachment.sizeBytes,
5932+ previewUrl: attachment.previewUrl,
5933+ }
5934+ : {
5935+ type: "file" as const,
5936+ id: attachment.id,
5937+ name: attachment.name,
5938+ mimeType: attachment.mimeType,
5939+ sizeBytes: attachment.sizeBytes,
5940+ downloadable: false,
5941+ },
57855942 );
5786− const optimisticAttachments = composerImagesSnapshot.map((image) => ({
5787− type: "image" as const,
5788− id: image.id,
5789− name: image.name,
5790− mimeType: image.mimeType,
5791− sizeBytes: image.sizeBytes,
5792− previewUrl: image.previewUrl,
5793− }));
57945943 if (!shouldQueueBehindActiveRun) {
57955944 // A sent turn returns to the live edge and anchors its new transcript
57965945 // row. Queued input stays in the composer queue and must not move the
@@ -5947,6 +6096,10 @@ function ChatViewContent(props: ChatViewProps) { · 150 unmodified lines
59476096 failure = startResult;
59486097 } else {
59496098 turnStartSucceeded = true;
6099+ if (turnUsesAttachmentUploads) {
6100+ releaseDraftAttachments(composerAttachmentsSnapshot);
6101+ }
6102+ acknowledgeActiveThreadWoke();
59506103 }
59516104 }
59526105
@@ -7143,6 +7296,9 @@ function ChatViewContent(props: ChatViewProps) { · 1190 unmodified lines
71437296 composerRef={composerRef}
71447297 composerDraftTarget={composerDraftTarget}
71457298 environmentId={environmentId}
7299+ attachmentUploadsCapabilityKnown={attachmentUploadsCapabilityKnown}
7300+ supportsAttachmentUploads={supportsAttachmentUploads}
7301+ maxFileAttachmentBytes={maxFileAttachmentBytes}
71467302 routeKind={routeKind}
71477303 routeThreadRef={routeThreadRef}
71487304 draftId={draftId}
@@ -7183,8 +7339,6 @@ function ChatViewContent(props: ChatViewProps) { · 34 unmodified lines
71837339 compactDisabled={composerCompactDisabled}
71847340 compactDisabledReason={null}
71857341 sendDisabledReason={null}
7186− attachmentUploadsCapabilityKnown={attachmentUploadsCapabilityKnown}
7187− supportsAttachmentUploads={supportsAttachmentUploads}
71887342 externalDrawerAttached={composerBannerItems.length > 0}
71897343 resolvedTheme={resolvedTheme}
71907344 settings={settings}
@@ -7193,6 +7347,7 @@ function ChatViewContent(props: ChatViewProps) { · 2 unmodified lines
71937347 gitCwd={gitCwd}
71947348 promptRef={promptRef}
71957349 composerImagesRef={composerImagesRef}
7350+ composerFilesRef={composerFilesRef}
71967351 composerTerminalContextsRef={composerTerminalContextsRef}
71977352 composerElementContextsRef={composerElementContextsRef}
71987353 shouldAutoScrollRef={isAtEndRef}
apps/web/src/components/chat/ChatComposer.tsx+279−33
@@ -14,7 +14,6 @@ import type { · 13 unmodified lines
1414 RunId,
1515 } from "@t3tools/contracts";
1616 import {
17− isProviderSendTurnSupportedImageMimeType,
1817 ProviderDriverKind,
1918 ProviderInstanceId,
2019 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
@@ -79,16 +78,28 @@ import { · 58 unmodified lines
7978 type ComposerTaskStep,
8079 type ComposerTasksProgress,
8180 } from "./ComposerTasksBadge";
81+import { compressImageForStash, prepareImageForAttachment } from "../../lib/imageCompression";
8282 import {
83− compressImageForStash,
84− isHeicImageFile,
85− prepareImageForAttachment,
86−} from "../../lib/imageCompression";
83+ fileAttachmentTooLargeMessage,
84+ formatAttachmentSize,
85+} from "@t3tools/client-runtime/state/attachments";
8786 import {
87+ attachmentsToReleaseOnUploadCapabilityLoss,
88+ classifyComposerAttachmentFile,
89+ fileAttachmentCapabilityBlockReason,
90+ fileAttachmentStagingLimit,
91+ normalizeComposerImageFileMimeType,
92+ shouldHandleComposerAttachmentPaste,
93+} from "./composerAttachmentFiles";
94+import {
95+ readAttachmentUpload,
8896 releaseAttachmentUpload,
97+ releaseDraftAttachment,
98+ releasePersistedAttachmentUpload,
8999 retryAttachmentUpload,
90100 startAttachmentUpload,
91101 useAttachmentUploadStore,
102+ verifyStashedAttachmentUpload,
92103 } from "../../lib/attachmentUploadQueue";
93104 import {
94105 attachmentUploadBlockReason,
@@ -272,6 +283,8 @@ import { toastManager } from "../ui/toast"; · 177 unmodified lines
272283 import {
273284 BotIcon,
274285 CircleAlertIcon,
286+ FileIcon,
287+ PaperclipIcon,
275288 PencilRulerIcon,
276289 PlayIcon,
277290 type LucideIcon,
@@ -597,6 +610,7 @@ export interface ChatComposerProps { · 319 unmodified lines
597610 environmentId: EnvironmentId;
598611 attachmentUploadsCapabilityKnown: boolean;
599612 supportsAttachmentUploads: boolean;
613+ maxFileAttachmentBytes: number | null;
600614 routeKind: "server" | "draft";
601615 routeThreadRef: ScopedThreadRef;
602616 draftId: DraftId | null;
@@ -736,6 +750,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 133 unmodified lines
736750 environmentId,
737751 attachmentUploadsCapabilityKnown,
738752 supportsAttachmentUploads,
753+ maxFileAttachmentBytes,
739754 routeKind,
740755 routeThreadRef,
741756 draftId,
@@ -833,13 +848,31 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 91 unmodified lines
833848 const composerReviewComments = composerDraft.reviewComments;
834849 const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds;
835850 const uploadsByImageId = useAttachmentUploadStore((state) => state.uploadsByImageId);
836− const attachmentBlockReason = supportsAttachmentUploads
837− ? attachmentUploadBlockReason({
838− imageIds: composerImages.map((image) => image.id),
839− uploadsByImageId,
840− environmentId,
841− })
842− : null;
851+ const needsReattachFileCount = composerFiles.filter(composerFileNeedsReattach).length;
852+ const fileStagingLimit = fileAttachmentStagingLimit({
853+ attachmentUploadsCapabilityKnown,
854+ supportsAttachmentUploads,
855+ maxFileAttachmentBytes,
856+ });
857+ const fileCapabilityBlockReason = fileAttachmentCapabilityBlockReason({
858+ files: composerFiles,
859+ attachmentUploadsCapabilityKnown,
860+ supportsAttachmentUploads,
861+ maxFileAttachmentBytes,
862+ });
863+ const attachmentBlockReason =
864+ fileCapabilityBlockReason ??
865+ (supportsAttachmentUploads
866+ ? needsReattachFileCount > 0
867+ ? needsReattachFileCount === 1
868+ ? "Attach the interrupted file again or remove it"
869+ : "Attach the interrupted files again or remove them"
870+ : attachmentUploadBlockReason({
871+ imageIds: [...composerImages, ...composerFiles].map((attachment) => attachment.id),
872+ uploadsByImageId,
873+ environmentId,
874+ })
875+ : null);
843876 const sendDisabledReason =
844877 externalSendDisabledReason ?? (activePendingProgress ? null : attachmentBlockReason);
845878 const isSendDisabled = sendDisabledReason !== null;
@@ -885,15 +918,76 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 39 unmodified lines
885918 return;
886919 }
887920 if (!supportsAttachmentUploads) {
888− for (const image of composerImages) {
889− releaseAttachmentUpload(image.id);
921+ // The capability can flap on reconnect or version skew. Deleting a
922+ // persisted hydrated upload here would make the next send fail
923+ // verification while the file still sits in the draft.
924+ for (const attachment of attachmentsToReleaseOnUploadCapabilityLoss([
925+ ...composerImages,
926+ ...composerFiles,
927+ ])) {
928+ releaseAttachmentUpload(attachment.id);
890929 }
891930 return;
892931 }
893− for (const image of composerImages) {
894− startAttachmentUpload({ environmentId, image });
932+ const invalidFiles =
933+ maxFileAttachmentBytes === null
934+ ? composerFiles
935+ : composerFiles.filter((file) => file.sizeBytes > maxFileAttachmentBytes);
936+ for (const attachment of attachmentsToReleaseOnUploadCapabilityLoss(invalidFiles)) {
937+ releaseAttachmentUpload(attachment.id);
895938 }
896− }, [attachmentUploadsCapabilityKnown, composerImages, environmentId, supportsAttachmentUploads]);
939+ const uploadableFiles =
940+ maxFileAttachmentBytes === null
941+ ? []
942+ : composerFiles.filter((file) => file.sizeBytes <= maxFileAttachmentBytes);
943+ const uploadableAttachments = [...composerImages, ...uploadableFiles];
944+ for (const attachment of uploadableAttachments) {
945+ // A needs-reattach file has no bytes to upload and no upload to verify.
946+ if (attachment.type === "file" && composerFileNeedsReattach(attachment)) {
947+ continue;
948+ }
949+ startAttachmentUpload({ environmentId, image: attachment, draftTarget: composerDraftTarget });
950+ }
951+ }, [
952+ attachmentUploadsCapabilityKnown,
953+ composerDraftTarget,
954+ composerFiles,
955+ composerImages,
956+ environmentId,
957+ maxFileAttachmentBytes,
958+ supportsAttachmentUploads,
959+ ]);
960+
961+ useEffect(() => {
962+ for (const file of composerFiles) {
963+ if (
964+ !attachmentUploadsCapabilityKnown ||
965+ !supportsAttachmentUploads ||
966+ maxFileAttachmentBytes === null ||
967+ file.sizeBytes > maxFileAttachmentBytes
968+ ) {
969+ continue;
970+ }
971+ const upload = uploadsByImageId[file.id];
972+ if (upload?.status === "ready" && upload.environmentId === environmentId) {
973+ setComposerDraftFileUpload(
974+ composerDraftTarget,
975+ file.id,
976+ environmentId,
977+ upload.attachmentId,
978+ );
979+ }
980+ }
981+ }, [
982+ attachmentUploadsCapabilityKnown,
983+ composerDraftTarget,
984+ composerFiles,
985+ environmentId,
986+ maxFileAttachmentBytes,
987+ setComposerDraftFileUpload,
988+ supportsAttachmentUploads,
989+ uploadsByImageId,
990+ ]);
897991
898992 // ------------------------------------------------------------------
899993 // Model state
@@ -2957,18 +3051,48 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 2057 unmodified lines
29573051 const acceptedFiles: ComposerFileAttachment[] = [];
29583052 let error: string | null = null;
29593053 for (const file of files) {
2960− const isHeicImage = isHeicImageFile(file);
2961− if (!file.type.startsWith("image/") && !isHeicImage) {
2962− error = `Unsupported file type for '${file.name}'. Please attach image files only.`;
3054+ const attachmentKind = classifyComposerAttachmentFile(file);
3055+ const replacesReattachMarker =
3056+ attachmentKind === "file" &&
3057+ reattachKeys.delete(
3058+ `${file.type || "application/octet-stream"}\u0000${file.size}\u0000${file.name || "file"}`,
3059+ );
3060+ if (!replacesReattachMarker && reservedCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) {
3061+ error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`;
3062+ // Keep scanning: a later file in this batch can still replace a
3063+ // needs-reattach marker without needing a free slot.
29633064 continue;
29643065 }
2965− if (!isHeicImage && !isProviderSendTurnSupportedImageMimeType(file.type)) {
3066+ if (attachmentKind === "unsupported-image") {
29663067 error = `'${file.name}' is not a supported image type. Attach GIF, HEIC, HEIF, JPEG, PNG, or WebP images.`;
29673068 continue;
29683069 }
2969− if (reservedCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) {
2970− error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`;
2971− break;
3070+ if (attachmentKind === "image") {
3071+ acceptedImages.push(normalizeComposerImageFileMimeType(file));
3072+ } else {
3073+ if (fileStagingLimit === null) {
3074+ error = "This server does not support file attachments.";
3075+ continue;
3076+ }
3077+ if (file.size <= 0) {
3078+ error = `'${file.name}' is empty or could not be read.`;
3079+ continue;
3080+ }
3081+ if (file.size > fileStagingLimit) {
3082+ error = fileAttachmentTooLargeMessage(file.name, fileStagingLimit);
3083+ continue;
3084+ }
3085+ acceptedFiles.push({
3086+ type: "file",
3087+ id: randomUUID(),
3088+ name: file.name || "file",
3089+ mimeType: file.type || "application/octet-stream",
3090+ sizeBytes: file.size,
3091+ file,
3092+ });
3093+ }
3094+ if (!replacesReattachMarker) {
3095+ reservedCount += 1;
29723096 }
29733097 }
29743098 setThreadError(threadId, error);
@@ -3039,11 +3163,20 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 64 unmodified lines
30393163 // ------------------------------------------------------------------
30403164 const onComposerPaste = (event: React.ClipboardEvent<HTMLElement>) => {
30413165 const files = Array.from(event.clipboardData.files);
3042− if (files.length === 0) return;
3043− const imageFiles = files.filter(
3044− (file) => file.type.startsWith("image/") || isHeicImageFile(file),
3045− );
3046− if (imageFiles.length === 0) return;
3166+ // Claimable pastes go through even when plan questions are pending or the
3167+ // composer is at its attachment limit: `addComposerAttachments` surfaces
3168+ // those as a toast and a thread error. An early return here would swallow
3169+ // the paste with no feedback.
3170+ if (
3171+ files.length === 0 ||
3172+ !activeThreadId ||
3173+ !shouldHandleComposerAttachmentPaste({
3174+ files,
3175+ plainText: event.clipboardData.getData("text/plain"),
3176+ })
3177+ ) {
3178+ return;
3179+ }
30473180 event.preventDefault();
30483181 void addComposerAttachments(files);
30493182 };
@@ -3171,7 +3304,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 121 unmodified lines
31713304 composerEditorRef.current?.focusAt(cursor);
31723305 },
31733306 addDroppedFiles: (files: File[]) => {
3174− void addComposerImages(files);
3307+ void addComposerAttachments(files);
31753308 focusComposer();
31763309 },
31773310 insertTextAtEnd: insertComposerTextAtEnd,
@@ -3268,7 +3401,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 90 unmodified lines
32683401 }),
32693402 [
32703403 activeThread,
3271− addComposerImages,
3404+ addComposerAttachments,
32723405 composerDraftTarget,
32733406 composerCursor,
32743407 composerTerminalContexts,
@@ -3595,7 +3728,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 320 unmodified lines
35953728 ? {
35963729 uploadsByImageId,
35973730 onRetryUpload: (image: ComposerImageAttachment) =>
3598− retryAttachmentUpload({ environmentId, image }),
3731+ retryAttachmentUpload({
3732+ environmentId,
3733+ image,
3734+ draftTarget: composerDraftTarget,
3735+ }),
35993736 }
36003737 : {})}
36013738 onRemove={(annotationId) => {
@@ -3757,7 +3894,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 155 unmodified lines
37573894 size="icon-xs"
37583895 className="absolute bottom-1 left-1 bg-background/85 hover:bg-background/95"
37593896 onClick={() =>
3760− retryAttachmentUpload({ environmentId, image })
3897+ retryAttachmentUpload({
3898+ environmentId,
3899+ image,
3900+ draftTarget: composerDraftTarget,
3901+ })
37613902 }
37623903 aria-label={`Retry upload for ${image.name}`}
37633904 />
@@ -3788,6 +3929,78 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 24 unmodified lines
37883929 </div>
37893930 )}
37903931
3932+ {!isComposerCollapsedMobile &&
3933+ !isComposerApprovalState &&
3934+ pendingUserInputs.length === 0 &&
3935+ composerFiles.length > 0 && (
3936+ <div className="mb-3 flex flex-col gap-1">
3937+ {composerFiles.map((file) => {
3938+ const fileCanUpload =
3939+ supportsAttachmentUploads &&
3940+ maxFileAttachmentBytes !== null &&
3941+ file.sizeBytes <= maxFileAttachmentBytes;
3942+ const upload = fileCanUpload ? uploadsByImageId[file.id] : undefined;
3943+ const needsReattach = composerFileNeedsReattach(file);
3944+ const canReattachFile =
3945+ fileStagingLimit !== null && file.sizeBytes <= fileStagingLimit;
3946+ return (
3947+ <div
3948+ key={file.id}
3949+ className="flex min-w-0 items-center gap-2 py-1 text-sm text-foreground"
3950+ >
3951+ <FileIcon className="size-4 shrink-0 text-secondary-label" />
3952+ <span className="min-w-0 flex-1 truncate">{file.name}</span>
3953+ <span className="shrink-0 text-xs text-secondary-label">
3954+ {needsReattach
3955+ ? canReattachFile
3956+ ? "Attach again"
3957+ : "Remove to send"
3958+ : upload?.status === "uploading"
3959+ ? formatAttachmentUploadProgress(upload.progress)
3960+ : formatAttachmentSize(file.sizeBytes)}
3961+ </span>
3962+ {!needsReattach && upload?.status === "failed" ? (
3963+ <Tooltip>
3964+ <TooltipTrigger
3965+ render={
3966+ <Button
3967+ variant="ghost"
3968+ size="icon-xs"
3969+ onClick={() =>
3970+ retryAttachmentUpload({
3971+ environmentId,
3972+ image: file,
3973+ draftTarget: composerDraftTarget,
3974+ })
3975+ }
3976+ aria-label={`Retry upload for ${file.name}`}
3977+ />
3978+ }
3979+ >
3980+ <RotateCcwIcon />
3981+ </TooltipTrigger>
3982+ <TooltipPopup
3983+ side="top"
3984+ className="max-w-64 whitespace-normal leading-tight"
3985+ >
3986+ {upload.reason}
3987+ </TooltipPopup>
3988+ </Tooltip>
3989+ ) : null}
3990+ <Button
3991+ variant="ghost"
3992+ size="icon-xs"
3993+ onClick={() => removeComposerFileFromDraft(file.id)}
3994+ aria-label={`Remove ${file.name}`}
3995+ >
3996+ <XIcon />
3997+ </Button>
3998+ </div>
3999+ );
4000+ })}
4001+ </div>
4002+ )}
4003+
37914004 <div className="relative">
37924005 <ComposerPromptEditor
37934006 editorRef={composerEditorRef}
@@ -3959,6 +4172,39 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 165 unmodified lines
39594172 }
39604173 className="flex shrink-0 flex-nowrap items-center justify-end gap-2"
39614174 >
4175+ {fileStagingLimit !== null && pendingUserInputs.length === 0 ? (
4176+ <>
4177+ <input
4178+ ref={attachmentInputRef}
4179+ type="file"
4180+ multiple
4181+ className="hidden"
4182+ onChange={(event) => {
4183+ const files = Array.from(event.currentTarget.files ?? []);
4184+ event.currentTarget.value = "";
4185+ void addComposerAttachments(files);
4186+ focusComposer();
4187+ }}
4188+ />
4189+ <Tooltip>
4190+ <TooltipTrigger
4191+ render={
4192+ <Button
4193+ type="button"
4194+ variant="ghost"
4195+ size="icon-sm"
4196+ onPointerDown={(event) => event.preventDefault()}
4197+ onClick={() => attachmentInputRef.current?.click()}
4198+ aria-label="Attach files"
4199+ />
4200+ }
4201+ >
4202+ <PaperclipIcon />
4203+ </TooltipTrigger>
4204+ <TooltipPopup>Attach files</TooltipPopup>
4205+ </Tooltip>
4206+ </>
4207+ ) : null}
39624208 {showMobilePendingAnswerActions ? null : inlineTasksBadge}
39634209 {showMobilePendingAnswerActions ? null : inlineStashBadge}
39644210 <ComposerFooterPrimaryActions
apps/web/src/components/chat/MessagesTimeline.test.tsx+227−0
@@ -617,6 +617,233 @@ describe("MessagesTimeline", () => { · 616 unmodified lines
617617 expect(onAnchorSizeChanged).toHaveBeenCalledWith(secondEntry.message.id, 240);
618618 });
619619
620+ it("does not reserve end space for a follow-up user message", () => {
621+ const onAnchorReady = vi.fn();
622+ const firstEntry = buildUserTimelineEntry("First prompt.");
623+ const secondEntry = {
624+ ...buildUserTimelineEntry("Newest prompt."),
625+ id: "entry-2",
626+ message: {
627+ ...buildUserTimelineEntry("Newest prompt.").message,
628+ id: MessageId.make("message-2"),
629+ },
630+ };
631+ const markup = renderToStaticMarkup(
632+ <MessagesTimeline
633+ {...buildProps()}
634+ anchorMessageId={secondEntry.message.id}
635+ onAnchorReady={onAnchorReady}
636+ timelineEntries={[firstEntry, secondEntry]}
637+ />,
638+ );
639+
640+ expect(markup).not.toContain("data-anchor-index=");
641+ expect(markup).toContain('data-maintain-scroll-at-end="enabled"');
642+ expect(onAnchorReady).not.toHaveBeenCalled();
643+ });
644+
645+ it("renders generic attachments as download links instead of image previews", () => {
646+ const entry = {
647+ ...buildUserTimelineEntry("Read the report."),
648+ message: {
649+ ...buildUserTimelineEntry("Read the report.").message,
650+ attachments: [
651+ {
652+ type: "file" as const,
653+ id: "attachment-report-pdf",
654+ name: "report.pdf",
655+ mimeType: "application/pdf",
656+ sizeBytes: 42,
657+ previewUrl: "https://environment.test/api/assets/report.pdf",
658+ },
659+ ],
660+ },
661+ };
662+
663+ const markup = renderToStaticMarkup(
664+ <MessagesTimeline {...buildProps()} timelineEntries={[entry]} />,
665+ );
666+
667+ expect(markup).toContain(
668+ '<a href="https://environment.test/api/assets/report.pdf" download="report.pdf" class="flex min-w-0 items-center gap-2 rounded-md py-1 text-sm hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70">',
669+ );
670+ expect(markup).not.toContain('alt="report.pdf"');
671+ });
672+
673+ it("renders a file download button without creating its URL in advance", () => {
674+ const entry = {
675+ ...buildUserTimelineEntry("Read the report."),
676+ message: {
677+ ...buildUserTimelineEntry("Read the report.").message,
678+ attachments: [
679+ {
680+ type: "file" as const,
681+ id: "attachment-report-pdf",
682+ name: "report.pdf",
683+ mimeType: "application/pdf",
684+ sizeBytes: 42,
685+ },
686+ ],
687+ },
688+ };
689+
690+ const markup = renderToStaticMarkup(
691+ <MessagesTimeline {...buildProps()} timelineEntries={[entry]} />,
692+ );
693+
694+ expect(markup).toContain(
695+ '<button type="button" aria-label="Download report.pdf" class="flex min-w-0 cursor-pointer items-center gap-2 rounded-md py-1 text-left text-sm hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70">',
696+ );
697+ expect(markup).not.toContain("href=");
698+ });
699+
700+ it("does not download an optimistic file before the server supplies its attachment ID", () => {
701+ const entry = {
702+ ...buildUserTimelineEntry("Read the report."),
703+ message: {
704+ ...buildUserTimelineEntry("Read the report.").message,
705+ attachments: [
706+ {
707+ type: "file" as const,
708+ id: "composer-local-report",
709+ name: "report.pdf",
710+ mimeType: "application/pdf",
711+ sizeBytes: 42,
712+ downloadable: false,
713+ },
714+ ],
715+ },
716+ };
717+
718+ const markup = renderToStaticMarkup(
719+ <MessagesTimeline {...buildProps()} timelineEntries={[entry]} />,
720+ );
721+
722+ expect(markup).toContain("report.pdf");
723+ expect(markup).not.toContain('aria-label="Download report.pdf"');
724+ });
725+
726+ it("renders unknown attachment types as inert rows instead of crashing", () => {
727+ const entry = {
728+ ...buildUserTimelineEntry("Play the recording."),
729+ message: {
730+ ...buildUserTimelineEntry("Play the recording.").message,
731+ attachments: [
732+ {
733+ // A newer server can introduce attachment types this build does
734+ // not know. They ride the open contract member.
735+ type: "recording",
736+ id: "attachment-voice-memo",
737+ name: "voice-memo.ogg",
738+ mimeType: "audio/ogg",
739+ sizeBytes: 42,
740+ },
741+ ],
742+ },
743+ };
744+
745+ const markup = renderToStaticMarkup(
746+ <MessagesTimeline {...buildProps()} timelineEntries={[entry]} />,
747+ );
748+
749+ expect(markup).toContain("voice-memo.ogg");
750+ expect(markup).not.toContain('aria-label="Download voice-memo.ogg"');
751+ expect(markup).not.toContain('alt="voice-memo.ogg"');
752+ expect(markup).not.toContain("href=");
753+ });
754+
755+ it("keeps reserved end space when tool work starts while reading history", () => {
756+ const runId = RunId.make("run-with-active-tool");
757+ const firstEntry = buildUserTimelineEntry("Run the command.");
758+ const markup = renderToStaticMarkup(
759+ <MessagesTimeline
760+ {...buildProps()}
761+ isWorking
762+ activeTurnInProgress
763+ activeTurnStartedAt={MESSAGE_CREATED_AT}
764+ latestRun={{
765+ runId,
766+ status: "running",
767+ startedAt: MESSAGE_CREATED_AT,
768+ completedAt: null,
769+ }}
770+ anchorMessageId={firstEntry.message.id}
771+ liveFollowEnabled={false}
772+ timelineEntries={[
773+ firstEntry,
774+ {
775+ id: "entry-active-tool",
776+ kind: "work",
777+ createdAt: MESSAGE_CREATED_AT,
778+ entry: {
779+ id: "work-active-tool",
780+ createdAt: MESSAGE_CREATED_AT,
781+ runId,
782+ label: "Run command",
783+ tone: "tool",
784+ itemType: "command_execution",
785+ command: "git status",
786+ toolLifecycleStatus: "inProgress",
787+ },
788+ },
789+ ]}
790+ />,
791+ );
792+
793+ expect(markup).toContain('data-anchor-index="0"');
794+ expect(markup).not.toContain('data-maintain-scroll-at-end="enabled"');
795+ });
796+
797+ it("hands end-following back to the list once the send anchor is released", () => {
798+ const firstEntry = buildUserTimelineEntry("First prompt.");
799+ const secondEntry = {
800+ ...buildUserTimelineEntry("Newest prompt."),
801+ id: "entry-2",
802+ message: {
803+ ...buildUserTimelineEntry("Newest prompt.").message,
804+ id: MessageId.make("message-2"),
805+ },
806+ };
807+ const timelineEntries = [firstEntry, secondEntry];
808+
809+ // While the send anchor holds the end space open, ChatView owns streaming
810+ // scrolls and LegendList must not re-pin behind it.
811+ expect(
812+ renderToStaticMarkup(
813+ <MessagesTimeline
814+ {...buildProps()}
815+ anchorMessageId={firstEntry.message.id}
816+ timelineEntries={timelineEntries}
817+ />,
818+ ),
819+ ).not.toContain('data-maintain-scroll-at-end="enabled"');
820+
821+ // Dropping the anchor is what actually gives end-following back, so
822+ // returning to the live edge has to release it — re-enabling live follow
823+ // alone leaves nothing pinned to the stream.
824+ expect(
825+ renderToStaticMarkup(
826+ <MessagesTimeline
827+ {...buildProps()}
828+ anchorMessageId={null}
829+ timelineEntries={timelineEntries}
830+ />,
831+ ),
832+ ).toContain('data-maintain-scroll-at-end="enabled"');
833+
834+ // Reading history still wins over both.
835+ expect(
836+ renderToStaticMarkup(
837+ <MessagesTimeline
838+ {...buildProps()}
839+ anchorMessageId={null}
840+ liveFollowEnabled={false}
841+ timelineEntries={timelineEntries}
842+ />,
843+ ),
844+ ).not.toContain('data-maintain-scroll-at-end="enabled"');
845+ });
846+
620847 it("renders collapse controls for long user messages", () => {
621848 const markup = renderToStaticMarkup(
622849 <MessagesTimeline
apps/web/src/components/chat/MessagesTimeline.tsx+68−2
@@ -1,4 +1,5 @@
11 import {
2+ type ChatFileAttachment,
23 type EnvironmentId,
34 type MessageId,
45 type OrchestrationV2TurnItem,
@@ -11,6 +12,8 @@ import { · 6 unmodified lines
1112 } from "@t3tools/contracts";
1213 import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";
1314 import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows";
15+const NOOP_DOWNLOAD_ATTACHMENT = (_attachment: ChatFileAttachment) => {};
16+
1417 import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
1518 import {
1619 createContext,
@@ -38,7 +41,12 @@ import { · 21 unmodified lines
3841 workEntrySignalsSevereFailure,
3942 workLogEntryIsToolLike,
4043 } from "../../session-logic";
41−import { type ChatImageAttachment, isImageAttachment, type TurnDiffSummary } from "../../types";
44+import {
45+ type ChatImageAttachment,
46+ isFileAttachment,
47+ isImageAttachment,
48+ type TurnDiffSummary,
49+} from "../../types";
4250 import {
4351 getRenderablePatch,
4452 resolveDiffThemeName,
@@ -52,7 +60,9 @@ import { · 7 unmodified lines
5260 ChevronRightIcon,
5361 ChevronUpIcon,
5462 CircleAlertIcon,
63+ DownloadIcon,
5564 EyeIcon,
65+ FileIcon,
5666 GitForkIcon,
5767 GlobeIcon,
5868 type LucideIcon,
@@ -168,6 +178,7 @@ interface TimelineRowSharedState { · 109 unmodified lines
168178 }) => void;
169179 onToggleTurnFold: (runId: RunId) => void;
170180 onToggleAttemptFold: (attemptId: RunAttemptId) => void;
181+ onFileDownload: (attachment: ChatFileAttachment) => void;
171182 onToggleWorkGroup: (groupId: string, anchorKey: string) => void;
172183 }
173184
@@ -239,6 +250,7 @@ interface MessagesTimelineProps { · 65 unmodified lines
239250 onRevertUserMessage: (messageId: MessageId) => void;
240251 isRevertingCheckpoint: boolean;
241252 onImageExpand: (preview: ExpandedImagePreview) => void;
253+ onFileDownload?: (attachment: ChatFileAttachment) => void;
242254 activeThreadEnvironmentId: EnvironmentId;
243255 markdownCwd: string | undefined;
244256 resolvedTheme: "light" | "dark";
@@ -289,6 +301,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 44 unmodified lines
289301 onRevertUserMessage,
290302 isRevertingCheckpoint,
291303 onImageExpand,
304+ onFileDownload = NOOP_DOWNLOAD_ATTACHMENT,
292305 activeThreadEnvironmentId,
293306 markdownCwd,
294307 resolvedTheme,
@@ -588,6 +601,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 293 unmodified lines
588601 activeThreadEnvironmentId,
589602 onRevertUserMessage,
590603 onImageExpand,
604+ onFileDownload,
591605 onOpenTurnDiff,
592606 onOpenThread,
593607 onForkFromRun,
@@ -608,6 +622,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 14 unmodified lines
608622 activeThreadEnvironmentId,
609623 onRevertUserMessage,
610624 onImageExpand,
625+ onFileDownload,
611626 onOpenTurnDiff,
612627 onOpenThread,
613628 onForkFromRun,
@@ -1131,7 +1146,13 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time · 517 unmodified lines
11311146
11321147 function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) {
11331148 const ctx = use(TimelineRowCtx);
1149+ // The attachment union has an open member, so guards (not literal type
1150+ // comparisons) split it. Unknown types render as inert rows below the files.
11341151 const userImages = (row.message.attachments ?? []).filter(isImageAttachment);
1152+ const userFiles = (row.message.attachments ?? []).filter(isFileAttachment);
1153+ const unknownAttachments = (row.message.attachments ?? []).filter(
1154+ (attachment) => !isImageAttachment(attachment) && !isFileAttachment(attachment),
1155+ );
11351156 const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text);
11361157 const terminalContexts = displayedUserMessage.contexts;
11371158 const previewAnnotations: ParsedPreviewAnnotation[] = [];
@@ -1167,7 +1188,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" · 29 unmodified lines
11671188 <div className="relative max-w-[80%] rounded-2xl bg-accent p-3">
11681189 {regularImages.length > 0 && (
11691190 <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2">
1170− {regularImages.map((image: ChatImageAttachment) => (
1191+ {regularImages.map((image) => (
11711192 <div
11721193 key={image.id}
11731194 className="overflow-hidden rounded-lg border border-border/80 bg-background/70"
@@ -1205,6 +1226,51 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" · 31 unmodified lines
12051226 image={previewImages[index] ?? null}
12061227 />
12071228 ))}
1229+ {userFiles.length > 0 || unknownAttachments.length > 0 ? (
1230+ <div className="mb-2 flex flex-col gap-1">
1231+ {userFiles.map((file) => {
1232+ const content = (
1233+ <>
1234+ <FileIcon className="size-4 shrink-0 text-secondary-label" />
1235+ <span className="min-w-0 flex-1 truncate">{file.name}</span>
1236+ {file.downloadable === false ? null : (
1237+ <DownloadIcon className="size-4 shrink-0" />
1238+ )}
1239+ </>
1240+ );
1241+ return file.previewUrl ? (
1242+ <a
1243+ key={file.id}
1244+ href={file.previewUrl}
1245+ download={file.name}
1246+ className="flex min-w-0 items-center gap-2 rounded-md py-1 text-sm hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
1247+ >
1248+ {content}
1249+ </a>
1250+ ) : file.downloadable === false ? (
1251+ <div key={file.id} className="flex min-w-0 items-center gap-2 py-1 text-sm">
1252+ {content}
1253+ </div>
1254+ ) : (
1255+ <button
1256+ key={file.id}
1257+ type="button"
1258+ aria-label={`Download ${file.name}`}
1259+ onClick={() => ctx.onFileDownload(file)}
1260+ className="flex min-w-0 cursor-pointer items-center gap-2 rounded-md py-1 text-left text-sm hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
1261+ >
1262+ {content}
1263+ </button>
1264+ );
1265+ })}
1266+ {unknownAttachments.map((attachment) => (
1267+ <div key={attachment.id} className="flex min-w-0 items-center gap-2 py-1 text-sm">
1268+ <FileIcon className="size-4 shrink-0 text-secondary-label" />
1269+ <span className="min-w-0 flex-1 truncate">{attachment.name}</span>
1270+ </div>
1271+ ))}
1272+ </div>
1273+ ) : null}
12081274 {elementContexts.length > 0 ? (
12091275 <div className="mb-2 flex flex-wrap gap-1.5">
12101276 {elementContexts.map((context) => (
apps/web/src/components/settings/ProviderInstanceCard.tsx+39−135
@@ -813,6 +813,7 @@ export function ProviderInstanceCard({ · 812 unmodified lines
813813 </div>
814814 );
815815 }
816+
816817 return (
817818 <div className="min-w-0 lg:flex lg:h-full lg:min-h-0 lg:flex-col">
818819 <div className="flex min-h-16 shrink-0 items-start justify-between gap-3 border-b border-border/70 px-4 py-3">
@@ -847,12 +848,28 @@ export function ProviderInstanceCard({ · 28 unmodified lines
847848 >
848849 <ArrowUpCircleIcon className="size-3.5" />
849850 </Button>
850− ) : null}
851− {onRunUpdate && updateCommand ? (
852− <div className="flex items-center gap-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
853− <span aria-hidden className="h-px flex-1 bg-border" />
854− or, update manually using
855− <span aria-hidden className="h-px flex-1 bg-border" />
851+ }
852+ />
853+ <PopoverPopup
854+ side="bottom"
855+ align="start"
856+ className="w-[min(21rem,calc(100vw-1.5rem))] [--popup-width:min(21rem,calc(100vw-1.5rem))]"
857+ >
858+ <div className="grid min-w-0 gap-3">
859+ <div className="grid gap-0.5">
860+ <p className="text-[13px] font-semibold leading-tight text-foreground">
861+ Update available
862+ </p>
863+ <p
864+ className={cn(
865+ "text-xs leading-snug",
866+ versionAdvisory.emphasis === "strong"
867+ ? "text-warning"
868+ : "text-muted-foreground",
869+ )}
870+ >
871+ {versionAdvisory.detail}
872+ </p>
856873 </div>
857874 {onRunUpdate ? (
858875 <Button
@@ -993,9 +1010,23 @@ export function ProviderInstanceCard({ · 134 unmodified lines
9931010 </div>
9941011
9951012 <div>
1013+ {environmentFields.length > 0 ? (
1014+ <div className="mb-4 grid gap-3">
1015+ {environmentFields.map((field) => (
1016+ <ProviderEnvironmentFieldRow
1017+ key={field.name}
1018+ field={field}
1019+ variable={readProviderEnvironmentVariable(instance.environment, field.name)}
1020+ idPrefix={`provider-instance-${instanceId}`}
1021+ onCommit={updateEnvironmentField}
1022+ onRemove={removeEnvironmentField}
1023+ />
1024+ ))}
1025+ </div>
1026+ ) : null}
9961027 <ProviderEnvironmentSection
997− environment={instance.environment ?? []}
998− onChange={updateEnvironment}
1028+ environment={genericEnvironment}
1029+ onChange={updateGenericEnvironment}
9991030 />
10001031 </div>
10011032
@@ -1042,133 +1073,6 @@ export function ProviderInstanceCard({ · 40 unmodified lines
10421073 onModelOrderChange={onModelOrderChange}
10431074 />
10441075 </div>
1045− {showEditorStatus ? (
1046− <p className="flex min-w-0 flex-wrap items-center gap-x-1.5 text-xs text-muted-foreground">
1047− <span>{summary.headline}</span>
1048− {summary.detail ? <span>· {summary.detail}</span> : null}
1049− </p>
1050− ) : null}
1051− </div>
1052− </div>
1053−
1054− <div className="flex h-11 border-b border-border/70 px-1">
1055− {driverOption !== undefined ? (
1056− <button
1057− type="button"
1058− aria-pressed={visibleTab === "models"}
1059− className={providerSettingsTabClassName(visibleTab === "models")}
1060− onClick={() => setActiveTab("models")}
1061− >
1062− Models
1063− </button>
1064− ) : null}
1065− <button
1066− type="button"
1067− aria-pressed={visibleTab === "configuration"}
1068− className={providerSettingsTabClassName(visibleTab === "configuration")}
1069− onClick={() => setActiveTab("configuration")}
1070− >
1071− Configuration
1072− </button>
1073− </div>
1074−
1075− <div
1076− inert={readOnly}
1077− aria-disabled={readOnly || undefined}
1078− className={cn("px-4 py-5", readOnly && "opacity-50 select-none")}
1079− >
1080− <div className="space-y-5" hidden={visibleTab !== "configuration"}>
1081− <div>
1082− <label htmlFor={`provider-instance-${instanceId}-display-name`} className="block">
1083− <span className="text-xs font-medium text-foreground">Display name</span>
1084− <DraftInput
1085− id={`provider-instance-${instanceId}-display-name`}
1086− className="mt-1.5"
1087− value={instance.displayName ?? ""}
1088− onCommit={updateDisplayName}
1089− placeholder={driverOption?.label ?? "Instance label"}
1090− spellCheck={false}
1091− />
1092− <span className="mt-1 block text-xs text-muted-foreground">
1093− Optional label shown in the provider list.
1094− </span>
1095− </label>
1096− </div>
1097−
1098− <div>
1099− <ProviderAccentColorPicker
1100− displayName={displayName}
1101− value={accentColor}
1102− onCommit={updateAccentColor}
1103− commitDelayMs={120}
1104− description="Used to distinguish this instance in picker rails and model lists."
1105− />
1106− </div>
1107− <div className="border-t border-border/60 px-4 py-3 sm:px-5">
1108− {environmentFields.length > 0 ? (
1109− <div className="mb-4 grid gap-3">
1110− {environmentFields.map((field) => (
1111− <ProviderEnvironmentFieldRow
1112− key={field.name}
1113− field={field}
1114− variable={readProviderEnvironmentVariable(instance.environment, field.name)}
1115− idPrefix={`provider-instance-${instanceId}`}
1116− onCommit={updateEnvironmentField}
1117− onRemove={removeEnvironmentField}
1118− />
1119− ))}
1120− </div>
1121− ) : null}
1122− <ProviderEnvironmentSection
1123− environment={genericEnvironment}
1124− onChange={updateGenericEnvironment}
1125− />
1126− </div>
1127−
1128− <div>
1129− <ProviderEnvironmentSection
1130− environment={instance.environment ?? []}
1131− onChange={updateEnvironment}
1132− />
1133− </div>
1134−
1135− {driverOption ? (
1136− <ProviderSettingsForm
1137− definition={driverOption}
1138− value={instance.config}
1139− idPrefix={`provider-instance-${instanceId}`}
1140− variant="card"
1141− onChange={updateConfig}
1142− />
1143− ) : null}
1144−
1145− {driverOption === undefined ? (
1146− <div>
1147− <p className="text-xs text-muted-foreground">
1148− This instance uses a driver (
1149− <code className="text-foreground">{String(instance.driver)}</code>) that is not
1150− shipped with the current build. Configuration values are preserved but cannot be
1151− edited from this surface.
1152− </p>
1153− </div>
1154− ) : null}
1155− </div>
1156− {driverOption !== undefined ? (
1157− <div className="px-4 py-5 lg:h-full lg:min-h-0" hidden={visibleTab !== "models"}>
1158− <ProviderModelsSection
1159− instanceId={instanceId}
1160− driverKind={driverKind}
1161− models={modelsForDisplay}
1162− customModels={customModels}
1163− hiddenModels={hiddenModels}
1164− favoriteModels={favoriteModels}
1165− modelOrder={modelOrder}
1166− onChange={updateCustomModels}
1167− onHiddenModelsChange={onHiddenModelsChange}
1168− onFavoriteModelsChange={onFavoriteModelsChange}
1169− onModelOrderChange={onModelOrderChange}
1170− />
1171− </div>
11721076 ) : null}
11731077 </div>
11741078 </div>
apps/web/src/types.ts+5−0
@@ -1,6 +1,7 @@
11 import type {
22 ChatFileAttachment as ContractChatFileAttachment,
33 ChatImageAttachment as ContractChatImageAttachment,
4+ ChatUnknownAttachment as ContractChatUnknownAttachment,
45 MessageId,
56 OrchestrationV2Actor,
67 OrchestrationV2CreationSource,
@@ -57,6 +58,10 @@ export function isImageAttachment(attachment: ChatAttachment): attachment is Cha · 50 unmodified lines
5758 return attachment.type === "image";
5859 }
5960
61+export function isFileAttachment(attachment: ChatAttachment): attachment is ChatFileAttachment {
62+ return attachment.type === "file";
63+}
64+
6065 export interface ChatMessage {
6166 readonly id: MessageId;
6267 readonly role: "user" | "assistant" | "system";
docs/README.md+1−0
@@ -16,6 +16,7 @@ · 15 unmodified lines
1616 - [Anonymous usage data](./user/telemetry.md)
1717 - [Customize a project icon](./user/project-settings.md)
1818 - [Mobile appearance](./user/mobile-appearance.md)
19+- [Environment themes](./user/environment-theme.md)
1920 - [Remote access](./user/remote-access.md)
2021 - [Appearance](./user/appearance.md)
2122 - [Keeping app and server in sync](./user/updating.md)