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
74 const providerLogsDir = path.join(logsDir, "provider");74 const providerLogsDir = path.join(logsDir, "provider");
75 const terminalLogsDir = path.join(logsDir, "terminals");75 const terminalLogsDir = path.join(logsDir, "terminals");
76 const attachmentsDir = path.join(stateDir, "attachments");76 const attachmentsDir = path.join(stateDir, "attachments");
77 const environmentThemesDir = path.join(stateDir, "themes");
77 const worktreesDir = path.join(baseDir, "worktrees");78 const worktreesDir = path.join(baseDir, "worktrees");
78 const providerStatusCacheDir = path.join(baseDir, "caches");79 const providerStatusCacheDir = path.join(baseDir, "caches");
79 80
@@ -83,6 +84,7 @@ export function makeReplayServerConfig( · 3 unmodified lines
83 providerLogsDir,84 providerLogsDir,
84 terminalLogsDir,85 terminalLogsDir,
85 attachmentsDir,86 attachmentsDir,
87 environmentThemesDir,
86 worktreesDir,88 worktreesDir,
87 providerStatusCacheDir,89 providerStatusCacheDir,
88 ]) {90 ]) {
@@ -122,6 +124,7 @@ export function makeReplayServerConfig( · 33 unmodified lines
122 providerStatusCacheDir,124 providerStatusCacheDir,
123 worktreesDir,125 worktreesDir,
124 attachmentsDir,126 attachmentsDir,
127 environmentThemesDir,
125 logsDir,128 logsDir,
126 serverLogPath: path.join(logsDir, "server.log"),129 serverLogPath: path.join(logsDir, "server.log"),
127 serverTracePath: path.join(logsDir, "server.trace.ndjson"),130 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
537 const providerLogsDir = path.join(logsDir, "provider");537 const providerLogsDir = path.join(logsDir, "provider");
538 const terminalLogsDir = path.join(logsDir, "terminals");538 const terminalLogsDir = path.join(logsDir, "terminals");
539 const attachmentsDir = path.join(stateDir, "attachments");539 const attachmentsDir = path.join(stateDir, "attachments");
540 const environmentThemesDir = path.join(stateDir, "themes");
540 const worktreesDir = path.join(baseDir, "worktrees");541 const worktreesDir = path.join(baseDir, "worktrees");
541 const providerStatusCacheDir = path.join(baseDir, "caches");542 const providerStatusCacheDir = path.join(baseDir, "caches");
542 for (const directory of [543 for (const directory of [
@@ -545,6 +546,7 @@ function makeReplayServerConfig( · 2 unmodified lines
545 providerLogsDir,546 providerLogsDir,
546 terminalLogsDir,547 terminalLogsDir,
547 attachmentsDir,548 attachmentsDir,
549 environmentThemesDir,
548 worktreesDir,550 worktreesDir,
549 providerStatusCacheDir,551 providerStatusCacheDir,
550 ]) {552 ]) {
@@ -583,6 +585,7 @@ function makeReplayServerConfig( · 32 unmodified lines
583 providerStatusCacheDir,585 providerStatusCacheDir,
584 worktreesDir,586 worktreesDir,
585 attachmentsDir,587 attachmentsDir,
588 environmentThemesDir,
586 logsDir,589 logsDir,
587 serverLogPath: path.join(logsDir, "server.log"),590 serverLogPath: path.join(logsDir, "server.log"),
588 serverTracePath: path.join(logsDir, "server.trace.ndjson"),591 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
78 const providerLogsDir = path.join(logsDir, "provider");78 const providerLogsDir = path.join(logsDir, "provider");
79 const terminalLogsDir = path.join(logsDir, "terminals");79 const terminalLogsDir = path.join(logsDir, "terminals");
80 const attachmentsDir = path.join(stateDir, "attachments");80 const attachmentsDir = path.join(stateDir, "attachments");
81 const environmentThemesDir = path.join(stateDir, "themes");
81 const worktreesDir = path.join(baseDir, "worktrees");82 const worktreesDir = path.join(baseDir, "worktrees");
82 const providerStatusCacheDir = path.join(baseDir, "caches");83 const providerStatusCacheDir = path.join(baseDir, "caches");
83 84
@@ -87,6 +88,7 @@ export function makeReplayServerConfig( · 3 unmodified lines
87 providerLogsDir,88 providerLogsDir,
88 terminalLogsDir,89 terminalLogsDir,
89 attachmentsDir,90 attachmentsDir,
91 environmentThemesDir,
90 worktreesDir,92 worktreesDir,
91 providerStatusCacheDir,93 providerStatusCacheDir,
92 ]) {94 ]) {
@@ -126,6 +128,7 @@ export function makeReplayServerConfig( · 33 unmodified lines
126 providerStatusCacheDir,128 providerStatusCacheDir,
127 worktreesDir,129 worktreesDir,
128 attachmentsDir,130 attachmentsDir,
131 environmentThemesDir,
129 logsDir,132 logsDir,
130 serverLogPath: path.join(logsDir, "server.log"),133 serverLogPath: path.join(logsDir, "server.log"),
131 serverTracePath: path.join(logsDir, "server.trace.ndjson"),134 serverTracePath: path.join(logsDir, "server.trace.ndjson"),
apps/server/src/ws.ts+1−0
@@ -560,6 +560,7 @@ const makeWsRpcLayer = ( · 559 unmodified lines
560 const projectService = yield* ProjectService.ProjectService;560 const projectService = yield* ProjectService.ProjectService;
561 const checkpointDiffQuery = yield* CheckpointDiffQuery.CheckpointDiffQuery;561 const checkpointDiffQuery = yield* CheckpointDiffQuery.CheckpointDiffQuery;
562 const keybindings = yield* Keybindings.Keybindings;562 const keybindings = yield* Keybindings.Keybindings;
563 const environmentTheme = yield* EnvironmentTheme.EnvironmentThemeService;
563 const externalLauncher = yield* ExternalLauncher.ExternalLauncher;564 const externalLauncher = yield* ExternalLauncher.ExternalLauncher;
564 const remoteOpenTargets = yield* RemoteOpenTargets.RemoteOpenTargets;565 const remoteOpenTargets = yield* RemoteOpenTargets.RemoteOpenTargets;
565 const gitWorkflow = yield* GitWorkflowService.GitWorkflowService;566 const gitWorkflow = yield* GitWorkflowService.GitWorkflowService;
apps/web/src/components/ChatView.logic.test.ts+1−0
@@ -32,6 +32,7 @@ import { · 31 unmodified lines
32 startNewThreadForProject,32 startNewThreadForProject,
33 shouldShowBranchMismatchBanner,33 shouldShowBranchMismatchBanner,
34 shouldShowComposerContextStrip,34 shouldShowComposerContextStrip,
35 shouldShowPlanFollowUpPrompt,
35 shouldWriteThreadErrorToCurrentServerThread,36 shouldWriteThreadErrorToCurrentServerThread,
36 toolGroupConsumesUpwardNavigation,37 toolGroupConsumesUpwardNavigation,
37} from "./ChatView.logic";38} from "./ChatView.logic";
apps/web/src/components/ChatView.logic.ts+1−0
@@ -8,6 +8,7 @@ import { · 7 unmodified lines
8 type MessageId,8 type MessageId,
9 type ModelSelection,9 type ModelSelection,
10 type OrchestrationV2ProjectedTurnItem,10 type OrchestrationV2ProjectedTurnItem,
11 type ProviderInteractionMode,
11 type ProviderDriverKind,12 type ProviderDriverKind,
12 type ServerProvider,13 type ServerProvider,
13 type ScopedProjectRef,14 type ScopedProjectRef,
apps/web/src/components/ChatView.tsx+177−22
@@ -1,4 +1,6 @@
1import {1import {
2 type ApprovalRequestId,
3 type ChatFileAttachment,
2 DEFAULT_MODEL,4 DEFAULT_MODEL,
3 defaultInstanceIdForDriver,5 defaultInstanceIdForDriver,
4 type ChatAttachment as ContractChatAttachment,6 type ChatAttachment as ContractChatAttachment,
@@ -18,6 +20,8 @@ import { · 13 unmodified lines
18 type RunId,20 type RunId,
19 type RuntimeRequestId,21 type RuntimeRequestId,
20 type KeybindingCommand,22 type KeybindingCommand,
23 OrchestrationThreadActivity,
24 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
21 ProviderInteractionMode,25 ProviderInteractionMode,
22 ProviderDriverKind,26 ProviderDriverKind,
23 RuntimeMode,27 RuntimeMode,
@@ -229,9 +233,12 @@ import { · 205 unmodified lines
229} from "../logicalProject";233} from "../logicalProject";
230import { buildDraftThreadRouteParams, buildThreadRouteParams } from "../threadRoutes";234import { buildDraftThreadRouteParams, buildThreadRouteParams } from "../threadRoutes";
231import {235import {
236 beginBackgroundDraftSubmissionByRef,
237 clearBackgroundDraftSubmissionByRef,
238 composerDraftHasUserContent,
239 type ComposerFileAttachment,
232 type ComposerImageAttachment,240 type ComposerImageAttachment,
233 type DraftThreadEnvMode,241 type DraftThreadEnvMode,
234 composerDraftHasUserContent,
235 useComposerDraftStore,242 useComposerDraftStore,
236 DraftId,243 DraftId,
237} from "../composerDraftStore";244} from "../composerDraftStore";
@@ -360,6 +367,12 @@ import { · 122 unmodified lines
360} from "./ChatView.logic";367} from "./ChatView.logic";
361import { useLocalStorage } from "~/hooks/useLocalStorage";368import { useLocalStorage } from "~/hooks/useLocalStorage";
362import { useComposerHandleContext } from "../composerHandleContext";369import { useComposerHandleContext } from "../composerHandleContext";
370import {
371 awaitAttachmentUploads,
372 getUploadedAttachments,
373 releaseDraftAttachments,
374 startAttachmentUpload,
375} from "../lib/attachmentUploadQueue";
363import { sanitizeThreadErrorMessage } from "~/rpc/transportError";376import { sanitizeThreadErrorMessage } from "~/rpc/transportError";
364import { RightPanelSheet } from "./RightPanelSheet";377import { RightPanelSheet } from "./RightPanelSheet";
365import { previewEnvironment } from "../state/preview";378import { previewEnvironment } from "../state/preview";
@@ -369,6 +382,7 @@ import { fileAttachmentCapabilityBlockReason } from "./chat/composerAttachmentFi · 3 unmodified lines
369import { assetEnvironment } from "../state/assets";382import { assetEnvironment } from "../state/assets";
370import { readPreparedConnection } from "../state/session";383import { readPreparedConnection } from "../state/session";
371import { useAtomCommand } from "../state/use-atom-command";384import { useAtomCommand } from "../state/use-atom-command";
385import { useAtomQueryRunner } from "../state/use-atom-query-runner";
372import { Button } from "./ui/button";386import { Button } from "./ui/button";
373import {387import {
374 AlertDialog,388 AlertDialog,
@@ -392,10 +406,10 @@ import { · 17 unmodified lines
392} from "../versionSkew";406} from "../versionSkew";
393import { useAssetUrls } from "../assets/assetUrls";407import { useAssetUrls } from "../assets/assetUrls";
394 408
395const 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.
398const TIMELINE_SCROLL_CANCEL_SENTINEL = Object.freeze({});409const TIMELINE_SCROLL_CANCEL_SENTINEL = Object.freeze({});
410const ATTACHMENT_ONLY_BOOTSTRAP_PROMPT =
411 "[User attached one or more files without additional text. Respond using the conversation context and the attached files.]";
412const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];
399const EMPTY_PROVIDERS: ServerProvider[] = [];413const EMPTY_PROVIDERS: ServerProvider[] = [];
400// During an active turn the thread's updatedAt advances several times per414// During an active turn the thread's updatedAt advances several times per
401// second, and every server-side visit is a full command dispatch plus a415// second, and every server-side visit is a full command dispatch plus a
@@ -1293,6 +1307,9 @@ function ChatViewContent(props: ChatViewProps) { · 891 unmodified lines
1293 reportFailure: false,1307 reportFailure: false,
1294 });1308 });
1295 const startThreadTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false });1309 const startThreadTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false });
1310 const createAttachmentAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
1311 reportFailure: false,
1312 });
1296 const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, {1313 const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, {
1297 reportFailure: false,1314 reportFailure: false,
1298 });1315 });
@@ -1452,6 +1469,13 @@ function ChatViewContent(props: ChatViewProps) { · 153 unmodified lines
1452 const composerActiveProvider = useComposerDraftStore(1469 const composerActiveProvider = useComposerDraftStore(
1453 (store) => store.getComposerDraft(composerDraftTarget)?.activeProvider ?? null,1470 (store) => store.getComposerDraft(composerDraftTarget)?.activeProvider ?? null,
1454 );1471 );
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 });
1455 const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt);1479 const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt);
1456 const addComposerDraftImages = useComposerDraftStore((store) => store.addImages);1480 const addComposerDraftImages = useComposerDraftStore((store) => store.addImages);
1457 const addComposerDraftFiles = useComposerDraftStore((store) => store.addFiles);1481 const addComposerDraftFiles = useComposerDraftStore((store) => store.addFiles);
@@ -2278,6 +2302,12 @@ function ChatViewContent(props: ChatViewProps) { · 820 unmodified lines
2278 const attachmentUploadsCapabilityKnown = attachmentEnvironmentConfig !== null;2302 const attachmentUploadsCapabilityKnown = attachmentEnvironmentConfig !== null;
2279 const supportsAttachmentUploads =2303 const supportsAttachmentUploads =
2280 attachmentEnvironmentConfig?.environment.capabilities.attachmentUploads === true;2304 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);
2281 const versionMismatch = resolveServerConfigVersionMismatch(serverConfig);2311 const versionMismatch = resolveServerConfigVersionMismatch(serverConfig);
2282 const versionMismatchDismissKey =2312 const versionMismatchDismissKey =
2283 versionMismatch && activeThread2313 versionMismatch && activeThread
@@ -2644,6 +2674,50 @@ function ChatViewContent(props: ChatViewProps) { · 360 unmodified lines
2644 }2674 }
2645 return [...attachmentIds];2675 return [...attachmentIds];
2646 }, [serverVisibleTurnItems]);2676 }, [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 );
2647 const serverAttachmentIds = isServerThread ? committedServerAttachmentIds : EMPTY_ATTACHMENT_IDS;2721 const serverAttachmentIds = isServerThread ? committedServerAttachmentIds : EMPTY_ATTACHMENT_IDS;
2648 const serverAttachmentResources = useMemo(2722 const serverAttachmentResources = useMemo(
2649 () => selectHandoffImageResources(serverMessages, attachmentPreviewHandoffByMessageId),2723 () => selectHandoffImageResources(serverMessages, attachmentPreviewHandoffByMessageId),
@@ -5619,7 +5693,7 @@ function ChatViewContent(props: ChatViewProps) { · 2969 unmodified lines
5619 input: {5693 input: {
5620 threadId: editingQueuedRun.threadId,5694 threadId: editingQueuedRun.threadId,
5621 runId: editingQueuedRun.runId,5695 runId: editingQueuedRun.runId,
5622 text: editText.length === 0 ? IMAGE_ONLY_BOOTSTRAP_PROMPT : editText,5696 text: editText.length === 0 ? ATTACHMENT_ONLY_BOOTSTRAP_PROMPT : editText,
5623 edit: {5697 edit: {
5624 messageId: editingQueuedRun.messageId,5698 messageId: editingQueuedRun.messageId,
5625 attachments: [...editingQueuedRun.existingAttachments, ...uploads],5699 attachments: [...editingQueuedRun.existingAttachments, ...uploads],
@@ -5774,23 +5848,98 @@ function ChatViewContent(props: ChatViewProps) { · 148 unmodified lines
5774 effort: ctxSelectedPromptEffort,5848 effort: ctxSelectedPromptEffort,
5775 text: messageTextForSend || ATTACHMENT_ONLY_BOOTSTRAP_PROMPT,5849 text: messageTextForSend || ATTACHMENT_ONLY_BOOTSTRAP_PROMPT,
5776 });5850 });
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 }
5777 const turnAttachmentsPromise = Promise.all(5903 const turnAttachmentsPromise = Promise.all(
5778 composerImagesSnapshot.map(async (image) => ({5904 composerAttachmentsSnapshot.map(async (attachment) => {
5779 type: "image" as const,5905 if (turnUsesAttachmentUploads) {
5780 name: image.name,5906 const uploaded = getUploadedAttachments({ environmentId, images: [attachment] })?.[0];
5781 mimeType: image.mimeType,5907 if (!uploaded) {
5782 sizeBytes: image.sizeBytes,5908 throw new Error(`Attachment '${attachment.name}' did not finish uploading.`);
5783 dataUrl: await readFileAsDataUrl(image.file),5909 }
5784 })),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 },
5785 );5942 );
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 }));
5794 if (!shouldQueueBehindActiveRun) {5943 if (!shouldQueueBehindActiveRun) {
5795 // A sent turn returns to the live edge and anchors its new transcript5944 // A sent turn returns to the live edge and anchors its new transcript
5796 // row. Queued input stays in the composer queue and must not move the5945 // row. Queued input stays in the composer queue and must not move the
@@ -5947,6 +6096,10 @@ function ChatViewContent(props: ChatViewProps) { · 150 unmodified lines
5947 failure = startResult;6096 failure = startResult;
5948 } else {6097 } else {
5949 turnStartSucceeded = true;6098 turnStartSucceeded = true;
6099 if (turnUsesAttachmentUploads) {
6100 releaseDraftAttachments(composerAttachmentsSnapshot);
6101 }
6102 acknowledgeActiveThreadWoke();
5950 }6103 }
5951 }6104 }
5952 6105
@@ -7143,6 +7296,9 @@ function ChatViewContent(props: ChatViewProps) { · 1190 unmodified lines
7143 composerRef={composerRef}7296 composerRef={composerRef}
7144 composerDraftTarget={composerDraftTarget}7297 composerDraftTarget={composerDraftTarget}
7145 environmentId={environmentId}7298 environmentId={environmentId}
7299 attachmentUploadsCapabilityKnown={attachmentUploadsCapabilityKnown}
7300 supportsAttachmentUploads={supportsAttachmentUploads}
7301 maxFileAttachmentBytes={maxFileAttachmentBytes}
7146 routeKind={routeKind}7302 routeKind={routeKind}
7147 routeThreadRef={routeThreadRef}7303 routeThreadRef={routeThreadRef}
7148 draftId={draftId}7304 draftId={draftId}
@@ -7183,8 +7339,6 @@ function ChatViewContent(props: ChatViewProps) { · 34 unmodified lines
7183 compactDisabled={composerCompactDisabled}7339 compactDisabled={composerCompactDisabled}
7184 compactDisabledReason={null}7340 compactDisabledReason={null}
7185 sendDisabledReason={null}7341 sendDisabledReason={null}
7186 attachmentUploadsCapabilityKnown={attachmentUploadsCapabilityKnown}
7187 supportsAttachmentUploads={supportsAttachmentUploads}
7188 externalDrawerAttached={composerBannerItems.length > 0}7342 externalDrawerAttached={composerBannerItems.length > 0}
7189 resolvedTheme={resolvedTheme}7343 resolvedTheme={resolvedTheme}
7190 settings={settings}7344 settings={settings}
@@ -7193,6 +7347,7 @@ function ChatViewContent(props: ChatViewProps) { · 2 unmodified lines
7193 gitCwd={gitCwd}7347 gitCwd={gitCwd}
7194 promptRef={promptRef}7348 promptRef={promptRef}
7195 composerImagesRef={composerImagesRef}7349 composerImagesRef={composerImagesRef}
7350 composerFilesRef={composerFilesRef}
7196 composerTerminalContextsRef={composerTerminalContextsRef}7351 composerTerminalContextsRef={composerTerminalContextsRef}
7197 composerElementContextsRef={composerElementContextsRef}7352 composerElementContextsRef={composerElementContextsRef}
7198 shouldAutoScrollRef={isAtEndRef}7353 shouldAutoScrollRef={isAtEndRef}
apps/web/src/components/chat/ChatComposer.tsx+279−33
@@ -14,7 +14,6 @@ import type { · 13 unmodified lines
14 RunId,14 RunId,
15} from "@t3tools/contracts";15} from "@t3tools/contracts";
16import {16import {
17 isProviderSendTurnSupportedImageMimeType,
18 ProviderDriverKind,17 ProviderDriverKind,
19 ProviderInstanceId,18 ProviderInstanceId,
20 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,19 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
@@ -79,16 +78,28 @@ import { · 58 unmodified lines
79 type ComposerTaskStep,78 type ComposerTaskStep,
80 type ComposerTasksProgress,79 type ComposerTasksProgress,
81} from "./ComposerTasksBadge";80} from "./ComposerTasksBadge";
81import { compressImageForStash, prepareImageForAttachment } from "../../lib/imageCompression";
82import {82import {
83 compressImageForStash,83 fileAttachmentTooLargeMessage,
84 isHeicImageFile,84 formatAttachmentSize,
85 prepareImageForAttachment,85} from "@t3tools/client-runtime/state/attachments";
86} from "../../lib/imageCompression";
87import {86import {
87 attachmentsToReleaseOnUploadCapabilityLoss,
88 classifyComposerAttachmentFile,
89 fileAttachmentCapabilityBlockReason,
90 fileAttachmentStagingLimit,
91 normalizeComposerImageFileMimeType,
92 shouldHandleComposerAttachmentPaste,
93} from "./composerAttachmentFiles";
94import {
95 readAttachmentUpload,
88 releaseAttachmentUpload,96 releaseAttachmentUpload,
97 releaseDraftAttachment,
98 releasePersistedAttachmentUpload,
89 retryAttachmentUpload,99 retryAttachmentUpload,
90 startAttachmentUpload,100 startAttachmentUpload,
91 useAttachmentUploadStore,101 useAttachmentUploadStore,
102 verifyStashedAttachmentUpload,
92} from "../../lib/attachmentUploadQueue";103} from "../../lib/attachmentUploadQueue";
93import {104import {
94 attachmentUploadBlockReason,105 attachmentUploadBlockReason,
@@ -272,6 +283,8 @@ import { toastManager } from "../ui/toast"; · 177 unmodified lines
272import {283import {
273 BotIcon,284 BotIcon,
274 CircleAlertIcon,285 CircleAlertIcon,
286 FileIcon,
287 PaperclipIcon,
275 PencilRulerIcon,288 PencilRulerIcon,
276 PlayIcon,289 PlayIcon,
277 type LucideIcon,290 type LucideIcon,
@@ -597,6 +610,7 @@ export interface ChatComposerProps { · 319 unmodified lines
597 environmentId: EnvironmentId;610 environmentId: EnvironmentId;
598 attachmentUploadsCapabilityKnown: boolean;611 attachmentUploadsCapabilityKnown: boolean;
599 supportsAttachmentUploads: boolean;612 supportsAttachmentUploads: boolean;
613 maxFileAttachmentBytes: number | null;
600 routeKind: "server" | "draft";614 routeKind: "server" | "draft";
601 routeThreadRef: ScopedThreadRef;615 routeThreadRef: ScopedThreadRef;
602 draftId: DraftId | null;616 draftId: DraftId | null;
@@ -736,6 +750,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 133 unmodified lines
736 environmentId,750 environmentId,
737 attachmentUploadsCapabilityKnown,751 attachmentUploadsCapabilityKnown,
738 supportsAttachmentUploads,752 supportsAttachmentUploads,
753 maxFileAttachmentBytes,
739 routeKind,754 routeKind,
740 routeThreadRef,755 routeThreadRef,
741 draftId,756 draftId,
@@ -833,13 +848,31 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 91 unmodified lines
833 const composerReviewComments = composerDraft.reviewComments;848 const composerReviewComments = composerDraft.reviewComments;
834 const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds;849 const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds;
835 const uploadsByImageId = useAttachmentUploadStore((state) => state.uploadsByImageId);850 const uploadsByImageId = useAttachmentUploadStore((state) => state.uploadsByImageId);
836 const attachmentBlockReason = supportsAttachmentUploads851 const needsReattachFileCount = composerFiles.filter(composerFileNeedsReattach).length;
837 ? attachmentUploadBlockReason({852 const fileStagingLimit = fileAttachmentStagingLimit({
838 imageIds: composerImages.map((image) => image.id),853 attachmentUploadsCapabilityKnown,
839 uploadsByImageId,854 supportsAttachmentUploads,
840 environmentId,855 maxFileAttachmentBytes,
841 })856 });
842 : null;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);
843 const sendDisabledReason =876 const sendDisabledReason =
844 externalSendDisabledReason ?? (activePendingProgress ? null : attachmentBlockReason);877 externalSendDisabledReason ?? (activePendingProgress ? null : attachmentBlockReason);
845 const isSendDisabled = sendDisabledReason !== null;878 const isSendDisabled = sendDisabledReason !== null;
@@ -885,15 +918,76 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 39 unmodified lines
885 return;918 return;
886 }919 }
887 if (!supportsAttachmentUploads) {920 if (!supportsAttachmentUploads) {
888 for (const image of composerImages) {921 // The capability can flap on reconnect or version skew. Deleting a
889 releaseAttachmentUpload(image.id);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);
890 }929 }
891 return;930 return;
892 }931 }
893 for (const image of composerImages) {932 const invalidFiles =
894 startAttachmentUpload({ environmentId, image });933 maxFileAttachmentBytes === null
934 ? composerFiles
935 : composerFiles.filter((file) => file.sizeBytes > maxFileAttachmentBytes);
936 for (const attachment of attachmentsToReleaseOnUploadCapabilityLoss(invalidFiles)) {
937 releaseAttachmentUpload(attachment.id);
895 }938 }
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 ]);
897 991
898 // ------------------------------------------------------------------992 // ------------------------------------------------------------------
899 // Model state993 // Model state
@@ -2957,18 +3051,48 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 2057 unmodified lines
2957 const acceptedFiles: ComposerFileAttachment[] = [];3051 const acceptedFiles: ComposerFileAttachment[] = [];
2958 let error: string | null = null;3052 let error: string | null = null;
2959 for (const file of files) {3053 for (const file of files) {
2960 const isHeicImage = isHeicImageFile(file);3054 const attachmentKind = classifyComposerAttachmentFile(file);
2961 if (!file.type.startsWith("image/") && !isHeicImage) {3055 const replacesReattachMarker =
2962 error = `Unsupported file type for '${file.name}'. Please attach image files only.`;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.
2963 continue;3064 continue;
2964 }3065 }
2965 if (!isHeicImage && !isProviderSendTurnSupportedImageMimeType(file.type)) {3066 if (attachmentKind === "unsupported-image") {
2966 error = `'${file.name}' is not a supported image type. Attach GIF, HEIC, HEIF, JPEG, PNG, or WebP images.`;3067 error = `'${file.name}' is not a supported image type. Attach GIF, HEIC, HEIF, JPEG, PNG, or WebP images.`;
2967 continue;3068 continue;
2968 }3069 }
2969 if (reservedCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) {3070 if (attachmentKind === "image") {
2970 error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`;3071 acceptedImages.push(normalizeComposerImageFileMimeType(file));
2971 break;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;
2972 }3096 }
2973 }3097 }
2974 setThreadError(threadId, error);3098 setThreadError(threadId, error);
@@ -3039,11 +3163,20 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 64 unmodified lines
3039 // ------------------------------------------------------------------3163 // ------------------------------------------------------------------
3040 const onComposerPaste = (event: React.ClipboardEvent<HTMLElement>) => {3164 const onComposerPaste = (event: React.ClipboardEvent<HTMLElement>) => {
3041 const files = Array.from(event.clipboardData.files);3165 const files = Array.from(event.clipboardData.files);
3042 if (files.length === 0) return;3166 // Claimable pastes go through even when plan questions are pending or the
3043 const imageFiles = files.filter(3167 // composer is at its attachment limit: `addComposerAttachments` surfaces
3044 (file) => file.type.startsWith("image/") || isHeicImageFile(file),3168 // those as a toast and a thread error. An early return here would swallow
3045 );3169 // the paste with no feedback.
3046 if (imageFiles.length === 0) return;3170 if (
3171 files.length === 0 ||
3172 !activeThreadId ||
3173 !shouldHandleComposerAttachmentPaste({
3174 files,
3175 plainText: event.clipboardData.getData("text/plain"),
3176 })
3177 ) {
3178 return;
3179 }
3047 event.preventDefault();3180 event.preventDefault();
3048 void addComposerAttachments(files);3181 void addComposerAttachments(files);
3049 };3182 };
@@ -3171,7 +3304,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 121 unmodified lines
3171 composerEditorRef.current?.focusAt(cursor);3304 composerEditorRef.current?.focusAt(cursor);
3172 },3305 },
3173 addDroppedFiles: (files: File[]) => {3306 addDroppedFiles: (files: File[]) => {
3174 void addComposerImages(files);3307 void addComposerAttachments(files);
3175 focusComposer();3308 focusComposer();
3176 },3309 },
3177 insertTextAtEnd: insertComposerTextAtEnd,3310 insertTextAtEnd: insertComposerTextAtEnd,
@@ -3268,7 +3401,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 90 unmodified lines
3268 }),3401 }),
3269 [3402 [
3270 activeThread,3403 activeThread,
3271 addComposerImages,3404 addComposerAttachments,
3272 composerDraftTarget,3405 composerDraftTarget,
3273 composerCursor,3406 composerCursor,
3274 composerTerminalContexts,3407 composerTerminalContexts,
@@ -3595,7 +3728,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 320 unmodified lines
3595 ? {3728 ? {
3596 uploadsByImageId,3729 uploadsByImageId,
3597 onRetryUpload: (image: ComposerImageAttachment) =>3730 onRetryUpload: (image: ComposerImageAttachment) =>
3598 retryAttachmentUpload({ environmentId, image }),3731 retryAttachmentUpload({
3732 environmentId,
3733 image,
3734 draftTarget: composerDraftTarget,
3735 }),
3599 }3736 }
3600 : {})}3737 : {})}
3601 onRemove={(annotationId) => {3738 onRemove={(annotationId) => {
@@ -3757,7 +3894,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 155 unmodified lines
3757 size="icon-xs"3894 size="icon-xs"
3758 className="absolute bottom-1 left-1 bg-background/85 hover:bg-background/95"3895 className="absolute bottom-1 left-1 bg-background/85 hover:bg-background/95"
3759 onClick={() =>3896 onClick={() =>
3760 retryAttachmentUpload({ environmentId, image })3897 retryAttachmentUpload({
3898 environmentId,
3899 image,
3900 draftTarget: composerDraftTarget,
3901 })
3761 }3902 }
3762 aria-label={`Retry upload for ${image.name}`}3903 aria-label={`Retry upload for ${image.name}`}
3763 />3904 />
@@ -3788,6 +3929,78 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 24 unmodified lines
3788 </div>3929 </div>
3789 )}3930 )}
3790 3931
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
3791 <div className="relative">4004 <div className="relative">
3792 <ComposerPromptEditor4005 <ComposerPromptEditor
3793 editorRef={composerEditorRef}4006 editorRef={composerEditorRef}
@@ -3959,6 +4172,39 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 165 unmodified lines
3959 }4172 }
3960 className="flex shrink-0 flex-nowrap items-center justify-end gap-2"4173 className="flex shrink-0 flex-nowrap items-center justify-end gap-2"
3961 >4174 >
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}
3962 {showMobilePendingAnswerActions ? null : inlineTasksBadge}4208 {showMobilePendingAnswerActions ? null : inlineTasksBadge}
3963 {showMobilePendingAnswerActions ? null : inlineStashBadge}4209 {showMobilePendingAnswerActions ? null : inlineStashBadge}
3964 <ComposerFooterPrimaryActions4210 <ComposerFooterPrimaryActions
apps/web/src/components/chat/MessagesTimeline.test.tsx+227−0
@@ -617,6 +617,233 @@ describe("MessagesTimeline", () => { · 616 unmodified lines
617 expect(onAnchorSizeChanged).toHaveBeenCalledWith(secondEntry.message.id, 240);617 expect(onAnchorSizeChanged).toHaveBeenCalledWith(secondEntry.message.id, 240);
618 });618 });
619 619
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
620 it("renders collapse controls for long user messages", () => {847 it("renders collapse controls for long user messages", () => {
621 const markup = renderToStaticMarkup(848 const markup = renderToStaticMarkup(
622 <MessagesTimeline849 <MessagesTimeline
apps/web/src/components/chat/MessagesTimeline.tsx+68−2
@@ -1,4 +1,5 @@
1import {1import {
2 type ChatFileAttachment,
2 type EnvironmentId,3 type EnvironmentId,
3 type MessageId,4 type MessageId,
4 type OrchestrationV2TurnItem,5 type OrchestrationV2TurnItem,
@@ -11,6 +12,8 @@ import { · 6 unmodified lines
11} from "@t3tools/contracts";12} from "@t3tools/contracts";
12import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";13import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";
13import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows";14import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows";
15const NOOP_DOWNLOAD_ATTACHMENT = (_attachment: ChatFileAttachment) => {};
16
14import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";17import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
15import {18import {
16 createContext,19 createContext,
@@ -38,7 +41,12 @@ import { · 21 unmodified lines
38 workEntrySignalsSevereFailure,41 workEntrySignalsSevereFailure,
39 workLogEntryIsToolLike,42 workLogEntryIsToolLike,
40} from "../../session-logic";43} from "../../session-logic";
41import { type ChatImageAttachment, isImageAttachment, type TurnDiffSummary } from "../../types";44import {
45 type ChatImageAttachment,
46 isFileAttachment,
47 isImageAttachment,
48 type TurnDiffSummary,
49} from "../../types";
42import {50import {
43 getRenderablePatch,51 getRenderablePatch,
44 resolveDiffThemeName,52 resolveDiffThemeName,
@@ -52,7 +60,9 @@ import { · 7 unmodified lines
52 ChevronRightIcon,60 ChevronRightIcon,
53 ChevronUpIcon,61 ChevronUpIcon,
54 CircleAlertIcon,62 CircleAlertIcon,
63 DownloadIcon,
55 EyeIcon,64 EyeIcon,
65 FileIcon,
56 GitForkIcon,66 GitForkIcon,
57 GlobeIcon,67 GlobeIcon,
58 type LucideIcon,68 type LucideIcon,
@@ -168,6 +178,7 @@ interface TimelineRowSharedState { · 109 unmodified lines
168 }) => void;178 }) => void;
169 onToggleTurnFold: (runId: RunId) => void;179 onToggleTurnFold: (runId: RunId) => void;
170 onToggleAttemptFold: (attemptId: RunAttemptId) => void;180 onToggleAttemptFold: (attemptId: RunAttemptId) => void;
181 onFileDownload: (attachment: ChatFileAttachment) => void;
171 onToggleWorkGroup: (groupId: string, anchorKey: string) => void;182 onToggleWorkGroup: (groupId: string, anchorKey: string) => void;
172}183}
173 184
@@ -239,6 +250,7 @@ interface MessagesTimelineProps { · 65 unmodified lines
239 onRevertUserMessage: (messageId: MessageId) => void;250 onRevertUserMessage: (messageId: MessageId) => void;
240 isRevertingCheckpoint: boolean;251 isRevertingCheckpoint: boolean;
241 onImageExpand: (preview: ExpandedImagePreview) => void;252 onImageExpand: (preview: ExpandedImagePreview) => void;
253 onFileDownload?: (attachment: ChatFileAttachment) => void;
242 activeThreadEnvironmentId: EnvironmentId;254 activeThreadEnvironmentId: EnvironmentId;
243 markdownCwd: string | undefined;255 markdownCwd: string | undefined;
244 resolvedTheme: "light" | "dark";256 resolvedTheme: "light" | "dark";
@@ -289,6 +301,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 44 unmodified lines
289 onRevertUserMessage,301 onRevertUserMessage,
290 isRevertingCheckpoint,302 isRevertingCheckpoint,
291 onImageExpand,303 onImageExpand,
304 onFileDownload = NOOP_DOWNLOAD_ATTACHMENT,
292 activeThreadEnvironmentId,305 activeThreadEnvironmentId,
293 markdownCwd,306 markdownCwd,
294 resolvedTheme,307 resolvedTheme,
@@ -588,6 +601,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 293 unmodified lines
588 activeThreadEnvironmentId,601 activeThreadEnvironmentId,
589 onRevertUserMessage,602 onRevertUserMessage,
590 onImageExpand,603 onImageExpand,
604 onFileDownload,
591 onOpenTurnDiff,605 onOpenTurnDiff,
592 onOpenThread,606 onOpenThread,
593 onForkFromRun,607 onForkFromRun,
@@ -608,6 +622,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 14 unmodified lines
608 activeThreadEnvironmentId,622 activeThreadEnvironmentId,
609 onRevertUserMessage,623 onRevertUserMessage,
610 onImageExpand,624 onImageExpand,
625 onFileDownload,
611 onOpenTurnDiff,626 onOpenTurnDiff,
612 onOpenThread,627 onOpenThread,
613 onForkFromRun,628 onForkFromRun,
@@ -1131,7 +1146,13 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time · 517 unmodified lines
1131 1146
1132function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) {1147function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) {
1133 const ctx = use(TimelineRowCtx);1148 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.
1134 const userImages = (row.message.attachments ?? []).filter(isImageAttachment);1151 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 );
1135 const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text);1156 const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text);
1136 const terminalContexts = displayedUserMessage.contexts;1157 const terminalContexts = displayedUserMessage.contexts;
1137 const previewAnnotations: ParsedPreviewAnnotation[] = [];1158 const previewAnnotations: ParsedPreviewAnnotation[] = [];
@@ -1167,7 +1188,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" · 29 unmodified lines
1167 <div className="relative max-w-[80%] rounded-2xl bg-accent p-3">1188 <div className="relative max-w-[80%] rounded-2xl bg-accent p-3">
1168 {regularImages.length > 0 && (1189 {regularImages.length > 0 && (
1169 <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2">1190 <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2">
1170 {regularImages.map((image: ChatImageAttachment) => (1191 {regularImages.map((image) => (
1171 <div1192 <div
1172 key={image.id}1193 key={image.id}
1173 className="overflow-hidden rounded-lg border border-border/80 bg-background/70"1194 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
1205 image={previewImages[index] ?? null}1226 image={previewImages[index] ?? null}
1206 />1227 />
1207 ))}1228 ))}
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}
1208 {elementContexts.length > 0 ? (1274 {elementContexts.length > 0 ? (
1209 <div className="mb-2 flex flex-wrap gap-1.5">1275 <div className="mb-2 flex flex-wrap gap-1.5">
1210 {elementContexts.map((context) => (1276 {elementContexts.map((context) => (
apps/web/src/components/settings/ProviderInstanceCard.tsx+39−135
@@ -813,6 +813,7 @@ export function ProviderInstanceCard({ · 812 unmodified lines
813 </div>813 </div>
814 );814 );
815 }815 }
816
816 return (817 return (
817 <div className="min-w-0 lg:flex lg:h-full lg:min-h-0 lg:flex-col">818 <div className="min-w-0 lg:flex lg:h-full lg:min-h-0 lg:flex-col">
818 <div className="flex min-h-16 shrink-0 items-start justify-between gap-3 border-b border-border/70 px-4 py-3">819 <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
847 >848 >
848 <ArrowUpCircleIcon className="size-3.5" />849 <ArrowUpCircleIcon className="size-3.5" />
849 </Button>850 </Button>
850 ) : null}851 }
851 {onRunUpdate && updateCommand ? (852 />
852 <div className="flex items-center gap-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">853 <PopoverPopup
853 <span aria-hidden className="h-px flex-1 bg-border" />854 side="bottom"
854 or, update manually using855 align="start"
855 <span aria-hidden className="h-px flex-1 bg-border" />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>
856 </div>873 </div>
857 {onRunUpdate ? (874 {onRunUpdate ? (
858 <Button875 <Button
@@ -993,9 +1010,23 @@ export function ProviderInstanceCard({ · 134 unmodified lines
993 </div>1010 </div>
994 1011
995 <div>1012 <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}
996 <ProviderEnvironmentSection1027 <ProviderEnvironmentSection
997 environment={instance.environment ?? []}1028 environment={genericEnvironment}
998 onChange={updateEnvironment}1029 onChange={updateGenericEnvironment}
999 />1030 />
1000 </div>1031 </div>
1001 1032
@@ -1042,133 +1073,6 @@ export function ProviderInstanceCard({ · 40 unmodified lines
1042 onModelOrderChange={onModelOrderChange}1073 onModelOrderChange={onModelOrderChange}
1043 />1074 />
1044 </div>1075 </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>
1172 ) : null}1076 ) : null}
1173 </div>1077 </div>
1174 </div>1078 </div>
apps/web/src/types.ts+5−0
@@ -1,6 +1,7 @@
1import type {1import type {
2 ChatFileAttachment as ContractChatFileAttachment,2 ChatFileAttachment as ContractChatFileAttachment,
3 ChatImageAttachment as ContractChatImageAttachment,3 ChatImageAttachment as ContractChatImageAttachment,
4 ChatUnknownAttachment as ContractChatUnknownAttachment,
4 MessageId,5 MessageId,
5 OrchestrationV2Actor,6 OrchestrationV2Actor,
6 OrchestrationV2CreationSource,7 OrchestrationV2CreationSource,
@@ -57,6 +58,10 @@ export function isImageAttachment(attachment: ChatAttachment): attachment is Cha · 50 unmodified lines
57 return attachment.type === "image";58 return attachment.type === "image";
58}59}
59 60
61export function isFileAttachment(attachment: ChatAttachment): attachment is ChatFileAttachment {
62 return attachment.type === "file";
63}
64
60export interface ChatMessage {65export interface ChatMessage {
61 readonly id: MessageId;66 readonly id: MessageId;
62 readonly role: "user" | "assistant" | "system";67 readonly role: "user" | "assistant" | "system";
docs/README.md+1−0
@@ -16,6 +16,7 @@ · 15 unmodified lines
16- [Anonymous usage data](./user/telemetry.md)16- [Anonymous usage data](./user/telemetry.md)
17- [Customize a project icon](./user/project-settings.md)17- [Customize a project icon](./user/project-settings.md)
18- [Mobile appearance](./user/mobile-appearance.md)18- [Mobile appearance](./user/mobile-appearance.md)
19- [Environment themes](./user/environment-theme.md)
19- [Remote access](./user/remote-access.md)20- [Remote access](./user/remote-access.md)
20- [Appearance](./user/appearance.md)21- [Appearance](./user/appearance.md)
21- [Keeping app and server in sync](./user/updating.md)22- [Keeping app and server in sync](./user/updating.md)