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.
Unified · Split
| @@ -74,6 +74,7 @@ export function makeReplayServerConfig( · 73 unmodified lines | ||
| 74 | 74 | const providerLogsDir = path.join(logsDir, "provider"); |
| 75 | 75 | const terminalLogsDir = path.join(logsDir, "terminals"); |
| 76 | 76 | const attachmentsDir = path.join(stateDir, "attachments"); |
| 77 | + const environmentThemesDir = path.join(stateDir, "themes"); | |
| 77 | 78 | const worktreesDir = path.join(baseDir, "worktrees"); |
| 78 | 79 | const providerStatusCacheDir = path.join(baseDir, "caches"); |
| 79 | 80 | |
| @@ -83,6 +84,7 @@ export function makeReplayServerConfig( · 3 unmodified lines | ||
| 83 | 84 | providerLogsDir, |
| 84 | 85 | terminalLogsDir, |
| 85 | 86 | attachmentsDir, |
| 87 | + environmentThemesDir, | |
| 86 | 88 | worktreesDir, |
| 87 | 89 | providerStatusCacheDir, |
| 88 | 90 | ]) { |
| @@ -122,6 +124,7 @@ export function makeReplayServerConfig( · 33 unmodified lines | ||
| 122 | 124 | providerStatusCacheDir, |
| 123 | 125 | worktreesDir, |
| 124 | 126 | attachmentsDir, |
| 127 | + environmentThemesDir, | |
| 125 | 128 | logsDir, |
| 126 | 129 | serverLogPath: path.join(logsDir, "server.log"), |
| 127 | 130 | serverTracePath: path.join(logsDir, "server.trace.ndjson"), |
| @@ -537,6 +537,7 @@ function makeReplayServerConfig( · 536 unmodified lines | ||
| 537 | 537 | const providerLogsDir = path.join(logsDir, "provider"); |
| 538 | 538 | const terminalLogsDir = path.join(logsDir, "terminals"); |
| 539 | 539 | const attachmentsDir = path.join(stateDir, "attachments"); |
| 540 | + const environmentThemesDir = path.join(stateDir, "themes"); | |
| 540 | 541 | const worktreesDir = path.join(baseDir, "worktrees"); |
| 541 | 542 | const providerStatusCacheDir = path.join(baseDir, "caches"); |
| 542 | 543 | for (const directory of [ |
| @@ -545,6 +546,7 @@ function makeReplayServerConfig( · 2 unmodified lines | ||
| 545 | 546 | providerLogsDir, |
| 546 | 547 | terminalLogsDir, |
| 547 | 548 | attachmentsDir, |
| 549 | + environmentThemesDir, | |
| 548 | 550 | worktreesDir, |
| 549 | 551 | providerStatusCacheDir, |
| 550 | 552 | ]) { |
| @@ -583,6 +585,7 @@ function makeReplayServerConfig( · 32 unmodified lines | ||
| 583 | 585 | providerStatusCacheDir, |
| 584 | 586 | worktreesDir, |
| 585 | 587 | attachmentsDir, |
| 588 | + environmentThemesDir, | |
| 586 | 589 | logsDir, |
| 587 | 590 | serverLogPath: path.join(logsDir, "server.log"), |
| 588 | 591 | serverTracePath: path.join(logsDir, "server.trace.ndjson"), |
| @@ -78,6 +78,7 @@ export function makeReplayServerConfig( · 77 unmodified lines | ||
| 78 | 78 | const providerLogsDir = path.join(logsDir, "provider"); |
| 79 | 79 | const terminalLogsDir = path.join(logsDir, "terminals"); |
| 80 | 80 | const attachmentsDir = path.join(stateDir, "attachments"); |
| 81 | + const environmentThemesDir = path.join(stateDir, "themes"); | |
| 81 | 82 | const worktreesDir = path.join(baseDir, "worktrees"); |
| 82 | 83 | const providerStatusCacheDir = path.join(baseDir, "caches"); |
| 83 | 84 | |
| @@ -87,6 +88,7 @@ export function makeReplayServerConfig( · 3 unmodified lines | ||
| 87 | 88 | providerLogsDir, |
| 88 | 89 | terminalLogsDir, |
| 89 | 90 | attachmentsDir, |
| 91 | + environmentThemesDir, | |
| 90 | 92 | worktreesDir, |
| 91 | 93 | providerStatusCacheDir, |
| 92 | 94 | ]) { |
| @@ -126,6 +128,7 @@ export function makeReplayServerConfig( · 33 unmodified lines | ||
| 126 | 128 | providerStatusCacheDir, |
| 127 | 129 | worktreesDir, |
| 128 | 130 | attachmentsDir, |
| 131 | + environmentThemesDir, | |
| 129 | 132 | logsDir, |
| 130 | 133 | serverLogPath: path.join(logsDir, "server.log"), |
| 131 | 134 | serverTracePath: path.join(logsDir, "server.trace.ndjson"), |
| @@ -560,6 +560,7 @@ const makeWsRpcLayer = ( · 559 unmodified lines | ||
| 560 | 560 | const projectService = yield* ProjectService.ProjectService; |
| 561 | 561 | const checkpointDiffQuery = yield* CheckpointDiffQuery.CheckpointDiffQuery; |
| 562 | 562 | const keybindings = yield* Keybindings.Keybindings; |
| 563 | + const environmentTheme = yield* EnvironmentTheme.EnvironmentThemeService; | |
| 563 | 564 | const externalLauncher = yield* ExternalLauncher.ExternalLauncher; |
| 564 | 565 | const remoteOpenTargets = yield* RemoteOpenTargets.RemoteOpenTargets; |
| 565 | 566 | const gitWorkflow = yield* GitWorkflowService.GitWorkflowService; |
| @@ -32,6 +32,7 @@ import { · 31 unmodified lines | ||
| 32 | 32 | startNewThreadForProject, |
| 33 | 33 | shouldShowBranchMismatchBanner, |
| 34 | 34 | shouldShowComposerContextStrip, |
| 35 | + shouldShowPlanFollowUpPrompt, | |
| 35 | 36 | shouldWriteThreadErrorToCurrentServerThread, |
| 36 | 37 | toolGroupConsumesUpwardNavigation, |
| 37 | 38 | } from "./ChatView.logic"; |
| @@ -8,6 +8,7 @@ import { · 7 unmodified lines | ||
| 8 | 8 | type MessageId, |
| 9 | 9 | type ModelSelection, |
| 10 | 10 | type OrchestrationV2ProjectedTurnItem, |
| 11 | + type ProviderInteractionMode, | |
| 11 | 12 | type ProviderDriverKind, |
| 12 | 13 | type ServerProvider, |
| 13 | 14 | type ScopedProjectRef, |
| @@ -1,4 +1,6 @@ | ||
| 1 | 1 | import { |
| 2 | + type ApprovalRequestId, | |
| 3 | + type ChatFileAttachment, | |
| 2 | 4 | DEFAULT_MODEL, |
| 3 | 5 | defaultInstanceIdForDriver, |
| 4 | 6 | type ChatAttachment as ContractChatAttachment, |
| @@ -18,6 +20,8 @@ import { · 13 unmodified lines | ||
| 18 | 20 | type RunId, |
| 19 | 21 | type RuntimeRequestId, |
| 20 | 22 | type KeybindingCommand, |
| 23 | + OrchestrationThreadActivity, | |
| 24 | + PROVIDER_SEND_TURN_MAX_ATTACHMENTS, | |
| 21 | 25 | ProviderInteractionMode, |
| 22 | 26 | ProviderDriverKind, |
| 23 | 27 | RuntimeMode, |
| @@ -229,9 +233,12 @@ import { · 205 unmodified lines | ||
| 229 | 233 | } from "../logicalProject"; |
| 230 | 234 | import { buildDraftThreadRouteParams, buildThreadRouteParams } from "../threadRoutes"; |
| 231 | 235 | import { |
| 236 | + beginBackgroundDraftSubmissionByRef, | |
| 237 | + clearBackgroundDraftSubmissionByRef, | |
| 238 | + composerDraftHasUserContent, | |
| 239 | + type ComposerFileAttachment, | |
| 232 | 240 | type ComposerImageAttachment, |
| 233 | 241 | type DraftThreadEnvMode, |
| 234 | − composerDraftHasUserContent, | |
| 235 | 242 | useComposerDraftStore, |
| 236 | 243 | DraftId, |
| 237 | 244 | } from "../composerDraftStore"; |
| @@ -360,6 +367,12 @@ import { · 122 unmodified lines | ||
| 360 | 367 | } from "./ChatView.logic"; |
| 361 | 368 | import { useLocalStorage } from "~/hooks/useLocalStorage"; |
| 362 | 369 | import { useComposerHandleContext } from "../composerHandleContext"; |
| 370 | +import { | |
| 371 | + awaitAttachmentUploads, | |
| 372 | + getUploadedAttachments, | |
| 373 | + releaseDraftAttachments, | |
| 374 | + startAttachmentUpload, | |
| 375 | +} from "../lib/attachmentUploadQueue"; | |
| 363 | 376 | import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; |
| 364 | 377 | import { RightPanelSheet } from "./RightPanelSheet"; |
| 365 | 378 | import { previewEnvironment } from "../state/preview"; |
| @@ -369,6 +382,7 @@ import { fileAttachmentCapabilityBlockReason } from "./chat/composerAttachmentFi · 3 unmodified lines | ||
| 369 | 382 | import { assetEnvironment } from "../state/assets"; |
| 370 | 383 | import { readPreparedConnection } from "../state/session"; |
| 371 | 384 | import { useAtomCommand } from "../state/use-atom-command"; |
| 385 | +import { useAtomQueryRunner } from "../state/use-atom-query-runner"; | |
| 372 | 386 | import { Button } from "./ui/button"; |
| 373 | 387 | import { |
| 374 | 388 | AlertDialog, |
| @@ -392,10 +406,10 @@ import { · 17 unmodified lines | ||
| 392 | 406 | } from "../versionSkew"; |
| 393 | 407 | import { useAssetUrls } from "../assets/assetUrls"; |
| 394 | 408 | |
| 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. | |
| 398 | 409 | 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[] = []; | |
| 399 | 413 | const EMPTY_PROVIDERS: ServerProvider[] = []; |
| 400 | 414 | // During an active turn the thread's updatedAt advances several times per |
| 401 | 415 | // 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 | 1307 | reportFailure: false, |
| 1294 | 1308 | }); |
| 1295 | 1309 | const startThreadTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); |
| 1310 | + const createAttachmentAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { | |
| 1311 | + reportFailure: false, | |
| 1312 | + }); | |
| 1296 | 1313 | const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, { |
| 1297 | 1314 | reportFailure: false, |
| 1298 | 1315 | }); |
| @@ -1452,6 +1469,13 @@ function ChatViewContent(props: ChatViewProps) { · 153 unmodified lines | ||
| 1452 | 1469 | const composerActiveProvider = useComposerDraftStore( |
| 1453 | 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 | 1479 | const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); |
| 1456 | 1480 | const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); |
| 1457 | 1481 | const addComposerDraftFiles = useComposerDraftStore((store) => store.addFiles); |
| @@ -2278,6 +2302,12 @@ function ChatViewContent(props: ChatViewProps) { · 820 unmodified lines | ||
| 2278 | 2302 | const attachmentUploadsCapabilityKnown = attachmentEnvironmentConfig !== null; |
| 2279 | 2303 | const supportsAttachmentUploads = |
| 2280 | 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 | 2311 | const versionMismatch = resolveServerConfigVersionMismatch(serverConfig); |
| 2282 | 2312 | const versionMismatchDismissKey = |
| 2283 | 2313 | versionMismatch && activeThread |
| @@ -2644,6 +2674,50 @@ function ChatViewContent(props: ChatViewProps) { · 360 unmodified lines | ||
| 2644 | 2674 | } |
| 2645 | 2675 | return [...attachmentIds]; |
| 2646 | 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 | 2721 | const serverAttachmentIds = isServerThread ? committedServerAttachmentIds : EMPTY_ATTACHMENT_IDS; |
| 2648 | 2722 | const serverAttachmentResources = useMemo( |
| 2649 | 2723 | () => selectHandoffImageResources(serverMessages, attachmentPreviewHandoffByMessageId), |
| @@ -5619,7 +5693,7 @@ function ChatViewContent(props: ChatViewProps) { · 2969 unmodified lines | ||
| 5619 | 5693 | input: { |
| 5620 | 5694 | threadId: editingQueuedRun.threadId, |
| 5621 | 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 | 5697 | edit: { |
| 5624 | 5698 | messageId: editingQueuedRun.messageId, |
| 5625 | 5699 | attachments: [...editingQueuedRun.existingAttachments, ...uploads], |
| @@ -5774,23 +5848,98 @@ function ChatViewContent(props: ChatViewProps) { · 148 unmodified lines | ||
| 5774 | 5848 | effort: ctxSelectedPromptEffort, |
| 5775 | 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 | 5903 | 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 | + }, | |
| 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 | 5943 | if (!shouldQueueBehindActiveRun) { |
| 5795 | 5944 | // A sent turn returns to the live edge and anchors its new transcript |
| 5796 | 5945 | // 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 | 6096 | failure = startResult; |
| 5948 | 6097 | } else { |
| 5949 | 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 | 7296 | composerRef={composerRef} |
| 7144 | 7297 | composerDraftTarget={composerDraftTarget} |
| 7145 | 7298 | environmentId={environmentId} |
| 7299 | + attachmentUploadsCapabilityKnown={attachmentUploadsCapabilityKnown} | |
| 7300 | + supportsAttachmentUploads={supportsAttachmentUploads} | |
| 7301 | + maxFileAttachmentBytes={maxFileAttachmentBytes} | |
| 7146 | 7302 | routeKind={routeKind} |
| 7147 | 7303 | routeThreadRef={routeThreadRef} |
| 7148 | 7304 | draftId={draftId} |
| @@ -7183,8 +7339,6 @@ function ChatViewContent(props: ChatViewProps) { · 34 unmodified lines | ||
| 7183 | 7339 | compactDisabled={composerCompactDisabled} |
| 7184 | 7340 | compactDisabledReason={null} |
| 7185 | 7341 | sendDisabledReason={null} |
| 7186 | − attachmentUploadsCapabilityKnown={attachmentUploadsCapabilityKnown} | |
| 7187 | − supportsAttachmentUploads={supportsAttachmentUploads} | |
| 7188 | 7342 | externalDrawerAttached={composerBannerItems.length > 0} |
| 7189 | 7343 | resolvedTheme={resolvedTheme} |
| 7190 | 7344 | settings={settings} |
| @@ -7193,6 +7347,7 @@ function ChatViewContent(props: ChatViewProps) { · 2 unmodified lines | ||
| 7193 | 7347 | gitCwd={gitCwd} |
| 7194 | 7348 | promptRef={promptRef} |
| 7195 | 7349 | composerImagesRef={composerImagesRef} |
| 7350 | + composerFilesRef={composerFilesRef} | |
| 7196 | 7351 | composerTerminalContextsRef={composerTerminalContextsRef} |
| 7197 | 7352 | composerElementContextsRef={composerElementContextsRef} |
| 7198 | 7353 | shouldAutoScrollRef={isAtEndRef} |
| @@ -14,7 +14,6 @@ import type { · 13 unmodified lines | ||
| 14 | 14 | RunId, |
| 15 | 15 | } from "@t3tools/contracts"; |
| 16 | 16 | import { |
| 17 | − isProviderSendTurnSupportedImageMimeType, | |
| 18 | 17 | ProviderDriverKind, |
| 19 | 18 | ProviderInstanceId, |
| 20 | 19 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| @@ -79,16 +78,28 @@ import { · 58 unmodified lines | ||
| 79 | 78 | type ComposerTaskStep, |
| 80 | 79 | type ComposerTasksProgress, |
| 81 | 80 | } from "./ComposerTasksBadge"; |
| 81 | +import { compressImageForStash, prepareImageForAttachment } from "../../lib/imageCompression"; | |
| 82 | 82 | import { |
| 83 | − compressImageForStash, | |
| 84 | − isHeicImageFile, | |
| 85 | − prepareImageForAttachment, | |
| 86 | −} from "../../lib/imageCompression"; | |
| 83 | + fileAttachmentTooLargeMessage, | |
| 84 | + formatAttachmentSize, | |
| 85 | +} from "@t3tools/client-runtime/state/attachments"; | |
| 87 | 86 | import { |
| 87 | + attachmentsToReleaseOnUploadCapabilityLoss, | |
| 88 | + classifyComposerAttachmentFile, | |
| 89 | + fileAttachmentCapabilityBlockReason, | |
| 90 | + fileAttachmentStagingLimit, | |
| 91 | + normalizeComposerImageFileMimeType, | |
| 92 | + shouldHandleComposerAttachmentPaste, | |
| 93 | +} from "./composerAttachmentFiles"; | |
| 94 | +import { | |
| 95 | + readAttachmentUpload, | |
| 88 | 96 | releaseAttachmentUpload, |
| 97 | + releaseDraftAttachment, | |
| 98 | + releasePersistedAttachmentUpload, | |
| 89 | 99 | retryAttachmentUpload, |
| 90 | 100 | startAttachmentUpload, |
| 91 | 101 | useAttachmentUploadStore, |
| 102 | + verifyStashedAttachmentUpload, | |
| 92 | 103 | } from "../../lib/attachmentUploadQueue"; |
| 93 | 104 | import { |
| 94 | 105 | attachmentUploadBlockReason, |
| @@ -272,6 +283,8 @@ import { toastManager } from "../ui/toast"; · 177 unmodified lines | ||
| 272 | 283 | import { |
| 273 | 284 | BotIcon, |
| 274 | 285 | CircleAlertIcon, |
| 286 | + FileIcon, | |
| 287 | + PaperclipIcon, | |
| 275 | 288 | PencilRulerIcon, |
| 276 | 289 | PlayIcon, |
| 277 | 290 | type LucideIcon, |
| @@ -597,6 +610,7 @@ export interface ChatComposerProps { · 319 unmodified lines | ||
| 597 | 610 | environmentId: EnvironmentId; |
| 598 | 611 | attachmentUploadsCapabilityKnown: boolean; |
| 599 | 612 | supportsAttachmentUploads: boolean; |
| 613 | + maxFileAttachmentBytes: number | null; | |
| 600 | 614 | routeKind: "server" | "draft"; |
| 601 | 615 | routeThreadRef: ScopedThreadRef; |
| 602 | 616 | draftId: DraftId | null; |
| @@ -736,6 +750,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 133 unmodified lines | ||
| 736 | 750 | environmentId, |
| 737 | 751 | attachmentUploadsCapabilityKnown, |
| 738 | 752 | supportsAttachmentUploads, |
| 753 | + maxFileAttachmentBytes, | |
| 739 | 754 | routeKind, |
| 740 | 755 | routeThreadRef, |
| 741 | 756 | draftId, |
| @@ -833,13 +848,31 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 91 unmodified lines | ||
| 833 | 848 | const composerReviewComments = composerDraft.reviewComments; |
| 834 | 849 | const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds; |
| 835 | 850 | 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); | |
| 843 | 876 | const sendDisabledReason = |
| 844 | 877 | externalSendDisabledReason ?? (activePendingProgress ? null : attachmentBlockReason); |
| 845 | 878 | const isSendDisabled = sendDisabledReason !== null; |
| @@ -885,15 +918,76 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 39 unmodified lines | ||
| 885 | 918 | return; |
| 886 | 919 | } |
| 887 | 920 | 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); | |
| 890 | 929 | } |
| 891 | 930 | return; |
| 892 | 931 | } |
| 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); | |
| 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 | 993 | // Model state |
| @@ -2957,18 +3051,48 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 2057 unmodified lines | ||
| 2957 | 3051 | const acceptedFiles: ComposerFileAttachment[] = []; |
| 2958 | 3052 | let error: string | null = null; |
| 2959 | 3053 | 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. | |
| 2963 | 3064 | continue; |
| 2964 | 3065 | } |
| 2965 | − if (!isHeicImage && !isProviderSendTurnSupportedImageMimeType(file.type)) { | |
| 3066 | + if (attachmentKind === "unsupported-image") { | |
| 2966 | 3067 | error = `'${file.name}' is not a supported image type. Attach GIF, HEIC, HEIF, JPEG, PNG, or WebP images.`; |
| 2967 | 3068 | continue; |
| 2968 | 3069 | } |
| 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; | |
| 2972 | 3096 | } |
| 2973 | 3097 | } |
| 2974 | 3098 | setThreadError(threadId, error); |
| @@ -3039,11 +3163,20 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 64 unmodified lines | ||
| 3039 | 3163 | // ------------------------------------------------------------------ |
| 3040 | 3164 | const onComposerPaste = (event: React.ClipboardEvent<HTMLElement>) => { |
| 3041 | 3165 | 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 | + } | |
| 3047 | 3180 | event.preventDefault(); |
| 3048 | 3181 | void addComposerAttachments(files); |
| 3049 | 3182 | }; |
| @@ -3171,7 +3304,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 121 unmodified lines | ||
| 3171 | 3304 | composerEditorRef.current?.focusAt(cursor); |
| 3172 | 3305 | }, |
| 3173 | 3306 | addDroppedFiles: (files: File[]) => { |
| 3174 | − void addComposerImages(files); | |
| 3307 | + void addComposerAttachments(files); | |
| 3175 | 3308 | focusComposer(); |
| 3176 | 3309 | }, |
| 3177 | 3310 | insertTextAtEnd: insertComposerTextAtEnd, |
| @@ -3268,7 +3401,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 90 unmodified lines | ||
| 3268 | 3401 | }), |
| 3269 | 3402 | [ |
| 3270 | 3403 | activeThread, |
| 3271 | − addComposerImages, | |
| 3404 | + addComposerAttachments, | |
| 3272 | 3405 | composerDraftTarget, |
| 3273 | 3406 | composerCursor, |
| 3274 | 3407 | composerTerminalContexts, |
| @@ -3595,7 +3728,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 320 unmodified lines | ||
| 3595 | 3728 | ? { |
| 3596 | 3729 | uploadsByImageId, |
| 3597 | 3730 | onRetryUpload: (image: ComposerImageAttachment) => |
| 3598 | − retryAttachmentUpload({ environmentId, image }), | |
| 3731 | + retryAttachmentUpload({ | |
| 3732 | + environmentId, | |
| 3733 | + image, | |
| 3734 | + draftTarget: composerDraftTarget, | |
| 3735 | + }), | |
| 3599 | 3736 | } |
| 3600 | 3737 | : {})} |
| 3601 | 3738 | onRemove={(annotationId) => { |
| @@ -3757,7 +3894,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 155 unmodified lines | ||
| 3757 | 3894 | size="icon-xs" |
| 3758 | 3895 | className="absolute bottom-1 left-1 bg-background/85 hover:bg-background/95" |
| 3759 | 3896 | onClick={() => |
| 3760 | − retryAttachmentUpload({ environmentId, image }) | |
| 3897 | + retryAttachmentUpload({ | |
| 3898 | + environmentId, | |
| 3899 | + image, | |
| 3900 | + draftTarget: composerDraftTarget, | |
| 3901 | + }) | |
| 3761 | 3902 | } |
| 3762 | 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 | 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 | 4004 | <div className="relative"> |
| 3792 | 4005 | <ComposerPromptEditor |
| 3793 | 4006 | editorRef={composerEditorRef} |
| @@ -3959,6 +4172,39 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 165 unmodified lines | ||
| 3959 | 4172 | } |
| 3960 | 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 | 4208 | {showMobilePendingAnswerActions ? null : inlineTasksBadge} |
| 3963 | 4209 | {showMobilePendingAnswerActions ? null : inlineStashBadge} |
| 3964 | 4210 | <ComposerFooterPrimaryActions |
| @@ -617,6 +617,233 @@ describe("MessagesTimeline", () => { · 616 unmodified lines | ||
| 617 | 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 | 847 | it("renders collapse controls for long user messages", () => { |
| 621 | 848 | const markup = renderToStaticMarkup( |
| 622 | 849 | <MessagesTimeline |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { |
| 2 | + type ChatFileAttachment, | |
| 2 | 3 | type EnvironmentId, |
| 3 | 4 | type MessageId, |
| 4 | 5 | type OrchestrationV2TurnItem, |
| @@ -11,6 +12,8 @@ import { · 6 unmodified lines | ||
| 11 | 12 | } from "@t3tools/contracts"; |
| 12 | 13 | import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; |
| 13 | 14 | import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows"; |
| 15 | +const NOOP_DOWNLOAD_ATTACHMENT = (_attachment: ChatFileAttachment) => {}; | |
| 16 | + | |
| 14 | 17 | import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; |
| 15 | 18 | import { |
| 16 | 19 | createContext, |
| @@ -38,7 +41,12 @@ import { · 21 unmodified lines | ||
| 38 | 41 | workEntrySignalsSevereFailure, |
| 39 | 42 | workLogEntryIsToolLike, |
| 40 | 43 | } 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"; | |
| 42 | 50 | import { |
| 43 | 51 | getRenderablePatch, |
| 44 | 52 | resolveDiffThemeName, |
| @@ -52,7 +60,9 @@ import { · 7 unmodified lines | ||
| 52 | 60 | ChevronRightIcon, |
| 53 | 61 | ChevronUpIcon, |
| 54 | 62 | CircleAlertIcon, |
| 63 | + DownloadIcon, | |
| 55 | 64 | EyeIcon, |
| 65 | + FileIcon, | |
| 56 | 66 | GitForkIcon, |
| 57 | 67 | GlobeIcon, |
| 58 | 68 | type LucideIcon, |
| @@ -168,6 +178,7 @@ interface TimelineRowSharedState { · 109 unmodified lines | ||
| 168 | 178 | }) => void; |
| 169 | 179 | onToggleTurnFold: (runId: RunId) => void; |
| 170 | 180 | onToggleAttemptFold: (attemptId: RunAttemptId) => void; |
| 181 | + onFileDownload: (attachment: ChatFileAttachment) => void; | |
| 171 | 182 | onToggleWorkGroup: (groupId: string, anchorKey: string) => void; |
| 172 | 183 | } |
| 173 | 184 | |
| @@ -239,6 +250,7 @@ interface MessagesTimelineProps { · 65 unmodified lines | ||
| 239 | 250 | onRevertUserMessage: (messageId: MessageId) => void; |
| 240 | 251 | isRevertingCheckpoint: boolean; |
| 241 | 252 | onImageExpand: (preview: ExpandedImagePreview) => void; |
| 253 | + onFileDownload?: (attachment: ChatFileAttachment) => void; | |
| 242 | 254 | activeThreadEnvironmentId: EnvironmentId; |
| 243 | 255 | markdownCwd: string | undefined; |
| 244 | 256 | resolvedTheme: "light" | "dark"; |
| @@ -289,6 +301,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 44 unmodified lines | ||
| 289 | 301 | onRevertUserMessage, |
| 290 | 302 | isRevertingCheckpoint, |
| 291 | 303 | onImageExpand, |
| 304 | + onFileDownload = NOOP_DOWNLOAD_ATTACHMENT, | |
| 292 | 305 | activeThreadEnvironmentId, |
| 293 | 306 | markdownCwd, |
| 294 | 307 | resolvedTheme, |
| @@ -588,6 +601,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 293 unmodified lines | ||
| 588 | 601 | activeThreadEnvironmentId, |
| 589 | 602 | onRevertUserMessage, |
| 590 | 603 | onImageExpand, |
| 604 | + onFileDownload, | |
| 591 | 605 | onOpenTurnDiff, |
| 592 | 606 | onOpenThread, |
| 593 | 607 | onForkFromRun, |
| @@ -608,6 +622,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 14 unmodified lines | ||
| 608 | 622 | activeThreadEnvironmentId, |
| 609 | 623 | onRevertUserMessage, |
| 610 | 624 | onImageExpand, |
| 625 | + onFileDownload, | |
| 611 | 626 | onOpenTurnDiff, |
| 612 | 627 | onOpenThread, |
| 613 | 628 | onForkFromRun, |
| @@ -1131,7 +1146,13 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time · 517 unmodified lines | ||
| 1131 | 1146 | |
| 1132 | 1147 | function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) { |
| 1133 | 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 | 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 | 1156 | const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text); |
| 1136 | 1157 | const terminalContexts = displayedUserMessage.contexts; |
| 1137 | 1158 | const previewAnnotations: ParsedPreviewAnnotation[] = []; |
| @@ -1167,7 +1188,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" · 29 unmodified lines | ||
| 1167 | 1188 | <div className="relative max-w-[80%] rounded-2xl bg-accent p-3"> |
| 1168 | 1189 | {regularImages.length > 0 && ( |
| 1169 | 1190 | <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2"> |
| 1170 | − {regularImages.map((image: ChatImageAttachment) => ( | |
| 1191 | + {regularImages.map((image) => ( | |
| 1171 | 1192 | <div |
| 1172 | 1193 | key={image.id} |
| 1173 | 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 | 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 | 1274 | {elementContexts.length > 0 ? ( |
| 1209 | 1275 | <div className="mb-2 flex flex-wrap gap-1.5"> |
| 1210 | 1276 | {elementContexts.map((context) => ( |
| @@ -813,6 +813,7 @@ export function ProviderInstanceCard({ · 812 unmodified lines | ||
| 813 | 813 | </div> |
| 814 | 814 | ); |
| 815 | 815 | } |
| 816 | + | |
| 816 | 817 | return ( |
| 817 | 818 | <div className="min-w-0 lg:flex lg:h-full lg:min-h-0 lg:flex-col"> |
| 818 | 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 | 849 | <ArrowUpCircleIcon className="size-3.5" /> |
| 849 | 850 | </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> | |
| 856 | 873 | </div> |
| 857 | 874 | {onRunUpdate ? ( |
| 858 | 875 | <Button |
| @@ -993,9 +1010,23 @@ export function ProviderInstanceCard({ · 134 unmodified lines | ||
| 993 | 1010 | </div> |
| 994 | 1011 | |
| 995 | 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 | 1027 | <ProviderEnvironmentSection |
| 997 | − environment={instance.environment ?? []} | |
| 998 | − onChange={updateEnvironment} | |
| 1028 | + environment={genericEnvironment} | |
| 1029 | + onChange={updateGenericEnvironment} | |
| 999 | 1030 | /> |
| 1000 | 1031 | </div> |
| 1001 | 1032 | |
| @@ -1042,133 +1073,6 @@ export function ProviderInstanceCard({ · 40 unmodified lines | ||
| 1042 | 1073 | onModelOrderChange={onModelOrderChange} |
| 1043 | 1074 | /> |
| 1044 | 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 | 1076 | ) : null} |
| 1173 | 1077 | </div> |
| 1174 | 1078 | </div> |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import type { |
| 2 | 2 | ChatFileAttachment as ContractChatFileAttachment, |
| 3 | 3 | ChatImageAttachment as ContractChatImageAttachment, |
| 4 | + ChatUnknownAttachment as ContractChatUnknownAttachment, | |
| 4 | 5 | MessageId, |
| 5 | 6 | OrchestrationV2Actor, |
| 6 | 7 | OrchestrationV2CreationSource, |
| @@ -57,6 +58,10 @@ export function isImageAttachment(attachment: ChatAttachment): attachment is Cha · 50 unmodified lines | ||
| 57 | 58 | return attachment.type === "image"; |
| 58 | 59 | } |
| 59 | 60 | |
| 61 | +export function isFileAttachment(attachment: ChatAttachment): attachment is ChatFileAttachment { | |
| 62 | + return attachment.type === "file"; | |
| 63 | +} | |
| 64 | + | |
| 60 | 65 | export interface ChatMessage { |
| 61 | 66 | readonly id: MessageId; |
| 62 | 67 | readonly role: "user" | "assistant" | "system"; |
| @@ -16,6 +16,7 @@ · 15 unmodified lines | ||
| 16 | 16 | - [Anonymous usage data](./user/telemetry.md) |
| 17 | 17 | - [Customize a project icon](./user/project-settings.md) |
| 18 | 18 | - [Mobile appearance](./user/mobile-appearance.md) |
| 19 | +- [Environment themes](./user/environment-theme.md) | |
| 19 | 20 | - [Remote access](./user/remote-access.md) |
| 20 | 21 | - [Appearance](./user/appearance.md) |
| 21 | 22 | - [Keeping app and server in sync](./user/updating.md) |