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 | 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"), |
| @@ -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"), |
| @@ -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"), |
| @@ -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; |
| @@ -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"; |
| @@ -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, |
| @@ -1,4 +1,6 @@ | |||
| 1 | import { | 1 | import { |
| 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"; |
| 230 | import { buildDraftThreadRouteParams, buildThreadRouteParams } from "../threadRoutes"; | 234 | import { buildDraftThreadRouteParams, buildThreadRouteParams } from "../threadRoutes"; |
| 231 | import { | 235 | import { |
| 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"; |
| 361 | import { useLocalStorage } from "~/hooks/useLocalStorage"; | 368 | import { useLocalStorage } from "~/hooks/useLocalStorage"; |
| 362 | import { useComposerHandleContext } from "../composerHandleContext"; | 369 | import { useComposerHandleContext } from "../composerHandleContext"; |
| 370 | import { | ||
| 371 | awaitAttachmentUploads, | ||
| 372 | getUploadedAttachments, | ||
| 373 | releaseDraftAttachments, | ||
| 374 | startAttachmentUpload, | ||
| 375 | } from "../lib/attachmentUploadQueue"; | ||
| 363 | import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; | 376 | import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; |
| 364 | import { RightPanelSheet } from "./RightPanelSheet"; | 377 | import { RightPanelSheet } from "./RightPanelSheet"; |
| 365 | import { previewEnvironment } from "../state/preview"; | 378 | import { previewEnvironment } from "../state/preview"; |
| @@ -369,6 +382,7 @@ import { fileAttachmentCapabilityBlockReason } from "./chat/composerAttachmentFi · 3 unmodified lines | |||
| 369 | import { assetEnvironment } from "../state/assets"; | 382 | import { assetEnvironment } from "../state/assets"; |
| 370 | import { readPreparedConnection } from "../state/session"; | 383 | import { readPreparedConnection } from "../state/session"; |
| 371 | import { useAtomCommand } from "../state/use-atom-command"; | 384 | import { useAtomCommand } from "../state/use-atom-command"; |
| 385 | import { useAtomQueryRunner } from "../state/use-atom-query-runner"; | ||
| 372 | import { Button } from "./ui/button"; | 386 | import { Button } from "./ui/button"; |
| 373 | import { | 387 | import { |
| 374 | AlertDialog, | 388 | AlertDialog, |
| @@ -392,10 +406,10 @@ import { · 17 unmodified lines | |||
| 392 | } from "../versionSkew"; | 406 | } from "../versionSkew"; |
| 393 | import { useAssetUrls } from "../assets/assetUrls"; | 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 | const TIMELINE_SCROLL_CANCEL_SENTINEL = Object.freeze({}); | 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 | const EMPTY_PROVIDERS: ServerProvider[] = []; | 413 | const EMPTY_PROVIDERS: ServerProvider[] = []; |
| 400 | // During an active turn the thread's updatedAt advances several times per | 414 | // 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 a | 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 | 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 && activeThread | 2313 | 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 transcript | 5944 | // 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 the | 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 | 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} |
| @@ -14,7 +14,6 @@ import type { · 13 unmodified lines | |||
| 14 | RunId, | 14 | RunId, |
| 15 | } from "@t3tools/contracts"; | 15 | } from "@t3tools/contracts"; |
| 16 | import { | 16 | import { |
| 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"; |
| 81 | import { compressImageForStash, prepareImageForAttachment } from "../../lib/imageCompression"; | ||
| 82 | import { | 82 | import { |
| 83 | compressImageForStash, | 83 | fileAttachmentTooLargeMessage, |
| 84 | isHeicImageFile, | 84 | formatAttachmentSize, |
| 85 | prepareImageForAttachment, | 85 | } from "@t3tools/client-runtime/state/attachments"; |
| 86 | } from "../../lib/imageCompression"; | ||
| 87 | import { | 86 | import { |
| 87 | attachmentsToReleaseOnUploadCapabilityLoss, | ||
| 88 | classifyComposerAttachmentFile, | ||
| 89 | fileAttachmentCapabilityBlockReason, | ||
| 90 | fileAttachmentStagingLimit, | ||
| 91 | normalizeComposerImageFileMimeType, | ||
| 92 | shouldHandleComposerAttachmentPaste, | ||
| 93 | } from "./composerAttachmentFiles"; | ||
| 94 | import { | ||
| 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"; |
| 93 | import { | 104 | import { |
| 94 | attachmentUploadBlockReason, | 105 | attachmentUploadBlockReason, |
| @@ -272,6 +283,8 @@ import { toastManager } from "../ui/toast"; · 177 unmodified lines | |||
| 272 | import { | 283 | import { |
| 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 = supportsAttachmentUploads | 851 | 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 state | 993 | // 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 | <ComposerPromptEditor | 4005 | <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 | <ComposerFooterPrimaryActions | 4210 | <ComposerFooterPrimaryActions |
| @@ -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 | <MessagesTimeline | 849 | <MessagesTimeline |
| @@ -1,4 +1,5 @@ | |||
| 1 | import { | 1 | import { |
| 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"; |
| 12 | import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; | 13 | import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; |
| 13 | import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows"; | 14 | import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows"; |
| 15 | const NOOP_DOWNLOAD_ATTACHMENT = (_attachment: ChatFileAttachment) => {}; | ||
| 16 | |||
| 14 | import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; | 17 | import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; |
| 15 | import { | 18 | import { |
| 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"; |
| 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 | import { | 50 | import { |
| 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 | ||
| 1132 | function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) { | 1147 | function 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 | <div | 1192 | <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) => ( |
| @@ -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 using | 855 | 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 | <Button | 875 | <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 | <ProviderEnvironmentSection | 1027 | <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> |
| @@ -1,6 +1,7 @@ | |||
| 1 | import type { | 1 | import 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 | ||
| 61 | export function isFileAttachment(attachment: ChatAttachment): attachment is ChatFileAttachment { | ||
| 62 | return attachment.type === "file"; | ||
| 63 | } | ||
| 64 | |||
| 60 | export interface ChatMessage { | 65 | export interface ChatMessage { |
| 61 | readonly id: MessageId; | 66 | readonly id: MessageId; |
| 62 | readonly role: "user" | "assistant" | "system"; | 67 | readonly role: "user" | "assistant" | "system"; |
| @@ -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) |