| author | |
| committer | |
| log | 7220dfe2c949476eaa7d21eccbcd3a0ce0eddb49 |
| tree | ec8f6c6fba758f61a36606fa123b53837d4edd84 |
| parent | 349ce3014233352e073a5fc1b3f12bd786160913 |
| signature | Signed by PGP key B5690EEEBB952194 |
40 files changed, 2315 insertions(+), 153 deletions(-)
apps/mobile/package.json+1| ... | @@ -128,6 +128,7 @@ | ... | @@ -128,6 +128,7 @@ |
| 128 | "@effect/vitest": "catalog:", | 128 | "@effect/vitest": "catalog:", |
| 129 | "@pierre/trees": "1.0.0-beta.4", | 129 | "@pierre/trees": "1.0.0-beta.4", |
| 130 | "@types/react": "~19.2.0", | 130 | "@types/react": "~19.2.0", |
| 131 | "@types/react-dom": "~19.2.3", | ||
| 131 | "babel-preset-expo": "~57.0.9", | 132 | "babel-preset-expo": "~57.0.9", |
| 132 | "tailwindcss": "^4.0.0", | 133 | "tailwindcss": "^4.0.0", |
| 133 | "typescript": "catalog:" | 134 | "typescript": "catalog:" |
apps/mobile/src/features/threads/PendingUserInputCard.tsx+13-13| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { QuestionAttachments } from "./QuestionAttachments"; | ||
| 1 | import type { ApprovalRequestId, UserInputQuestion } from "@t3tools/contracts"; | 2 | import type { ApprovalRequestId, UserInputQuestion } from "@t3tools/contracts"; |
| 2 | import { useCallback, useRef } from "react"; | 3 | import { useCallback, useRef } from "react"; |
| 3 | import { Platform, Pressable, ScrollView, View, type LayoutChangeEvent } from "react-native"; | 4 | import { Platform, Pressable, ScrollView, View, type LayoutChangeEvent } from "react-native"; |
| ... | @@ -15,7 +16,7 @@ import Animated, { | ... | @@ -15,7 +16,7 @@ import Animated, { |
| 15 | import { USER_INPUT_TOGGLE_DURATION_MS } from "./pendingUserInputLayout"; | 16 | import { USER_INPUT_TOGGLE_DURATION_MS } from "./pendingUserInputLayout"; |
| 16 | 17 | ||
| 17 | import { SymbolView } from "../../components/AppSymbol"; | 18 | import { SymbolView } from "../../components/AppSymbol"; |
| 18 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; | 19 | import { AppText as Text } from "../../components/AppText"; |
| 19 | import { ControlPill } from "../../components/ControlPill"; | 20 | import { ControlPill } from "../../components/ControlPill"; |
| 20 | import { cn } from "../../lib/cn"; | 21 | import { cn } from "../../lib/cn"; |
| 21 | import { | 22 | import { |
| ... | @@ -305,18 +306,17 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { | ... | @@ -305,18 +306,17 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { |
| 305 | ); | 306 | ); |
| 306 | })} | 307 | })} |
| 307 | </View> | 308 | </View> |
| 308 | {question.allowCustomAnswer !== false ? ( | 309 | <QuestionAttachments |
| 309 | <TextInput | 310 | requestId={props.pendingUserInput.requestId} |
| 310 | value={draft?.customAnswer ?? ""} | 311 | question={question} |
| 311 | onChangeText={(value) => | 312 | questions={props.pendingUserInput.questions} |
| 312 | props.onChangeCustomAnswer(props.pendingUserInput.requestId, question.id, value) | 313 | disabled={props.respondingUserInputId === props.pendingUserInput.requestId} |
| 313 | } | 314 | value={draft?.customAnswer ?? ""} |
| 314 | onFocus={() => props.onInputFocusChange?.(true)} | 315 | onChangeText={(value) => |
| 315 | onBlur={() => props.onInputFocusChange?.(false)} | 316 | props.onChangeCustomAnswer(props.pendingUserInput.requestId, question.id, value) |
| 316 | placeholder="Or type a custom answer" | 317 | } |
| 317 | className="min-h-[54px] rounded-2xl border border-input-border bg-input px-3.5 py-3 font-sans text-base text-foreground" | 318 | onInputFocusChange={props.onInputFocusChange} |
| 318 | /> | 319 | /> |
| 319 | ) : null} | ||
| 320 | </View> | 320 | </View> |
| 321 | ); | 321 | ); |
| 322 | })} | 322 | })} |
apps/mobile/src/features/threads/QuestionAnswerHistory.test.tsx created+58| ... | @@ -0,0 +1,58 @@ | ||
| 1 | import { ApprovalRequestId, EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import type { ReactNode } from "react"; | ||
| 3 | import { renderToStaticMarkup } from "react-dom/server"; | ||
| 4 | import { describe, expect, it, vi } from "vite-plus/test"; | ||
| 5 | |||
| 6 | vi.mock("react-native", () => ({ | ||
| 7 | View: "div", | ||
| 8 | Pressable: ({ children }: { children: ReactNode }) => <button>{children}</button>, | ||
| 9 | Image: "img", | ||
| 10 | Linking: { openURL: vi.fn() }, | ||
| 11 | })); | ||
| 12 | vi.mock("../../components/AppText", () => ({ AppText: "span" })); | ||
| 13 | vi.mock("../../state/assets", () => ({ useAssetUrl: () => null })); | ||
| 14 | |||
| 15 | import { QuestionAnswerHistory } from "./QuestionAnswerHistory"; | ||
| 16 | |||
| 17 | describe("QuestionAnswerHistory", () => { | ||
| 18 | it.each([{}, { text: "Text-only answer", file: "Answer with a file" }])( | ||
| 19 | "renders attachment-only questions alongside text answers: %j", | ||
| 20 | (answers) => { | ||
| 21 | const markup = renderToStaticMarkup( | ||
| 22 | <QuestionAnswerHistory | ||
| 23 | environmentId={EnvironmentId.make("environment-local")} | ||
| 24 | answer={{ | ||
| 25 | requestId: ApprovalRequestId.make("question-request"), | ||
| 26 | answers, | ||
| 27 | questionTextById: { file: "Provide a spec", image: "Provide a screenshot" }, | ||
| 28 | attachmentsByQuestionId: { | ||
| 29 | file: [ | ||
| 30 | { | ||
| 31 | type: "file", | ||
| 32 | id: "spec", | ||
| 33 | name: "spec.txt", | ||
| 34 | mimeType: "text/plain", | ||
| 35 | sizeBytes: 4, | ||
| 36 | }, | ||
| 37 | ], | ||
| 38 | image: [ | ||
| 39 | { | ||
| 40 | type: "image", | ||
| 41 | id: "shot", | ||
| 42 | name: "shot.png", | ||
| 43 | mimeType: "image/png", | ||
| 44 | sizeBytes: 4, | ||
| 45 | }, | ||
| 46 | ], | ||
| 47 | }, | ||
| 48 | }} | ||
| 49 | />, | ||
| 50 | ); | ||
| 51 | expect(markup.match(/Provide a spec/g)).toHaveLength(1); | ||
| 52 | expect(markup.match(/spec\.txt/g)).toHaveLength(1); | ||
| 53 | expect(markup).toContain("Provide a screenshot"); | ||
| 54 | expect(markup).toContain("shot.png"); | ||
| 55 | for (const answer of Object.values(answers)) expect(markup).toContain(answer); | ||
| 56 | }, | ||
| 57 | ); | ||
| 58 | }); | ||
apps/mobile/src/features/threads/QuestionAnswerHistory.tsx created+71| ... | @@ -0,0 +1,71 @@ | ||
| 1 | import type { | ||
| 2 | EnvironmentId, | ||
| 3 | UserInputAttachmentAnswerPayload, | ||
| 4 | UserInputAttachments, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | import { Image, Linking, Pressable, View } from "react-native"; | ||
| 7 | import { AppText as Text } from "../../components/AppText"; | ||
| 8 | import { useAssetUrl } from "../../state/assets"; | ||
| 9 | |||
| 10 | function AnswerFile(props: { | ||
| 11 | environmentId: EnvironmentId; | ||
| 12 | attachment: UserInputAttachments[string][number]; | ||
| 13 | }) { | ||
| 14 | const url = useAssetUrl(props.environmentId, { | ||
| 15 | _tag: "attachment", | ||
| 16 | attachmentId: props.attachment.id, | ||
| 17 | }); | ||
| 18 | return ( | ||
| 19 | <Pressable | ||
| 20 | accessibilityRole="link" | ||
| 21 | accessibilityLabel={props.attachment.name} | ||
| 22 | disabled={!url} | ||
| 23 | onPress={() => { | ||
| 24 | if (url) void Linking.openURL(url); | ||
| 25 | }} | ||
| 26 | className="gap-1" | ||
| 27 | > | ||
| 28 | {props.attachment.type === "image" && url ? ( | ||
| 29 | <Image source={{ uri: url }} style={{ width: 160, height: 96 }} resizeMode="contain" /> | ||
| 30 | ) : null} | ||
| 31 | <Text className="text-sm text-foreground underline">{props.attachment.name}</Text> | ||
| 32 | </Pressable> | ||
| 33 | ); | ||
| 34 | } | ||
| 35 | |||
| 36 | export function QuestionAnswerHistory(props: { | ||
| 37 | environmentId: EnvironmentId; | ||
| 38 | answer: UserInputAttachmentAnswerPayload; | ||
| 39 | }) { | ||
| 40 | return ( | ||
| 41 | <View className="gap-2"> | ||
| 42 | {[ | ||
| 43 | ...new Set([ | ||
| 44 | ...Object.keys(props.answer.answers), | ||
| 45 | ...Object.keys(props.answer.attachmentsByQuestionId), | ||
| 46 | ]), | ||
| 47 | ].map((questionId) => ( | ||
| 48 | <View key={questionId} className="gap-1"> | ||
| 49 | {props.answer.questionTextById?.[questionId] ? ( | ||
| 50 | <Text className="text-sm text-foreground-muted"> | ||
| 51 | {props.answer.questionTextById[questionId]} | ||
| 52 | </Text> | ||
| 53 | ) : null} | ||
| 54 | <Text className="text-sm text-foreground"> | ||
| 55 | {[props.answer.answers[questionId]] | ||
| 56 | .flat() | ||
| 57 | .filter((value): value is string => typeof value === "string") | ||
| 58 | .join(", ")} | ||
| 59 | </Text> | ||
| 60 | {(props.answer.attachmentsByQuestionId[questionId] ?? []).map((attachment) => ( | ||
| 61 | <AnswerFile | ||
| 62 | key={attachment.id} | ||
| 63 | environmentId={props.environmentId} | ||
| 64 | attachment={attachment} | ||
| 65 | /> | ||
| 66 | ))} | ||
| 67 | </View> | ||
| 68 | ))} | ||
| 69 | </View> | ||
| 70 | ); | ||
| 71 | } | ||
apps/mobile/src/features/threads/QuestionAttachments.tsx created+183| ... | @@ -0,0 +1,183 @@ | ||
| 1 | import { TextInputWrapper } from "expo-paste-input"; | ||
| 2 | import { AppTextInput as TextInput } from "../../components/AppText"; | ||
| 3 | import { useNativePaste } from "../../lib/useNativePaste"; | ||
| 4 | import { convertPastedImagesToAttachments } from "../../lib/composerImages"; | ||
| 5 | import { | ||
| 6 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, | ||
| 7 | type ApprovalRequestId, | ||
| 8 | type UserInputQuestion, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | import { useAtomValue } from "@effect/atom-react"; | ||
| 11 | import { Alert, View } from "react-native"; | ||
| 12 | import { useEffect, useRef } from "react"; | ||
| 13 | import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton"; | ||
| 14 | import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; | ||
| 15 | import { pickComposerFiles, pickComposerMedia } from "../../lib/composerImages"; | ||
| 16 | import { useThreadSelection } from "../../state/use-thread-selection"; | ||
| 17 | import { useServerConfigs } from "../../state/entities"; | ||
| 18 | import { appAtomRegistry } from "../../state/atom-registry"; | ||
| 19 | import { | ||
| 20 | appendComposerDraftAttachments, | ||
| 21 | composerDraftsAtom, | ||
| 22 | removeComposerDraftAttachment, | ||
| 23 | releaseUnusedComposerAttachmentFiles, | ||
| 24 | } from "../../state/use-composer-drafts"; | ||
| 25 | import { | ||
| 26 | changeQuestionAttachmentPreparation, | ||
| 27 | questionAttachmentDraftKey, | ||
| 28 | questionAttachmentPreparationAtom, | ||
| 29 | } from "../../state/question-attachments"; | ||
| 30 | |||
| 31 | export function QuestionAttachments(props: { | ||
| 32 | requestId: ApprovalRequestId; | ||
| 33 | question: UserInputQuestion; | ||
| 34 | questions: ReadonlyArray<UserInputQuestion>; | ||
| 35 | disabled: boolean; | ||
| 36 | value: string; | ||
| 37 | onChangeText: (value: string) => void; | ||
| 38 | onInputFocusChange?: ((focused: boolean) => void) | undefined; | ||
| 39 | }) { | ||
| 40 | const { selectedThread } = useThreadSelection(); | ||
| 41 | const configs = useServerConfigs(); | ||
| 42 | const drafts = useAtomValue(composerDraftsAtom); | ||
| 43 | const scopeKey = JSON.stringify([ | ||
| 44 | selectedThread?.environmentId, | ||
| 45 | selectedThread?.id, | ||
| 46 | props.requestId, | ||
| 47 | props.question.id, | ||
| 48 | ]); | ||
| 49 | const pickerScope = useRef<{ key: string; active: boolean } | null>(null); | ||
| 50 | useEffect(() => { | ||
| 51 | const scope = { key: scopeKey, active: true }; | ||
| 52 | pickerScope.current = scope; | ||
| 53 | return () => { | ||
| 54 | scope.active = false; | ||
| 55 | }; | ||
| 56 | }, [scopeKey]); | ||
| 57 | const append = ( | ||
| 58 | key: string, | ||
| 59 | attachments: Parameters<typeof appendComposerDraftAttachments>[1], | ||
| 60 | ) => { | ||
| 61 | if (!selectedThread) return 0; | ||
| 62 | const current = appAtomRegistry.get(composerDraftsAtom); | ||
| 63 | const otherCount = props.questions.reduce((count, question) => { | ||
| 64 | const target = questionAttachmentDraftKey( | ||
| 65 | selectedThread.environmentId, | ||
| 66 | selectedThread.id, | ||
| 67 | props.requestId, | ||
| 68 | question.id, | ||
| 69 | ); | ||
| 70 | return target === key ? count : count + (current[target]?.attachments.length ?? 0); | ||
| 71 | }, 0); | ||
| 72 | return appendComposerDraftAttachments(key, attachments, { | ||
| 73 | maxAttachments: Math.max(0, PROVIDER_SEND_TURN_MAX_ATTACHMENTS - otherCount), | ||
| 74 | }); | ||
| 75 | }; | ||
| 76 | const paste = useNativePaste((uris) => { | ||
| 77 | const scope = pickerScope.current; | ||
| 78 | if ( | ||
| 79 | !selectedThread || | ||
| 80 | props.disabled || | ||
| 81 | !configs.get(selectedThread.environmentId)?.environment.capabilities.questionAttachments | ||
| 82 | ) | ||
| 83 | return; | ||
| 84 | const key = questionAttachmentDraftKey( | ||
| 85 | selectedThread.environmentId, | ||
| 86 | selectedThread.id, | ||
| 87 | props.requestId, | ||
| 88 | props.question.id, | ||
| 89 | ); | ||
| 90 | changeQuestionAttachmentPreparation(key, 1); | ||
| 91 | void convertPastedImagesToAttachments({ | ||
| 92 | uris, | ||
| 93 | existingCount: appAtomRegistry.get(composerDraftsAtom)[key]?.attachments.length ?? 0, | ||
| 94 | }) | ||
| 95 | .then(async (images) => { | ||
| 96 | if ( | ||
| 97 | scope?.active && | ||
| 98 | (appAtomRegistry.get(questionAttachmentPreparationAtom)[key] ?? 0) > 0 | ||
| 99 | ) { | ||
| 100 | if (append(key, images) > 0) | ||
| 101 | Alert.alert("Could not paste image", "Too many attachments."); | ||
| 102 | } else await releaseUnusedComposerAttachmentFiles(images); | ||
| 103 | }) | ||
| 104 | .catch((error) => | ||
| 105 | Alert.alert("Could not paste image", error instanceof Error ? error.message : "Try again."), | ||
| 106 | ) | ||
| 107 | .finally(() => changeQuestionAttachmentPreparation(key, -1)); | ||
| 108 | }); | ||
| 109 | if (!selectedThread || props.question.allowCustomAnswer === false) return null; | ||
| 110 | const { environmentId, id: threadId } = selectedThread; | ||
| 111 | const capabilities = configs.get(environmentId)?.environment.capabilities; | ||
| 112 | const canAttach = capabilities?.questionAttachments === true; | ||
| 113 | const key = questionAttachmentDraftKey( | ||
| 114 | environmentId, | ||
| 115 | threadId, | ||
| 116 | props.requestId, | ||
| 117 | props.question.id, | ||
| 118 | ); | ||
| 119 | const attachments = drafts[key]?.attachments ?? []; | ||
| 120 | const pick = async (kind: "media" | "files") => { | ||
| 121 | const scope = pickerScope.current; | ||
| 122 | changeQuestionAttachmentPreparation(key, 1); | ||
| 123 | try { | ||
| 124 | const existingCount = appAtomRegistry.get(composerDraftsAtom)[key]?.attachments.length ?? 0; | ||
| 125 | const result = | ||
| 126 | kind === "files" | ||
| 127 | ? await pickComposerFiles({ | ||
| 128 | existingCount, | ||
| 129 | maxBytes: capabilities?.fileAttachments?.maxUploadBytes, | ||
| 130 | }) | ||
| 131 | : await pickComposerMedia({ | ||
| 132 | existingCount, | ||
| 133 | maxVideoBytes: capabilities?.fileAttachments?.maxUploadBytes, | ||
| 134 | }); | ||
| 135 | const picked = "files" in result ? result.files : result.attachments; | ||
| 136 | // Resolution on another client clears the reservation while the picker is open. | ||
| 137 | if ( | ||
| 138 | !scope?.active || | ||
| 139 | (appAtomRegistry.get(questionAttachmentPreparationAtom)[key] ?? 0) === 0 | ||
| 140 | ) { | ||
| 141 | await releaseUnusedComposerAttachmentFiles(picked); | ||
| 142 | return; | ||
| 143 | } | ||
| 144 | const rejected = append(key, picked); | ||
| 145 | if (result.error || rejected > 0) | ||
| 146 | Alert.alert("Could not attach file", result.error ?? "Too many attachments."); | ||
| 147 | } catch (error) { | ||
| 148 | Alert.alert("Could not attach file", error instanceof Error ? error.message : "Try again."); | ||
| 149 | } finally { | ||
| 150 | changeQuestionAttachmentPreparation(key, -1); | ||
| 151 | } | ||
| 152 | }; | ||
| 153 | return ( | ||
| 154 | <View className="gap-2"> | ||
| 155 | {canAttach ? ( | ||
| 156 | <ComposerAttachmentButton | ||
| 157 | disabled={props.disabled} | ||
| 158 | supportsFiles={Boolean(capabilities?.fileAttachments)} | ||
| 159 | onPickMedia={() => pick("media")} | ||
| 160 | onPickFiles={() => pick("files")} | ||
| 161 | /> | ||
| 162 | ) : null} | ||
| 163 | <ComposerAttachmentStrip | ||
| 164 | environmentId={environmentId} | ||
| 165 | attachments={attachments} | ||
| 166 | onRemove={(id) => { | ||
| 167 | if (!props.disabled) removeComposerDraftAttachment(key, id); | ||
| 168 | }} | ||
| 169 | /> | ||
| 170 | <TextInputWrapper onPaste={paste}> | ||
| 171 | <TextInput | ||
| 172 | value={props.value} | ||
| 173 | editable={!props.disabled} | ||
| 174 | onChangeText={props.onChangeText} | ||
| 175 | onFocus={() => props.onInputFocusChange?.(true)} | ||
| 176 | onBlur={() => props.onInputFocusChange?.(false)} | ||
| 177 | placeholder="Or type a custom answer" | ||
| 178 | className="min-h-[54px] rounded-2xl border border-input-border bg-input px-3.5 py-3 font-sans text-base text-foreground" | ||
| 179 | /> | ||
| 180 | </TextInputWrapper> | ||
| 181 | </View> | ||
| 182 | ); | ||
| 183 | } | ||
apps/mobile/src/features/threads/thread-work-log.tsx+8-1| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { QuestionAnswerHistory } from "./QuestionAnswerHistory"; | ||
| 1 | import * as Haptics from "expo-haptics"; | 2 | import * as Haptics from "expo-haptics"; |
| 2 | import { Image } from "expo-image"; | 3 | import { Image } from "expo-image"; |
| 3 | import { type AppSymbolName, SymbolView } from "../../components/AppSymbol"; | 4 | import { type AppSymbolName, SymbolView } from "../../components/AppSymbol"; |
| ... | @@ -849,13 +850,19 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( | ... | @@ -849,13 +850,19 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( |
| 849 | </View> | 850 | </View> |
| 850 | </Pressable> | 851 | </Pressable> |
| 851 | 852 | ||
| 852 | {expanded && (fullDetail || viewedImagePath) ? ( | 853 | {expanded && (fullDetail || viewedImagePath || row.workEntry.questionAnswer) ? ( |
| 853 | <Animated.View | 854 | <Animated.View |
| 854 | entering={WORK_LOG_DETAIL_ENTER_TRANSITION} | 855 | entering={WORK_LOG_DETAIL_ENTER_TRANSITION} |
| 855 | exiting={WORK_LOG_DETAIL_EXIT_TRANSITION} | 856 | exiting={WORK_LOG_DETAIL_EXIT_TRANSITION} |
| 856 | layout={WORK_LOG_LAYOUT_TRANSITION} | 857 | layout={WORK_LOG_LAYOUT_TRANSITION} |
| 857 | className="ml-7 border-l border-adaptive-neutral-300-a60-white-a12 pb-1 pl-3 pt-0.5" | 858 | className="ml-7 border-l border-adaptive-neutral-300-a60-white-a12 pb-1 pl-3 pt-0.5" |
| 858 | > | 859 | > |
| 860 | {row.workEntry.questionAnswer ? ( | ||
| 861 | <QuestionAnswerHistory | ||
| 862 | environmentId={props.environmentId} | ||
| 863 | answer={row.workEntry.questionAnswer} | ||
| 864 | /> | ||
| 865 | ) : null} | ||
| 859 | {viewedImagePath ? ( | 866 | {viewedImagePath ? ( |
| 860 | <View className="pb-1.5"> | 867 | <View className="pb-1.5"> |
| 861 | {props.renderImage({ href: viewedImagePath, alt: null, title: null })} | 868 | {props.renderImage({ href: viewedImagePath, alt: null, title: null })} |
apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts+26-1| ... | @@ -1,5 +1,10 @@ | ... | @@ -1,5 +1,10 @@ |
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | 1 | import { ApprovalRequestId, EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 2 | import { describe, expect, it, vi } from "vite-plus/test"; | 2 | import { describe, expect, it, vi } from "vite-plus/test"; |
| 3 | import { | ||
| 4 | questionAttachmentDraftKey, | ||
| 5 | questionAttachmentDraftPrefix, | ||
| 6 | } from "../state/question-attachments"; | ||
| 7 | vi.mock("../state/atom-registry", () => ({ appAtomRegistry: {} })); | ||
| 3 | 8 | ||
| 4 | import { | 9 | import { |
| 5 | composerAttachmentUploadBlockReason, | 10 | composerAttachmentUploadBlockReason, |
| ... | @@ -217,6 +222,26 @@ describe("composer attachment upload queue", () => { | ... | @@ -217,6 +222,26 @@ describe("composer attachment upload queue", () => { |
| 217 | }); | 222 | }); |
| 218 | 223 | ||
| 219 | describe("draft upload scope and offline submission", () => { | 224 | describe("draft upload scope and offline submission", () => { |
| 225 | it("uploads question drafts in their environment without matching other thread prefixes", () => { | ||
| 226 | const requestId = ApprovalRequestId.make("request:1"); | ||
| 227 | for (const environment of [environmentId, EnvironmentId.make("remote:server")]) { | ||
| 228 | const threadId = ThreadId.make("thread:1"); | ||
| 229 | const prefix = questionAttachmentDraftPrefix(environment, threadId); | ||
| 230 | const key = questionAttachmentDraftKey(environment, threadId, requestId, "question:1"); | ||
| 231 | expect(composerDraftEnvironmentId(key, [])).toBe(environment); | ||
| 232 | expect(key.startsWith(prefix)).toBe(true); | ||
| 233 | for (const suffix of ["-extra", ":extra", "/extra", "%extra"]) { | ||
| 234 | const otherKey = questionAttachmentDraftKey( | ||
| 235 | environment, | ||
| 236 | ThreadId.make(`${threadId}${suffix}`), | ||
| 237 | requestId, | ||
| 238 | "question:1", | ||
| 239 | ); | ||
| 240 | expect(otherKey.startsWith(prefix)).toBe(false); | ||
| 241 | expect(composerDraftEnvironmentId(otherKey, [])).toBe(environment); | ||
| 242 | } | ||
| 243 | } | ||
| 244 | }); | ||
| 220 | it("resolves thread, new-task, and queued-task drafts without crossing environments", () => { | 245 | it("resolves thread, new-task, and queued-task drafts without crossing environments", () => { |
| 221 | expect(composerDraftEnvironmentId("environment-1:thread", [])).toBe(environmentId); | 246 | expect(composerDraftEnvironmentId("environment-1:thread", [])).toBe(environmentId); |
| 222 | expect(composerDraftEnvironmentId("new-task:environment-1:project", [])).toBe(environmentId); | 247 | expect(composerDraftEnvironmentId("new-task:environment-1:project", [])).toBe(environmentId); |
apps/mobile/src/lib/threadActivity.test.ts+70| ... | @@ -2,6 +2,7 @@ import { derivePendingRequests } from "@t3tools/client-runtime/pending-requests" | ... | @@ -2,6 +2,7 @@ import { derivePendingRequests } from "@t3tools/client-runtime/pending-requests" |
| 2 | import { describe, expect, it } from "vite-plus/test"; | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | ||
| 4 | import { | 4 | import { |
| 5 | ApprovalRequestId, | ||
| 5 | EventId, | 6 | EventId, |
| 6 | MessageId, | 7 | MessageId, |
| 7 | ProjectId, | 8 | ProjectId, |
| ... | @@ -3314,3 +3315,72 @@ describe("quiet timeline: nested agents", () => { | ... | @@ -3314,3 +3315,72 @@ describe("quiet timeline: nested agents", () => { |
| 3314 | ]); | 3315 | ]); |
| 3315 | }); | 3316 | }); |
| 3316 | }); | 3317 | }); |
| 3318 | |||
| 3319 | it("accepts ready attachment-only answers while preserving selected options", () => { | ||
| 3320 | const question = { | ||
| 3321 | id: "q", | ||
| 3322 | header: "Spec", | ||
| 3323 | question: "Provide a specification", | ||
| 3324 | options: [{ label: "Yes", description: "Approve" }], | ||
| 3325 | multiSelect: false, | ||
| 3326 | }; | ||
| 3327 | expect(buildPendingUserInputAnswers([question], { q: { attachmentCount: 1 } })).toEqual({ | ||
| 3328 | q: "", | ||
| 3329 | }); | ||
| 3330 | expect( | ||
| 3331 | buildPendingUserInputAnswers([question], { | ||
| 3332 | q: { attachmentCount: 1, selectedOptionValues: ["Yes"] }, | ||
| 3333 | }), | ||
| 3334 | ).toEqual({ q: "Yes" }); | ||
| 3335 | expect( | ||
| 3336 | buildPendingUserInputAnswers([question], { | ||
| 3337 | q: { attachmentCount: 1, attachmentsBlocked: true }, | ||
| 3338 | }), | ||
| 3339 | ).toBeNull(); | ||
| 3340 | expect( | ||
| 3341 | buildPendingUserInputAnswers([{ ...question, allowCustomAnswer: false }], { | ||
| 3342 | q: { attachmentCount: 1 }, | ||
| 3343 | }), | ||
| 3344 | ).toBeNull(); | ||
| 3345 | }); | ||
| 3346 | |||
| 3347 | it("makes attachment-only question answers expandable in the mobile feed", () => { | ||
| 3348 | const answer = { | ||
| 3349 | requestId: ApprovalRequestId.make("question-request"), | ||
| 3350 | answers: { q: "" }, | ||
| 3351 | questionTextById: { q: "Attach the specification" }, | ||
| 3352 | attachmentsByQuestionId: { | ||
| 3353 | q: [ | ||
| 3354 | { | ||
| 3355 | type: "file" as const, | ||
| 3356 | id: "question-file", | ||
| 3357 | name: "spec.txt", | ||
| 3358 | mimeType: "text/plain", | ||
| 3359 | sizeBytes: 4, | ||
| 3360 | }, | ||
| 3361 | ], | ||
| 3362 | }, | ||
| 3363 | }; | ||
| 3364 | const thread = makeThread({ | ||
| 3365 | id: ThreadId.make("thread-answer"), | ||
| 3366 | projectId: ProjectId.make("project-answer"), | ||
| 3367 | title: "Answer history", | ||
| 3368 | activities: [ | ||
| 3369 | makeActivity({ | ||
| 3370 | id: EventId.make("answer-submitted"), | ||
| 3371 | createdAt: "2026-09-08T00:00:00.000Z", | ||
| 3372 | kind: "user-input.answer-submitted", | ||
| 3373 | summary: "Answered questions", | ||
| 3374 | payload: answer, | ||
| 3375 | }), | ||
| 3376 | ], | ||
| 3377 | }); | ||
| 3378 | const [group] = buildThreadFeed(thread); | ||
| 3379 | expect(group?.type).toBe("activity-group"); | ||
| 3380 | if (group?.type !== "activity-group") return; | ||
| 3381 | expect(group.activities[0]).toMatchObject({ | ||
| 3382 | canExpand: true, | ||
| 3383 | workEntry: { questionAnswer: answer }, | ||
| 3384 | }); | ||
| 3385 | expect(group.activities[0]?.getFullDetail()).toBeNull(); | ||
| 3386 | }); |
apps/mobile/src/lib/threadActivity.ts+24-3| ... | @@ -1,8 +1,10 @@ | ... | @@ -1,8 +1,10 @@ |
| 1 | import * as Option from "effect/Option"; | ||
| 2 | import * as Schema from "effect/Schema"; | ||
| 1 | import { | 3 | import { |
| 2 | requestKindFromRequestType, | 4 | requestKindFromRequestType, |
| 3 | type PendingApproval, | 5 | type PendingApproval, |
| 4 | } from "@t3tools/client-runtime/pending-requests"; | 6 | } from "@t3tools/client-runtime/pending-requests"; |
| 5 | import { isToolLifecycleItemType } from "@t3tools/contracts"; | 7 | import { UserInputAttachmentAnswerPayload, isToolLifecycleItemType } from "@t3tools/contracts"; |
| 6 | import type { | 8 | import type { |
| 7 | OrchestrationLatestTurn, | 9 | OrchestrationLatestTurn, |
| 8 | OrchestrationThread, | 10 | OrchestrationThread, |
| ... | @@ -41,6 +43,8 @@ export type { PendingApproval, PendingUserInput } from "@t3tools/client-runtime/ | ... | @@ -41,6 +43,8 @@ export type { PendingApproval, PendingUserInput } from "@t3tools/client-runtime/ |
| 41 | export interface PendingUserInputDraftAnswer { | 43 | export interface PendingUserInputDraftAnswer { |
| 42 | readonly selectedOptionValues?: ReadonlyArray<string>; | 44 | readonly selectedOptionValues?: ReadonlyArray<string>; |
| 43 | readonly customAnswer?: string; | 45 | readonly customAnswer?: string; |
| 46 | readonly attachmentCount?: number; | ||
| 47 | readonly attachmentsBlocked?: boolean; | ||
| 44 | } | 48 | } |
| 45 | 49 | ||
| 46 | export interface ThreadFeedActivity { | 50 | export interface ThreadFeedActivity { |
| ... | @@ -76,6 +80,7 @@ export interface ThreadFeedActivity { | ... | @@ -76,6 +80,7 @@ export interface ThreadFeedActivity { |
| 76 | } | 80 | } |
| 77 | 81 | ||
| 78 | export interface WorkLogEntry { | 82 | export interface WorkLogEntry { |
| 83 | readonly questionAnswer?: UserInputAttachmentAnswerPayload; | ||
| 79 | id: string; | 84 | id: string; |
| 80 | createdAt: string; | 85 | createdAt: string; |
| 81 | turnId: TurnId | null; | 86 | turnId: TurnId | null; |
| ... | @@ -302,6 +307,7 @@ function resolvePendingUserInputAnswer( | ... | @@ -302,6 +307,7 @@ function resolvePendingUserInputAnswer( |
| 302 | question: UserInputQuestion, | 307 | question: UserInputQuestion, |
| 303 | draft: PendingUserInputDraftAnswer | undefined, | 308 | draft: PendingUserInputDraftAnswer | undefined, |
| 304 | ): string | ReadonlyArray<string> | null { | 309 | ): string | ReadonlyArray<string> | null { |
| 310 | if (draft?.attachmentsBlocked) return null; | ||
| 305 | const customAnswer = | 311 | const customAnswer = |
| 306 | question.allowCustomAnswer === false ? null : normalizeDraftAnswer(draft?.customAnswer); | 312 | question.allowCustomAnswer === false ? null : normalizeDraftAnswer(draft?.customAnswer); |
| 307 | if (customAnswer) { | 313 | if (customAnswer) { |
| ... | @@ -310,9 +316,16 @@ function resolvePendingUserInputAnswer( | ... | @@ -310,9 +316,16 @@ function resolvePendingUserInputAnswer( |
| 310 | 316 | ||
| 311 | const selectedOptionValues = normalizeSelectedOptionValues(question, draft?.selectedOptionValues); | 317 | const selectedOptionValues = normalizeSelectedOptionValues(question, draft?.selectedOptionValues); |
| 312 | if (question.multiSelect) { | 318 | if (question.multiSelect) { |
| 313 | return selectedOptionValues.length > 0 ? selectedOptionValues : null; | 319 | return selectedOptionValues.length > 0 |
| 320 | ? selectedOptionValues | ||
| 321 | : question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 | ||
| 322 | ? "" | ||
| 323 | : null; | ||
| 314 | } | 324 | } |
| 315 | return selectedOptionValues[0] ?? null; | 325 | return ( |
| 326 | selectedOptionValues[0] ?? | ||
| 327 | (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null) | ||
| 328 | ); | ||
| 316 | } | 329 | } |
| 317 | 330 | ||
| 318 | /** Some providers settle agents through task.updated instead of task.completed. */ | 331 | /** Some providers settle agents through task.updated instead of task.completed. */ |
| ... | @@ -435,6 +448,8 @@ function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): bool | ... | @@ -435,6 +448,8 @@ function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): bool |
| 435 | return typeof payload?.detail === "string" && payload.detail.startsWith("ExitPlanMode:"); | 448 | return typeof payload?.detail === "string" && payload.detail.startsWith("ExitPlanMode:"); |
| 436 | } | 449 | } |
| 437 | 450 | ||
| 451 | const decodeQuestionAttachmentAnswer = Schema.decodeUnknownOption(UserInputAttachmentAnswerPayload); | ||
| 452 | |||
| 438 | function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWorkLogEntry { | 453 | function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWorkLogEntry { |
| 439 | const payload = | 454 | const payload = |
| 440 | activity.payload && typeof activity.payload === "object" | 455 | activity.payload && typeof activity.payload === "object" |
| ... | @@ -480,6 +495,11 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo | ... | @@ -480,6 +495,11 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo |
| 480 | ? "info" | 495 | ? "info" |
| 481 | : activity.tone, | 496 | : activity.tone, |
| 482 | sourceActivityKind: activity.kind, | 497 | sourceActivityKind: activity.kind, |
| 498 | ...(() => { | ||
| 499 | if (activity.kind !== "user-input.answer-submitted") return {}; | ||
| 500 | const answer = decodeQuestionAttachmentAnswer(activity.payload); | ||
| 501 | return Option.isSome(answer) ? { questionAnswer: answer.value } : {}; | ||
| 502 | })(), | ||
| 483 | }; | 503 | }; |
| 484 | const toolCallId = | 504 | const toolCallId = |
| 485 | asTrimmedString(payload?.toolCallId) ?? asTrimmedString(asRecord(payload?.data)?.toolCallId); | 505 | asTrimmedString(payload?.toolCallId) ?? asTrimmedString(asRecord(payload?.data)?.toolCallId); |
| ... | @@ -969,6 +989,7 @@ function buildWorkEntryExpandedBody(entry: WorkLogEntry): string | null { | ... | @@ -969,6 +989,7 @@ function buildWorkEntryExpandedBody(entry: WorkLogEntry): string | null { |
| 969 | * for every row (see the deferred-expansion test). | 989 | * for every row (see the deferred-expansion test). |
| 970 | */ | 990 | */ |
| 971 | function workEntryCanExpand(entry: WorkLogEntry): boolean { | 991 | function workEntryCanExpand(entry: WorkLogEntry): boolean { |
| 992 | if (entry.questionAnswer) return true; | ||
| 972 | if (entry.agentSpawn) return agentSpawnMembers(entry.agentSpawn).length > 0; | 993 | if (entry.agentSpawn) return agentSpawnMembers(entry.agentSpawn).length > 0; |
| 973 | if (entry.itemType === "mcp_tool_call" && entry.toolData !== undefined) return true; | 994 | if (entry.itemType === "mcp_tool_call" && entry.toolData !== undefined) return true; |
| 974 | if (entry.changedFiles?.some((path) => path.trim().length > 0)) return true; | 995 | if (entry.changedFiles?.some((path) => path.trim().length > 0)) return true; |
apps/mobile/src/state/question-attachments.ts created+29| ... | @@ -0,0 +1,29 @@ | ||
| 1 | import type { ApprovalRequestId, EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| 2 | import { Atom } from "effect/unstable/reactivity"; | ||
| 3 | import { appAtomRegistry } from "./atom-registry"; | ||
| 4 | |||
| 5 | export function questionAttachmentDraftPrefix( | ||
| 6 | environmentId: EnvironmentId, | ||
| 7 | threadId: ThreadId, | ||
| 8 | ): string { | ||
| 9 | return `${environmentId}:question-${encodeURIComponent(JSON.stringify(threadId))}-`; | ||
| 10 | } | ||
| 11 | export function questionAttachmentDraftKey( | ||
| 12 | environmentId: EnvironmentId, | ||
| 13 | threadId: ThreadId, | ||
| 14 | requestId: ApprovalRequestId, | ||
| 15 | questionId: string, | ||
| 16 | ): string { | ||
| 17 | return `${questionAttachmentDraftPrefix(environmentId, threadId)}${encodeURIComponent(JSON.stringify([requestId, questionId]))}`; | ||
| 18 | } | ||
| 19 | export const questionAttachmentPreparationAtom = Atom.make<Record<string, number>>({}).pipe( | ||
| 20 | Atom.keepAlive, | ||
| 21 | ); | ||
| 22 | export function changeQuestionAttachmentPreparation(key: string, delta: number): void { | ||
| 23 | const counts = appAtomRegistry.get(questionAttachmentPreparationAtom); | ||
| 24 | if (delta < 0 && !(key in counts)) return; | ||
| 25 | appAtomRegistry.set(questionAttachmentPreparationAtom, { | ||
| 26 | ...counts, | ||
| 27 | [key]: Math.max(0, (counts[key] ?? 0) + delta), | ||
| 28 | }); | ||
| 29 | } | ||
apps/mobile/src/state/use-composer-drafts.ts+8-2| ... | @@ -925,7 +925,7 @@ export function appendComposerDraftText(draftKey: string, value: string): void { | ... | @@ -925,7 +925,7 @@ export function appendComposerDraftText(draftKey: string, value: string): void { |
| 925 | export function appendComposerDraftAttachments( | 925 | export function appendComposerDraftAttachments( |
| 926 | draftKey: string, | 926 | draftKey: string, |
| 927 | attachments: ReadonlyArray<DraftComposerAttachment>, | 927 | attachments: ReadonlyArray<DraftComposerAttachment>, |
| 928 | options?: { readonly allowOverflow?: boolean }, | 928 | options?: { readonly allowOverflow?: boolean; readonly maxAttachments?: number }, |
| 929 | ): number { | 929 | ): number { |
| 930 | if (attachments.length === 0) { | 930 | if (attachments.length === 0) { |
| 931 | return 0; | 931 | return 0; |
| ... | @@ -935,7 +935,13 @@ export function appendComposerDraftAttachments( | ... | @@ -935,7 +935,13 @@ export function appendComposerDraftAttachments( |
| 935 | const existing = normalizeDraft(current[draftKey]); | 935 | const existing = normalizeDraft(current[draftKey]); |
| 936 | const remaining = options?.allowOverflow | 936 | const remaining = options?.allowOverflow |
| 937 | ? attachments.length | 937 | ? attachments.length |
| 938 | : Math.max(0, PROVIDER_SEND_TURN_MAX_ATTACHMENTS - existing.attachments.length); | 938 | : Math.max( |
| 939 | 0, | ||
| 940 | Math.min( | ||
| 941 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, | ||
| 942 | options?.maxAttachments ?? PROVIDER_SEND_TURN_MAX_ATTACHMENTS, | ||
| 943 | ) - existing.attachments.length, | ||
| 944 | ); | ||
| 939 | const accepted = attachments.slice(0, remaining); | 945 | const accepted = attachments.slice(0, remaining); |
| 940 | rejected = attachments.slice(remaining); | 946 | rejected = attachments.slice(remaining); |
| 941 | if (accepted.length === 0) { | 947 | if (accepted.length === 0) { |
apps/mobile/src/state/use-selected-thread-requests.test.tsx created+140| ... | @@ -0,0 +1,140 @@ | ||
| 1 | import { renderToStaticMarkup } from "react-dom/server"; | ||
| 2 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 3 | |||
| 4 | const fixture = vi.hoisted(() => ({ | ||
| 5 | drafts: {} as Record<string, unknown>, | ||
| 6 | uploads: {} as Record<string, unknown>, | ||
| 7 | preparations: {} as Record<string, number>, | ||
| 8 | preparationAtom: Symbol("preparation"), | ||
| 9 | })); | ||
| 10 | vi.mock("react-native", () => ({ Alert: { alert: vi.fn() } })); | ||
| 11 | vi.mock("@effect/atom-react", () => ({ | ||
| 12 | useAtomValue: (atom: unknown) => | ||
| 13 | atom === "drafts" | ||
| 14 | ? fixture.drafts | ||
| 15 | : atom === "uploads" | ||
| 16 | ? fixture.uploads | ||
| 17 | : atom === fixture.preparationAtom | ||
| 18 | ? fixture.preparations | ||
| 19 | : {}, | ||
| 20 | })); | ||
| 21 | vi.mock("./use-composer-drafts", () => ({ | ||
| 22 | composerDraftsAtom: "drafts", | ||
| 23 | clearComposerDraft: vi.fn(), | ||
| 24 | })); | ||
| 25 | vi.mock("./composer-attachment-uploads", async () => ({ | ||
| 26 | ...(await import("../lib/composerAttachmentUploadQueue")), | ||
| 27 | composerAttachmentUploadsAtom: "uploads", | ||
| 28 | })); | ||
| 29 | vi.mock("./question-attachments", async (importOriginal) => ({ | ||
| 30 | ...(await importOriginal<typeof import("./question-attachments")>()), | ||
| 31 | questionAttachmentPreparationAtom: fixture.preparationAtom, | ||
| 32 | })); | ||
| 33 | vi.mock("./entities", () => ({ | ||
| 34 | useServerConfigs: () => | ||
| 35 | new Map([ | ||
| 36 | [ | ||
| 37 | "environment-1", | ||
| 38 | { | ||
| 39 | environment: { | ||
| 40 | capabilities: { | ||
| 41 | questionAttachments: true, | ||
| 42 | attachmentUploads: true, | ||
| 43 | fileAttachments: { maxUploadBytes: 20_000_000 }, | ||
| 44 | }, | ||
| 45 | }, | ||
| 46 | }, | ||
| 47 | ], | ||
| 48 | ]), | ||
| 49 | })); | ||
| 50 | vi.mock("./threads", () => ({ threadEnvironment: {} })); | ||
| 51 | vi.mock("./use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); | ||
| 52 | vi.mock("./use-thread-selection", () => ({ | ||
| 53 | useThreadSelection: () => ({ | ||
| 54 | selectedThread: { environmentId: "environment-1", id: "thread-1" }, | ||
| 55 | }), | ||
| 56 | })); | ||
| 57 | vi.mock("./use-thread-detail", () => ({ | ||
| 58 | useSelectedThreadDetail: () => ({ | ||
| 59 | activities: [ | ||
| 60 | { | ||
| 61 | id: "request-activity", | ||
| 62 | kind: "user-input.requested", | ||
| 63 | createdAt: "2026-09-08T00:00:00Z", | ||
| 64 | payload: { | ||
| 65 | requestId: "request-1", | ||
| 66 | questions: ["first", "second"].map((id) => ({ | ||
| 67 | id, | ||
| 68 | header: id, | ||
| 69 | question: `Attach ${id} file`, | ||
| 70 | options: [], | ||
| 71 | allowCustomAnswer: true, | ||
| 72 | })), | ||
| 73 | }, | ||
| 74 | }, | ||
| 75 | ], | ||
| 76 | }), | ||
| 77 | })); | ||
| 78 | |||
| 79 | import { ApprovalRequestId, EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| 80 | import { questionAttachmentDraftKey } from "./question-attachments"; | ||
| 81 | import { useSelectedThreadRequests } from "./use-selected-thread-requests"; | ||
| 82 | |||
| 83 | const environmentId = EnvironmentId.make("environment-1"); | ||
| 84 | const key = (question: string) => | ||
| 85 | questionAttachmentDraftKey( | ||
| 86 | environmentId, | ||
| 87 | ThreadId.make("thread-1"), | ||
| 88 | ApprovalRequestId.make("request-1"), | ||
| 89 | question, | ||
| 90 | ); | ||
| 91 | function submitButtonMarkup() { | ||
| 92 | function Probe() { | ||
| 93 | const { activePendingUserInputAnswers } = useSelectedThreadRequests(); | ||
| 94 | return <button disabled={activePendingUserInputAnswers === null}>Submit answers</button>; | ||
| 95 | } | ||
| 96 | return renderToStaticMarkup(<Probe />); | ||
| 97 | } | ||
| 98 | beforeEach(() => { | ||
| 99 | fixture.preparations = {}; | ||
| 100 | fixture.drafts = Object.fromEntries( | ||
| 101 | ["first", "second"].map((id) => [ | ||
| 102 | key(id), | ||
| 103 | { | ||
| 104 | attachments: [ | ||
| 105 | { | ||
| 106 | id, | ||
| 107 | type: "file", | ||
| 108 | name: `${id}.txt`, | ||
| 109 | mimeType: "text/plain", | ||
| 110 | sizeBytes: 4, | ||
| 111 | fileUri: `file:///${id}.txt`, | ||
| 112 | }, | ||
| 113 | ], | ||
| 114 | }, | ||
| 115 | ]), | ||
| 116 | ); | ||
| 117 | fixture.uploads = { "environment-1:first": { status: "ready" } }; | ||
| 118 | }); | ||
| 119 | describe("question attachment submission readiness", () => { | ||
| 120 | it.each([ | ||
| 121 | undefined, | ||
| 122 | { status: "uploading", progress: 0.5 }, | ||
| 123 | { status: "failed", reason: "Offline" }, | ||
| 124 | ])("keeps Submit disabled until all question uploads finish: %j", (state) => { | ||
| 125 | if (state) fixture.uploads["environment-1:second"] = state; | ||
| 126 | expect(submitButtonMarkup()).toContain("disabled"); | ||
| 127 | fixture.uploads["environment-1:second"] = { status: "ready" }; | ||
| 128 | expect(submitButtonMarkup()).not.toContain("disabled"); | ||
| 129 | }); | ||
| 130 | it("ignores an upload in another environment", () => { | ||
| 131 | fixture.uploads["environment-1:second"] = { status: "ready" }; | ||
| 132 | fixture.uploads["environment-2:second"] = { status: "uploading", progress: 0.5 }; | ||
| 133 | expect(submitButtonMarkup()).not.toContain("disabled"); | ||
| 134 | }); | ||
| 135 | it("waits for attachment preparation even when uploads are ready", () => { | ||
| 136 | fixture.uploads["environment-1:second"] = { status: "ready" }; | ||
| 137 | fixture.preparations[key("first")] = 1; | ||
| 138 | expect(submitButtonMarkup()).toContain("disabled"); | ||
| 139 | }); | ||
| 140 | }); | ||
apps/mobile/src/state/use-selected-thread-requests.ts+138-4| ... | @@ -1,6 +1,19 @@ | ... | @@ -1,6 +1,19 @@ |
| 1 | import { derivePendingRequests } from "@t3tools/client-runtime/pending-requests"; | 1 | import { derivePendingRequests } from "@t3tools/client-runtime/pending-requests"; |
| 2 | import { useServerConfigs } from "./entities"; | ||
| 3 | import { Alert } from "react-native"; | ||
| 4 | import { | ||
| 5 | questionAttachmentDraftKey, | ||
| 6 | questionAttachmentDraftPrefix, | ||
| 7 | questionAttachmentPreparationAtom, | ||
| 8 | } from "./question-attachments"; | ||
| 9 | import { composerDraftsAtom, clearComposerDraft } from "./use-composer-drafts"; | ||
| 10 | import { | ||
| 11 | composerAttachmentUploadsAtom, | ||
| 12 | composerAttachmentUploadBlockReason, | ||
| 13 | composerAttachmentsStillUploading, | ||
| 14 | } from "./composer-attachment-uploads"; | ||
| 2 | import { useAtomValue } from "@effect/atom-react"; | 15 | import { useAtomValue } from "@effect/atom-react"; |
| 3 | import { useCallback, useMemo, useState } from "react"; | 16 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 4 | 17 | ||
| 5 | import { | 18 | import { |
| 6 | ApprovalRequestId, | 19 | ApprovalRequestId, |
| ... | @@ -81,6 +94,7 @@ export function useSelectedThreadRequests() { | ... | @@ -81,6 +94,7 @@ export function useSelectedThreadRequests() { |
| 81 | const selectedThread = useSelectedThreadDetail(); | 94 | const selectedThread = useSelectedThreadDetail(); |
| 82 | const userInputDraftsByRequestKey = useAtomValue(userInputDraftsByRequestKeyAtom); | 95 | const userInputDraftsByRequestKey = useAtomValue(userInputDraftsByRequestKeyAtom); |
| 83 | const [respondingApprovalId, setRespondingApprovalId] = useState<ApprovalRequestId | null>(null); | 96 | const [respondingApprovalId, setRespondingApprovalId] = useState<ApprovalRequestId | null>(null); |
| 97 | const userInputResponsesInFlight = useRef(new Set<string>()); | ||
| 84 | const [respondingUserInputId, setRespondingUserInputId] = useState<ApprovalRequestId | null>( | 98 | const [respondingUserInputId, setRespondingUserInputId] = useState<ApprovalRequestId | null>( |
| 85 | null, | 99 | null, |
| 86 | ); | 100 | ); |
| ... | @@ -91,11 +105,81 @@ export function useSelectedThreadRequests() { | ... | @@ -91,11 +105,81 @@ export function useSelectedThreadRequests() { |
| 91 | ); | 105 | ); |
| 92 | const activePendingApproval = activePendingApprovals[0] ?? null; | 106 | const activePendingApproval = activePendingApprovals[0] ?? null; |
| 93 | const activePendingUserInput = activePendingUserInputs[0] ?? null; | 107 | const activePendingUserInput = activePendingUserInputs[0] ?? null; |
| 108 | const questionServerConfigs = useServerConfigs(); | ||
| 109 | const attachmentDrafts = useAtomValue(composerDraftsAtom); | ||
| 110 | const preparationCounts = useAtomValue(questionAttachmentPreparationAtom); | ||
| 111 | const uploadStates = useAtomValue(composerAttachmentUploadsAtom); | ||
| 112 | useEffect(() => { | ||
| 113 | if (!selectedThreadShell || !selectedThread) return; | ||
| 114 | const prefix = questionAttachmentDraftPrefix( | ||
| 115 | selectedThreadShell.environmentId, | ||
| 116 | selectedThreadShell.id, | ||
| 117 | ); | ||
| 118 | const retained = new Set( | ||
| 119 | activePendingUserInputs.flatMap((request) => | ||
| 120 | request.questions.map((question) => | ||
| 121 | questionAttachmentDraftKey( | ||
| 122 | selectedThreadShell.environmentId, | ||
| 123 | selectedThreadShell.id, | ||
| 124 | request.requestId, | ||
| 125 | question.id, | ||
| 126 | ), | ||
| 127 | ), | ||
| 128 | ), | ||
| 129 | ); | ||
| 130 | const counts = { ...appAtomRegistry.get(questionAttachmentPreparationAtom) }; | ||
| 131 | let changed = false; | ||
| 132 | for (const key of new Set([...Object.keys(attachmentDrafts), ...Object.keys(counts)])) { | ||
| 133 | if (!key.startsWith(prefix) || retained.has(key)) continue; | ||
| 134 | if (attachmentDrafts[key]) clearComposerDraft(key); | ||
| 135 | if (key in counts) { | ||
| 136 | delete counts[key]; | ||
| 137 | changed = true; | ||
| 138 | } | ||
| 139 | } | ||
| 140 | if (changed) appAtomRegistry.set(questionAttachmentPreparationAtom, counts); | ||
| 141 | }, [activePendingUserInputs, attachmentDrafts, selectedThread, selectedThreadShell]); | ||
| 94 | const activePendingUserInputDrafts = | 142 | const activePendingUserInputDrafts = |
| 95 | activePendingUserInput && selectedThreadShell | 143 | activePendingUserInput && selectedThreadShell |
| 96 | ? (userInputDraftsByRequestKey[ | 144 | ? Object.fromEntries( |
| 97 | scopedRequestKey(selectedThreadShell.environmentId, activePendingUserInput.requestId) | 145 | activePendingUserInput.questions.map((question) => { |
| 98 | ] ?? {}) | 146 | const key = questionAttachmentDraftKey( |
| 147 | selectedThreadShell.environmentId, | ||
| 148 | selectedThreadShell.id, | ||
| 149 | activePendingUserInput.requestId, | ||
| 150 | question.id, | ||
| 151 | ); | ||
| 152 | const attachments = attachmentDrafts[key]?.attachments ?? []; | ||
| 153 | const uploadInput = { | ||
| 154 | environmentId: selectedThreadShell.environmentId, | ||
| 155 | attachments, | ||
| 156 | serverConfig: questionServerConfigs.get(selectedThreadShell.environmentId) ?? null, | ||
| 157 | states: uploadStates, | ||
| 158 | }; | ||
| 159 | return [ | ||
| 160 | question.id, | ||
| 161 | { | ||
| 162 | ...userInputDraftsByRequestKey[ | ||
| 163 | scopedRequestKey( | ||
| 164 | selectedThreadShell.environmentId, | ||
| 165 | activePendingUserInput.requestId, | ||
| 166 | ) | ||
| 167 | ]?.[question.id], | ||
| 168 | attachmentCount: attachments.length, | ||
| 169 | attachmentsBlocked: | ||
| 170 | (attachments.length > 0 && | ||
| 171 | questionServerConfigs.get(selectedThreadShell.environmentId)?.environment | ||
| 172 | .capabilities.questionAttachments !== true) || | ||
| 173 | (preparationCounts[key] ?? 0) > 0 || | ||
| 174 | composerAttachmentsStillUploading(uploadInput) || | ||
| 175 | composerAttachmentUploadBlockReason({ | ||
| 176 | ...uploadInput, | ||
| 177 | connected: true, | ||
| 178 | }) !== null, | ||
| 179 | }, | ||
| 180 | ]; | ||
| 181 | }), | ||
| 182 | ) | ||
| 99 | : {}; | 183 | : {}; |
| 100 | const activePendingUserInputAnswers = activePendingUserInput | 184 | const activePendingUserInputAnswers = activePendingUserInput |
| 101 | ? buildPendingUserInputAnswers(activePendingUserInput.questions, activePendingUserInputDrafts) | 185 | ? buildPendingUserInputAnswers(activePendingUserInput.questions, activePendingUserInputDrafts) |
| ... | @@ -154,6 +238,52 @@ export function useSelectedThreadRequests() { | ... | @@ -154,6 +238,52 @@ export function useSelectedThreadRequests() { |
| 154 | return; | 238 | return; |
| 155 | } | 239 | } |
| 156 | 240 | ||
| 241 | const responseKey = questionAttachmentDraftKey( | ||
| 242 | selectedThreadShell.environmentId, | ||
| 243 | selectedThreadShell.id, | ||
| 244 | activePendingUserInput.requestId, | ||
| 245 | "", | ||
| 246 | ); | ||
| 247 | if (userInputResponsesInFlight.current.has(responseKey)) return; | ||
| 248 | const attachmentsByQuestionId = new Map< | ||
| 249 | string, | ||
| 250 | import("@t3tools/contracts").UserInputAttachments[string] | ||
| 251 | >(); | ||
| 252 | for (const question of activePendingUserInput.questions) { | ||
| 253 | const key = questionAttachmentDraftKey( | ||
| 254 | selectedThreadShell.environmentId, | ||
| 255 | selectedThreadShell.id, | ||
| 256 | activePendingUserInput.requestId, | ||
| 257 | question.id, | ||
| 258 | ); | ||
| 259 | if ((appAtomRegistry.get(questionAttachmentPreparationAtom)[key] ?? 0) > 0) return; | ||
| 260 | const attachments = appAtomRegistry.get(composerDraftsAtom)[key]?.attachments ?? []; | ||
| 261 | if (attachments.length === 0) continue; | ||
| 262 | if ( | ||
| 263 | attachments.some( | ||
| 264 | (attachment) => | ||
| 265 | !attachment.uploadedAttachmentId || | ||
| 266 | attachment.uploadEnvironmentId !== selectedThreadShell.environmentId, | ||
| 267 | ) | ||
| 268 | ) { | ||
| 269 | Alert.alert( | ||
| 270 | "Attachments are not ready", | ||
| 271 | "Wait for uploads to finish, or retry failed uploads.", | ||
| 272 | ); | ||
| 273 | return; | ||
| 274 | } | ||
| 275 | attachmentsByQuestionId.set( | ||
| 276 | question.id, | ||
| 277 | attachments.map((attachment) => ({ | ||
| 278 | type: attachment.type, | ||
| 279 | id: attachment.uploadedAttachmentId!, | ||
| 280 | name: attachment.name, | ||
| 281 | mimeType: attachment.mimeType, | ||
| 282 | sizeBytes: attachment.sizeBytes, | ||
| 283 | })), | ||
| 284 | ); | ||
| 285 | } | ||
| 286 | userInputResponsesInFlight.current.add(responseKey); | ||
| 157 | setRespondingUserInputId(activePendingUserInput.requestId); | 287 | setRespondingUserInputId(activePendingUserInput.requestId); |
| 158 | const result = await respondToUserInput({ | 288 | const result = await respondToUserInput({ |
| 159 | environmentId: selectedThreadShell.environmentId, | 289 | environmentId: selectedThreadShell.environmentId, |
| ... | @@ -161,8 +291,12 @@ export function useSelectedThreadRequests() { | ... | @@ -161,8 +291,12 @@ export function useSelectedThreadRequests() { |
| 161 | threadId: selectedThreadShell.id, | 291 | threadId: selectedThreadShell.id, |
| 162 | requestId: activePendingUserInput.requestId, | 292 | requestId: activePendingUserInput.requestId, |
| 163 | answers: activePendingUserInputAnswers, | 293 | answers: activePendingUserInputAnswers, |
| 294 | ...(attachmentsByQuestionId.size > 0 | ||
| 295 | ? { attachmentsByQuestionId: Object.fromEntries(attachmentsByQuestionId) } | ||
| 296 | : {}), | ||
| 164 | }, | 297 | }, |
| 165 | }); | 298 | }); |
| 299 | userInputResponsesInFlight.current.delete(responseKey); | ||
| 166 | setRespondingUserInputId((current) => | 300 | setRespondingUserInputId((current) => |
| 167 | current === activePendingUserInput.requestId ? null : current, | 301 | current === activePendingUserInput.requestId ? null : current, |
| 168 | ); | 302 | ); |
apps/server/src/environment/ServerEnvironment.ts+1| ... | @@ -215,6 +215,7 @@ export const make = Effect.gen(function* () { | ... | @@ -215,6 +215,7 @@ export const make = Effect.gen(function* () { |
| 215 | repositoryIdentity: true, | 215 | repositoryIdentity: true, |
| 216 | connectionProbe: true, | 216 | connectionProbe: true, |
| 217 | attachmentUploads: true, | 217 | attachmentUploads: true, |
| 218 | questionAttachments: true, | ||
| 218 | fileAttachments: { maxUploadBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES }, | 219 | fileAttachments: { maxUploadBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES }, |
| 219 | pullRequests: true, | 220 | pullRequests: true, |
| 220 | threadSettlement: true, | 221 | threadSettlement: true, |
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+13-1| ... | @@ -239,6 +239,17 @@ describe("OrchestrationEngine", () => { | ... | @@ -239,6 +239,17 @@ describe("OrchestrationEngine", () => { |
| 239 | threadId, | 239 | threadId, |
| 240 | requestId, | 240 | requestId, |
| 241 | answers: { "0": "pnpm", "1": "Example" }, | 241 | answers: { "0": "pnpm", "1": "Example" }, |
| 242 | attachmentsByQuestionId: { | ||
| 243 | "1": [ | ||
| 244 | { | ||
| 245 | type: "file" as const, | ||
| 246 | id: "thread-1-00000000-0000-4000-8000-0000000000aa-txt", | ||
| 247 | name: "spec.txt", | ||
| 248 | mimeType: "text/plain", | ||
| 249 | sizeBytes: 4, | ||
| 250 | }, | ||
| 251 | ], | ||
| 252 | }, | ||
| 242 | createdAt: "2026-01-01T00:00:02.000Z", | 253 | createdAt: "2026-01-01T00:00:02.000Z", |
| 243 | }; | 254 | }; |
| 244 | await expect( | 255 | await expect( |
| ... | @@ -256,8 +267,9 @@ describe("OrchestrationEngine", () => { | ... | @@ -256,8 +267,9 @@ describe("OrchestrationEngine", () => { |
| 256 | (message) => message.role === "user", | 267 | (message) => message.role === "user", |
| 257 | ); | 268 | ); |
| 258 | expect(userMessages).toHaveLength(1); | 269 | expect(userMessages).toHaveLength(1); |
| 270 | expect(userMessages?.[0]?.attachments).toEqual(response.attachmentsByQuestionId["1"]); | ||
| 259 | expect(userMessages?.[0]?.text).toBe( | 271 | expect(userMessages?.[0]?.text).toBe( |
| 260 | "Which package manager?\npnpm\n\nWhat should it be named?\nExample", | 272 | "Which package manager?\npnpm\n\nWhat should it be named?\nExample\nAttached file: spec.txt (thread-1-00000000-0000-4000-8000-0000000000aa-txt)", |
| 261 | ); | 273 | ); |
| 262 | expect( | 274 | expect( |
| 263 | after.threads[0]?.activities.find((activity) => activity.kind === "user-input.resolved") | 275 | after.threads[0]?.activities.find((activity) => activity.kind === "user-input.resolved") |
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+109-7| ... | @@ -411,7 +411,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { | ... | @@ -411,7 +411,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { |
| 411 | FROM projection_state | 411 | FROM projection_state |
| 412 | ORDER BY projector ASC | 412 | ORDER BY projector ASC |
| 413 | `; | 413 | `; |
| 414 | assert.equal(stateRows.length, Object.keys(ORCHESTRATION_PROJECTOR_NAMES).length); | 414 | assert.equal(stateRows.length, Object.keys(ORCHESTRATION_PROJECTOR_NAMES).length + 1); |
| 415 | for (const row of stateRows) { | 415 | for (const row of stateRows) { |
| 416 | assert.equal(row.lastAppliedSequence, 3); | 416 | assert.equal(row.lastAppliedSequence, 3); |
| 417 | } | 417 | } |
| ... | @@ -1164,14 +1164,18 @@ it.layer( | ... | @@ -1164,14 +1164,18 @@ it.layer( |
| 1164 | 1164 | ||
| 1165 | yield* projectionPipeline.bootstrap; | 1165 | yield* projectionPipeline.bootstrap; |
| 1166 | yield* projectionPipeline.bootstrap; | 1166 | yield* projectionPipeline.bootstrap; |
| 1167 | assert.deepEqual( | 1167 | assert.deepEqual(yield* projectionState.listAll(), [ |
| 1168 | yield* projectionState.listAll(), | 1168 | { |
| 1169 | cursorsBeforeFailure.map((cursor) => ({ | 1169 | projector: "projection.attachment-cleanup", |
| 1170 | lastAppliedSequence: pendingEvent.sequence, | ||
| 1171 | updatedAt: pendingEvent.occurredAt, | ||
| 1172 | }, | ||
| 1173 | ...cursorsBeforeFailure.map((cursor) => ({ | ||
| 1170 | ...cursor, | 1174 | ...cursor, |
| 1171 | lastAppliedSequence: pendingEvent.sequence, | 1175 | lastAppliedSequence: pendingEvent.sequence, |
| 1172 | updatedAt: pendingEvent.occurredAt, | 1176 | updatedAt: pendingEvent.occurredAt, |
| 1173 | })), | 1177 | })), |
| 1174 | ); | 1178 | ]); |
| 1175 | const replayedMessages = yield* sql<{ readonly text: string }>` | 1179 | const replayedMessages = yield* sql<{ readonly text: string }>` |
| 1176 | SELECT text FROM projection_thread_messages WHERE message_id = 'message-rollback' | 1180 | SELECT text FROM projection_thread_messages WHERE message_id = 'message-rollback' |
| 1177 | `; | 1181 | `; |
| ... | @@ -1364,10 +1368,53 @@ it.layer( | ... | @@ -1364,10 +1368,53 @@ it.layer( |
| 1364 | }, | 1368 | }, |
| 1365 | }); | 1369 | }); |
| 1366 | 1370 | ||
| 1371 | const answerKeepId = "thread-revert-files-00000000-0000-4000-8000-000000000006-txt"; | ||
| 1372 | const answerRemoveId = "thread-revert-files-00000000-0000-4000-8000-000000000007-txt"; | ||
| 1373 | for (const [id, turnId] of [ | ||
| 1374 | [answerKeepId, "turn-keep"], | ||
| 1375 | [answerRemoveId, "turn-remove"], | ||
| 1376 | ] as const) { | ||
| 1377 | yield* appendAndProject({ | ||
| 1378 | type: "thread.activity-appended", | ||
| 1379 | eventId: EventId.make(`answer-${id}`), | ||
| 1380 | aggregateKind: "thread", | ||
| 1381 | aggregateId: threadId, | ||
| 1382 | occurredAt: now, | ||
| 1383 | commandId: CommandId.make(`answer-${id}`), | ||
| 1384 | causationEventId: null, | ||
| 1385 | correlationId: CorrelationId.make(`answer-${id}`), | ||
| 1386 | metadata: {}, | ||
| 1387 | payload: { | ||
| 1388 | threadId, | ||
| 1389 | activity: { | ||
| 1390 | id: EventId.make(`answer-${id}`), | ||
| 1391 | kind: "user-input.answer-submitted", | ||
| 1392 | tone: "info", | ||
| 1393 | summary: "Answer with file", | ||
| 1394 | createdAt: now, | ||
| 1395 | turnId: TurnId.make(turnId), | ||
| 1396 | payload: { | ||
| 1397 | requestId: ApprovalRequestId.make(id), | ||
| 1398 | answers: { q: "See file" }, | ||
| 1399 | attachmentsByQuestionId: { | ||
| 1400 | q: [ | ||
| 1401 | { type: "file", id, name: "answer.txt", mimeType: "text/plain", sizeBytes: 6 }, | ||
| 1402 | ], | ||
| 1403 | }, | ||
| 1404 | }, | ||
| 1405 | }, | ||
| 1406 | }, | ||
| 1407 | }); | ||
| 1408 | } | ||
| 1367 | const keepPath = path.join(attachmentsDir, `${keepAttachmentId}.png`); | 1409 | const keepPath = path.join(attachmentsDir, `${keepAttachmentId}.png`); |
| 1368 | const keepFilePath = path.join(attachmentsDir, `${keepFileAttachmentId}.pdf`); | 1410 | const keepFilePath = path.join(attachmentsDir, `${keepFileAttachmentId}.pdf`); |
| 1369 | const removePath = path.join(attachmentsDir, `${removeAttachmentId}.png`); | 1411 | const removePath = path.join(attachmentsDir, `${removeAttachmentId}.png`); |
| 1370 | yield* fileSystem.makeDirectory(attachmentsDir, { recursive: true }); | 1412 | yield* fileSystem.makeDirectory(attachmentsDir, { recursive: true }); |
| 1413 | yield* fileSystem.writeFileString(path.join(attachmentsDir, `${answerKeepId}.txt`), "answer"); | ||
| 1414 | yield* fileSystem.writeFileString( | ||
| 1415 | path.join(attachmentsDir, `${answerRemoveId}.txt`), | ||
| 1416 | "answer", | ||
| 1417 | ); | ||
| 1371 | yield* fileSystem.writeFileString(keepPath, "keep"); | 1418 | yield* fileSystem.writeFileString(keepPath, "keep"); |
| 1372 | yield* fileSystem.writeFileString(keepFilePath, "keep"); | 1419 | yield* fileSystem.writeFileString(keepFilePath, "keep"); |
| 1373 | yield* fileSystem.writeFileString(removePath, "remove"); | 1420 | yield* fileSystem.writeFileString(removePath, "remove"); |
| ... | @@ -1460,9 +1507,33 @@ it.layer( | ... | @@ -1460,9 +1507,33 @@ it.layer( |
| 1460 | 1507 | ||
| 1461 | assert.isTrue(yield* exists(keepPath)); | 1508 | assert.isTrue(yield* exists(keepPath)); |
| 1462 | assert.isTrue(yield* exists(keepFilePath)); | 1509 | assert.isTrue(yield* exists(keepFilePath)); |
| 1510 | assert.isTrue(yield* exists(path.join(attachmentsDir, `${answerKeepId}.txt`))); | ||
| 1511 | assert.isFalse(yield* exists(path.join(attachmentsDir, `${answerRemoveId}.txt`))); | ||
| 1463 | assert.isFalse(yield* exists(removePath)); | 1512 | assert.isFalse(yield* exists(removePath)); |
| 1464 | assert.isTrue(yield* exists(laterPath)); | 1513 | assert.isTrue(yield* exists(laterPath)); |
| 1465 | assert.isTrue(yield* exists(otherThreadPath)); | 1514 | assert.isTrue(yield* exists(otherThreadPath)); |
| 1515 | |||
| 1516 | // Replay message and activity history from different cursors, as during a projection rebuild. | ||
| 1517 | yield* sql`DELETE FROM projection_thread_messages WHERE thread_id = ${threadId}`; | ||
| 1518 | yield* sql`DELETE FROM projection_thread_activities WHERE thread_id = ${threadId}`; | ||
| 1519 | yield* sql`UPDATE projection_state SET last_applied_sequence = 0 | ||
| 1520 | WHERE projector IN ('projection.thread-messages', 'projection.thread-activities', 'projection.threads')`; | ||
| 1521 | yield* fileSystem.writeFileString(removePath, "remove"); | ||
| 1522 | yield* fileSystem.writeFileString( | ||
| 1523 | path.join(attachmentsDir, `${answerRemoveId}.txt`), | ||
| 1524 | "answer", | ||
| 1525 | ); | ||
| 1526 | yield* sql`CREATE TRIGGER fail_bootstrap_thread BEFORE UPDATE ON projection_threads | ||
| 1527 | BEGIN SELECT RAISE(FAIL, 'forced bootstrap failure'); END`; | ||
| 1528 | yield* projectionPipeline.bootstrap.pipe(Effect.flip); | ||
| 1529 | assert.isTrue(yield* exists(removePath)); | ||
| 1530 | yield* sql`DROP TRIGGER fail_bootstrap_thread`; | ||
| 1531 | yield* projectionPipeline.bootstrap; | ||
| 1532 | assert.isTrue(yield* exists(keepPath)); | ||
| 1533 | assert.isTrue(yield* exists(laterPath)); | ||
| 1534 | assert.isTrue(yield* exists(path.join(attachmentsDir, `${answerKeepId}.txt`))); | ||
| 1535 | assert.isFalse(yield* exists(removePath)); | ||
| 1536 | assert.isFalse(yield* exists(path.join(attachmentsDir, `${answerRemoveId}.txt`))); | ||
| 1466 | }), | 1537 | }), |
| 1467 | ); | 1538 | ); |
| 1468 | }); | 1539 | }); |
| ... | @@ -1934,6 +2005,15 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { | ... | @@ -1934,6 +2005,15 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { |
| 1934 | }, | 2005 | }, |
| 1935 | }); | 2006 | }); |
| 1936 | 2007 | ||
| 2008 | yield* sql`CREATE TRIGGER fail_later_stream_projector BEFORE UPDATE ON projection_state | ||
| 2009 | WHEN NEW.projector = 'projection.threads' | ||
| 2010 | BEGIN SELECT RAISE(FAIL, 'forced later projector failure'); END`; | ||
| 2011 | yield* projectionPipeline.bootstrap.pipe(Effect.flip); | ||
| 2012 | const committedMessage = yield* sql<{ readonly text: string }>` | ||
| 2013 | SELECT text FROM projection_thread_messages WHERE message_id = 'message-a' | ||
| 2014 | `; | ||
| 2015 | assert.deepEqual(committedMessage, [{ text: "hello world" }]); | ||
| 2016 | yield* sql`DROP TRIGGER fail_later_stream_projector`; | ||
| 1937 | yield* projectionPipeline.bootstrap; | 2017 | yield* projectionPipeline.bootstrap; |
| 1938 | yield* projectionPipeline.bootstrap; | 2018 | yield* projectionPipeline.bootstrap; |
| 1939 | 2019 | ||
| ... | @@ -3884,7 +3964,7 @@ const engineLayer = it.layer( | ... | @@ -3884,7 +3964,7 @@ const engineLayer = it.layer( |
| 3884 | Layer.provideMerge(OrchestrationProjectionSnapshotQueryLive), | 3964 | Layer.provideMerge(OrchestrationProjectionSnapshotQueryLive), |
| 3885 | Layer.provide(ThreadBackgroundLiveness.layer), | 3965 | Layer.provide(ThreadBackgroundLiveness.layer), |
| 3886 | Layer.provide(ThreadPlanProgress.layer), | 3966 | Layer.provide(ThreadPlanProgress.layer), |
| 3887 | Layer.provide(OrchestrationProjectionPipelineLive), | 3967 | Layer.provideMerge(OrchestrationProjectionPipelineLive), |
| 3888 | Layer.provide(OrchestrationEventStoreLive), | 3968 | Layer.provide(OrchestrationEventStoreLive), |
| 3889 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), | 3969 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| 3890 | Layer.provide(RepositoryIdentityResolver.layer), | 3970 | Layer.provide(RepositoryIdentityResolver.layer), |
| ... | @@ -4236,7 +4316,10 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { | ... | @@ -4236,7 +4316,10 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { |
| 4236 | yield* readCursors, | 4316 | yield* readCursors, |
| 4237 | cursorsBeforeFailure.map((cursor) => ({ | 4317 | cursorsBeforeFailure.map((cursor) => ({ |
| 4238 | ...cursor, | 4318 | ...cursor, |
| 4239 | lastAppliedSequence: result.sequence, | 4319 | lastAppliedSequence: |
| 4320 | cursor.projector === "projection.attachment-cleanup" | ||
| 4321 | ? cursor.lastAppliedSequence | ||
| 4322 | : result.sequence, | ||
| 4240 | })), | 4323 | })), |
| 4241 | ); | 4324 | ); |
| 4242 | assert.isFalse(yield* exists(attachmentPath)); | 4325 | assert.isFalse(yield* exists(attachmentPath)); |
| ... | @@ -4266,6 +4349,25 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { | ... | @@ -4266,6 +4349,25 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { |
| 4266 | WHERE command_id = ${cleanupFailureCommandId} | 4349 | WHERE command_id = ${cleanupFailureCommandId} |
| 4267 | `; | 4350 | `; |
| 4268 | assert.deepEqual(cleanupFailureReceipts, [{ status: "accepted" }]); | 4351 | assert.deepEqual(cleanupFailureReceipts, [{ status: "accepted" }]); |
| 4352 | |||
| 4353 | const pipeline = yield* OrchestrationProjectionPipeline; | ||
| 4354 | const cleanupCursor = sql<{ readonly lastAppliedSequence: number }>` | ||
| 4355 | SELECT last_applied_sequence AS "lastAppliedSequence" FROM projection_state | ||
| 4356 | WHERE projector = 'projection.attachment-cleanup' | ||
| 4357 | `; | ||
| 4358 | const cursorBeforeRetry = yield* cleanupCursor; | ||
| 4359 | yield* pipeline.bootstrap; | ||
| 4360 | assert.deepEqual(yield* cleanupCursor, cursorBeforeRetry); | ||
| 4361 | assert.isTrue(yield* exists(blockedAttachmentPath)); | ||
| 4362 | |||
| 4363 | yield* fileSystem.remove(blockedAttachmentPath, { recursive: true }); | ||
| 4364 | yield* fileSystem.writeFileString(blockedAttachmentPath, "retry this attachment"); | ||
| 4365 | yield* pipeline.bootstrap; | ||
| 4366 | assert.isFalse(yield* exists(blockedAttachmentPath)); | ||
| 4367 | assert.isAbove( | ||
| 4368 | (yield* cleanupCursor)[0]!.lastAppliedSequence, | ||
| 4369 | cursorBeforeRetry[0]!.lastAppliedSequence, | ||
| 4370 | ); | ||
| 4269 | }), | 4371 | }), |
| 4270 | ); | 4372 | ); |
| 4271 | }); | 4373 | }); |
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+89-31| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import { | 1 | import { |
| 2 | ApprovalRequestId, | 2 | ApprovalRequestId, |
| 3 | isImportedAgentSessionMessageId, | 3 | isImportedAgentSessionMessageId, |
| 4 | UserInputAttachmentAnswerPayload, | ||
| 4 | type ChatAttachment, | 5 | type ChatAttachment, |
| 5 | type OrchestrationEvent, | 6 | type OrchestrationEvent, |
| 6 | type OrchestrationSessionStatus, | 7 | type OrchestrationSessionStatus, |
| ... | @@ -12,6 +13,7 @@ import * as FileSystem from "effect/FileSystem"; | ... | @@ -12,6 +13,7 @@ import * as FileSystem from "effect/FileSystem"; |
| 12 | import * as Layer from "effect/Layer"; | 13 | import * as Layer from "effect/Layer"; |
| 13 | import * as Option from "effect/Option"; | 14 | import * as Option from "effect/Option"; |
| 14 | import * as Path from "effect/Path"; | 15 | import * as Path from "effect/Path"; |
| 16 | import * as Schema from "effect/Schema"; | ||
| 15 | import * as Stream from "effect/Stream"; | 17 | import * as Stream from "effect/Stream"; |
| 16 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | 18 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 17 | 19 | ||
| ... | @@ -327,6 +329,8 @@ function retainProjectionProposedPlansAfterRevert( | ... | @@ -327,6 +329,8 @@ function retainProjectionProposedPlansAfterRevert( |
| 327 | ); | 329 | ); |
| 328 | } | 330 | } |
| 329 | 331 | ||
| 332 | const decodeQuestionAttachmentAnswer = Schema.decodeUnknownOption(UserInputAttachmentAnswerPayload); | ||
| 333 | |||
| 330 | function collectThreadAttachmentRelativePaths( | 334 | function collectThreadAttachmentRelativePaths( |
| 331 | threadId: string, | 335 | threadId: string, |
| 332 | messages: ReadonlyArray<ProjectionThreadMessage>, | 336 | messages: ReadonlyArray<ProjectionThreadMessage>, |
| ... | @@ -359,9 +363,9 @@ const runAttachmentSideEffects = Effect.fn("runAttachmentSideEffects")(function* | ... | @@ -359,9 +363,9 @@ const runAttachmentSideEffects = Effect.fn("runAttachmentSideEffects")(function* |
| 359 | const path = yield* Effect.service(Path.Path); | 363 | const path = yield* Effect.service(Path.Path); |
| 360 | 364 | ||
| 361 | const attachmentsRootDir = serverConfig.attachmentsDir; | 365 | const attachmentsRootDir = serverConfig.attachmentsDir; |
| 362 | const readAttachmentRootEntries = fileSystem | 366 | const readAttachmentRootEntries = fileSystem.readDirectory(attachmentsRootDir, { |
| 363 | .readDirectory(attachmentsRootDir, { recursive: false }) | 367 | recursive: false, |
| 364 | .pipe(Effect.orElseSucceed(() => [] as Array<string>)); | 368 | }); |
| 365 | 369 | ||
| 366 | const removeDeletedThreadAttachmentEntry = Effect.fn("removeDeletedThreadAttachmentEntry")( | 370 | const removeDeletedThreadAttachmentEntry = Effect.fn("removeDeletedThreadAttachmentEntry")( |
| 367 | function* (threadSegment: string, entry: string) { | 371 | function* (threadSegment: string, entry: string) { |
| ... | @@ -423,7 +427,7 @@ const runAttachmentSideEffects = Effect.fn("runAttachmentSideEffects")(function* | ... | @@ -423,7 +427,7 @@ const runAttachmentSideEffects = Effect.fn("runAttachmentSideEffects")(function* |
| 423 | } | 427 | } |
| 424 | 428 | ||
| 425 | const absolutePath = path.join(attachmentsRootDir, relativePath); | 429 | const absolutePath = path.join(attachmentsRootDir, relativePath); |
| 426 | const fileInfo = yield* fileSystem.stat(absolutePath).pipe(Effect.orElseSucceed(() => null)); | 430 | const fileInfo = yield* fileSystem.stat(absolutePath); |
| 427 | if (!fileInfo || fileInfo.type !== "File") { | 431 | if (!fileInfo || fileInfo.type !== "File") { |
| 428 | return; | 432 | return; |
| 429 | } | 433 | } |
| ... | @@ -1156,7 +1160,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti | ... | @@ -1156,7 +1160,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 1156 | 1160 | ||
| 1157 | const applyThreadActivitiesProjection: ProjectorDefinition["apply"] = Effect.fn( | 1161 | const applyThreadActivitiesProjection: ProjectorDefinition["apply"] = Effect.fn( |
| 1158 | "applyThreadActivitiesProjection", | 1162 | "applyThreadActivitiesProjection", |
| 1159 | )(function* (event, _attachmentSideEffects) { | 1163 | )(function* (event, attachmentSideEffects) { |
| 1160 | switch (event.type) { | 1164 | switch (event.type) { |
| 1161 | case "thread.created": | 1165 | case "thread.created": |
| 1162 | yield* projectionThreadActivityRepository.deleteByThreadId({ | 1166 | yield* projectionThreadActivityRepository.deleteByThreadId({ |
| ... | @@ -1204,6 +1208,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti | ... | @@ -1204,6 +1208,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 1204 | yield* Effect.forEach(keptRows, projectionThreadActivityRepository.upsert, { | 1208 | yield* Effect.forEach(keptRows, projectionThreadActivityRepository.upsert, { |
| 1205 | concurrency: 1, | 1209 | concurrency: 1, |
| 1206 | }).pipe(Effect.asVoid); | 1210 | }).pipe(Effect.asVoid); |
| 1211 | attachmentSideEffects.prunedThreadRelativePaths.set(event.payload.threadId, new Set()); | ||
| 1207 | return; | 1212 | return; |
| 1208 | } | 1213 | } |
| 1209 | 1214 | ||
| ... | @@ -1866,10 +1871,20 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti | ... | @@ -1866,10 +1871,20 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 1866 | const messages = yield* projectionThreadMessageRepository.listByThreadId({ | 1871 | const messages = yield* projectionThreadMessageRepository.listByThreadId({ |
| 1867 | threadId: ThreadId.make(threadId), | 1872 | threadId: ThreadId.make(threadId), |
| 1868 | }); | 1873 | }); |
| 1869 | prunedThreadRelativePaths.set( | 1874 | const retainedPaths = collectThreadAttachmentRelativePaths(threadId, messages); |
| 1870 | threadId, | 1875 | const activities = yield* projectionThreadActivityRepository.listByThreadId({ |
| 1871 | collectThreadAttachmentRelativePaths(threadId, messages), | 1876 | threadId: ThreadId.make(threadId), |
| 1872 | ); | 1877 | }); |
| 1878 | for (const activity of activities) { | ||
| 1879 | if (activity.kind !== "user-input.answer-submitted") continue; | ||
| 1880 | const payload = decodeQuestionAttachmentAnswer(activity.payload); | ||
| 1881 | if (Option.isNone(payload)) continue; | ||
| 1882 | for (const attachment of Object.values(payload.value.attachmentsByQuestionId).flat()) { | ||
| 1883 | const relativePath = attachmentRelativePath(attachment); | ||
| 1884 | if (relativePath) retainedPaths.add(relativePath); | ||
| 1885 | } | ||
| 1886 | } | ||
| 1887 | prunedThreadRelativePaths.set(threadId, retainedPaths); | ||
| 1873 | } | 1888 | } |
| 1874 | 1889 | ||
| 1875 | yield* runAttachmentSideEffects({ deletedThreadIds, prunedThreadRelativePaths }); | 1890 | yield* runAttachmentSideEffects({ deletedThreadIds, prunedThreadRelativePaths }); |
| ... | @@ -1879,29 +1894,17 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti | ... | @@ -1879,29 +1894,17 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 1879 | Effect.provideService(ServerConfig, serverConfig), | 1894 | Effect.provideService(ServerConfig, serverConfig), |
| 1880 | (effect, event) => | 1895 | (effect, event) => |
| 1881 | effect.pipe( | 1896 | effect.pipe( |
| 1897 | Effect.as(true), | ||
| 1882 | Effect.catch((cause) => | 1898 | Effect.catch((cause) => |
| 1883 | Effect.logWarning("failed to apply projected attachment side-effects", { | 1899 | Effect.logWarning("failed to apply projected attachment side-effects", { |
| 1884 | sequence: event.sequence, | 1900 | sequence: event.sequence, |
| 1885 | eventType: event.type, | 1901 | eventType: event.type, |
| 1886 | cause, | 1902 | cause, |
| 1887 | }), | 1903 | }).pipe(Effect.as(false)), |
| 1888 | ), | 1904 | ), |
| 1889 | ), | 1905 | ), |
| 1890 | ); | 1906 | ); |
| 1891 | 1907 | ||
| 1892 | const applyProjectorForEvent = Effect.fn("applyProjectorForEvent")(function* ( | ||
| 1893 | projector: ProjectorDefinition, | ||
| 1894 | event: OrchestrationEvent, | ||
| 1895 | attachmentSideEffects: AttachmentSideEffects, | ||
| 1896 | ) { | ||
| 1897 | yield* projector.apply(event, attachmentSideEffects); | ||
| 1898 | yield* projectionStateRepository.upsert({ | ||
| 1899 | projector: projector.name, | ||
| 1900 | lastAppliedSequence: event.sequence, | ||
| 1901 | updatedAt: event.occurredAt, | ||
| 1902 | }); | ||
| 1903 | }); | ||
| 1904 | |||
| 1905 | const runProjectorForEvent = Effect.fn("runProjectorForEvent")(function* ( | 1908 | const runProjectorForEvent = Effect.fn("runProjectorForEvent")(function* ( |
| 1906 | projector: ProjectorDefinition, | 1909 | projector: ProjectorDefinition, |
| 1907 | event: OrchestrationEvent, | 1910 | event: OrchestrationEvent, |
| ... | @@ -1911,8 +1914,16 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti | ... | @@ -1911,8 +1914,16 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 1911 | prunedThreadRelativePaths: new Map<string, Set<string>>(), | 1914 | prunedThreadRelativePaths: new Map<string, Set<string>>(), |
| 1912 | }; | 1915 | }; |
| 1913 | 1916 | ||
| 1914 | yield* sql.withTransaction(applyProjectorForEvent(projector, event, attachmentSideEffects)); | 1917 | yield* sql.withTransaction( |
| 1915 | yield* applyAttachmentSideEffects(event, attachmentSideEffects); | 1918 | Effect.gen(function* () { |
| 1919 | yield* projector.apply(event, attachmentSideEffects); | ||
| 1920 | yield* projectionStateRepository.upsert({ | ||
| 1921 | projector: projector.name, | ||
| 1922 | lastAppliedSequence: event.sequence, | ||
| 1923 | updatedAt: event.occurredAt, | ||
| 1924 | }); | ||
| 1925 | }), | ||
| 1926 | ); | ||
| 1916 | }); | 1927 | }); |
| 1917 | 1928 | ||
| 1918 | const bootstrapProjector = (projector: ProjectorDefinition) => | 1929 | const bootstrapProjector = (projector: ProjectorDefinition) => |
| ... | @@ -1958,7 +1969,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti | ... | @@ -1958,7 +1969,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 1958 | ); | 1969 | ); |
| 1959 | // Return the cleanup effect so the caller runs it after the outer transaction commits. | 1970 | // Return the cleanup effect so the caller runs it after the outer transaction commits. |
| 1960 | // @effect-diagnostics-next-line returnEffectInGen:off | 1971 | // @effect-diagnostics-next-line returnEffectInGen:off |
| 1961 | return applyAttachmentSideEffects(event, attachmentSideEffects); | 1972 | return applyAttachmentSideEffects(event, attachmentSideEffects).pipe(Effect.asVoid); |
| 1962 | }, | 1973 | }, |
| 1963 | Effect.provideService(FileSystem.FileSystem, fileSystem), | 1974 | Effect.provideService(FileSystem.FileSystem, fileSystem), |
| 1964 | Effect.provideService(Path.Path, path), | 1975 | Effect.provideService(Path.Path, path), |
| ... | @@ -1975,11 +1986,58 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti | ... | @@ -1975,11 +1986,58 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 1975 | yield* cleanup; | 1986 | yield* cleanup; |
| 1976 | }); | 1987 | }); |
| 1977 | 1988 | ||
| 1978 | const bootstrap: OrchestrationProjectionPipelineShape["bootstrap"] = Effect.forEach( | 1989 | const bootstrap: OrchestrationProjectionPipelineShape["bootstrap"] = Effect.gen(function* () { |
| 1979 | projectors, | 1990 | const cleanupProjector = "projection.attachment-cleanup"; |
| 1980 | bootstrapProjector, | 1991 | const states = yield* projectionStateRepository.listAll(); |
| 1981 | { concurrency: 1 }, | 1992 | const byProjector = new Map(states.map((state) => [state.projector, state])); |
| 1982 | ).pipe( | 1993 | const cleanupState = byProjector.get(cleanupProjector); |
| 1994 | const cleanupStart = Math.min( | ||
| 1995 | cleanupState?.lastAppliedSequence ?? 0, | ||
| 1996 | ...projectors.map((projector) => byProjector.get(projector.name)?.lastAppliedSequence ?? 0), | ||
| 1997 | ); | ||
| 1998 | // Persist this boundary before replay: a reset projector can encounter an old | ||
| 1999 | // revert, then fail after other projectors have committed past that event. | ||
| 2000 | yield* projectionStateRepository.upsert({ | ||
| 2001 | projector: cleanupProjector, | ||
| 2002 | lastAppliedSequence: cleanupStart, | ||
| 2003 | updatedAt: cleanupState?.updatedAt ?? "1970-01-01T00:00:00.000Z", | ||
| 2004 | }); | ||
| 2005 | yield* Effect.forEach(projectors, bootstrapProjector, { concurrency: 1, discard: true }); | ||
| 2006 | |||
| 2007 | // Cleanup has its own cursor so retries never have to replay committed text. | ||
| 2008 | // All message and activity references are current before any files are removed. | ||
| 2009 | const pendingCleanup = new Map<string, OrchestrationEvent>(); | ||
| 2010 | let lastEvent: OrchestrationEvent | undefined; | ||
| 2011 | yield* Stream.runForEach( | ||
| 2012 | eventStore.readFromSequence(cleanupStart, Number.MAX_SAFE_INTEGER), | ||
| 2013 | (event) => | ||
| 2014 | Effect.sync(() => { | ||
| 2015 | lastEvent = event; | ||
| 2016 | if (event.type === "thread.reverted" || event.type === "thread.deleted") { | ||
| 2017 | pendingCleanup.set(`${event.type}:${event.payload.threadId}`, event); | ||
| 2018 | } | ||
| 2019 | }), | ||
| 2020 | ); | ||
| 2021 | for (const event of pendingCleanup.values()) { | ||
| 2022 | if (event.type !== "thread.reverted" && event.type !== "thread.deleted") continue; | ||
| 2023 | const threadId = event.payload.threadId; | ||
| 2024 | const cleaned = yield* applyAttachmentSideEffects(event, { | ||
| 2025 | deletedThreadIds: new Set(event.type === "thread.deleted" ? [threadId] : []), | ||
| 2026 | prunedThreadRelativePaths: new Map( | ||
| 2027 | event.type === "thread.reverted" ? [[threadId, new Set<string>()]] : [], | ||
| 2028 | ), | ||
| 2029 | }); | ||
| 2030 | // Leave the cleanup cursor behind this event so the next bootstrap retries it. | ||
| 2031 | if (!cleaned) return; | ||
| 2032 | } | ||
| 2033 | if (lastEvent) { | ||
| 2034 | yield* projectionStateRepository.upsert({ | ||
| 2035 | projector: cleanupProjector, | ||
| 2036 | lastAppliedSequence: lastEvent.sequence, | ||
| 2037 | updatedAt: lastEvent.occurredAt, | ||
| 2038 | }); | ||
| 2039 | } | ||
| 2040 | }).pipe( | ||
| 1983 | Effect.provideService(FileSystem.FileSystem, fileSystem), | 2041 | Effect.provideService(FileSystem.FileSystem, fileSystem), |
| 1984 | Effect.provideService(Path.Path, path), | 2042 | Effect.provideService(Path.Path, path), |
| 1985 | Effect.provideService(ServerConfig, serverConfig), | 2043 | Effect.provideService(ServerConfig, serverConfig), |
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+3| ... | @@ -1609,6 +1609,9 @@ const make = Effect.gen(function* () { | ... | @@ -1609,6 +1609,9 @@ const make = Effect.gen(function* () { |
| 1609 | threadId: event.payload.threadId, | 1609 | threadId: event.payload.threadId, |
| 1610 | requestId: event.payload.requestId, | 1610 | requestId: event.payload.requestId, |
| 1611 | answers: event.payload.answers, | 1611 | answers: event.payload.answers, |
| 1612 | ...(event.payload.attachmentsByQuestionId | ||
| 1613 | ? { attachmentsByQuestionId: event.payload.attachmentsByQuestionId } | ||
| 1614 | : {}), | ||
| 1612 | }) | 1615 | }) |
| 1613 | .pipe( | 1616 | .pipe( |
| 1614 | Effect.catchCause((cause) => | 1617 | Effect.catchCause((cause) => |
apps/server/src/orchestration/Normalizer.attachments.test.ts+202-1| ... | @@ -5,13 +5,15 @@ import * as NodePath from "node:path"; | ... | @@ -5,13 +5,15 @@ import * as NodePath from "node:path"; |
| 5 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 5 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 6 | import { describe, expect, it } from "@effect/vitest"; | 6 | import { describe, expect, it } from "@effect/vitest"; |
| 7 | import { | 7 | import { |
| 8 | type ClientOrchestrationCommand, | 8 | ClientOrchestrationCommand, |
| 9 | CommandId, | 9 | CommandId, |
| 10 | ApprovalRequestId, | ||
| 10 | MessageId, | 11 | MessageId, |
| 11 | ThreadId, | 12 | ThreadId, |
| 12 | } from "@t3tools/contracts"; | 13 | } from "@t3tools/contracts"; |
| 13 | import * as Effect from "effect/Effect"; | 14 | import * as Effect from "effect/Effect"; |
| 14 | import * as Layer from "effect/Layer"; | 15 | import * as Layer from "effect/Layer"; |
| 16 | import * as Schema from "effect/Schema"; | ||
| 15 | 17 | ||
| 16 | import * as ServerConfig from "../config.ts"; | 18 | import * as ServerConfig from "../config.ts"; |
| 17 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; | 19 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; |
| ... | @@ -23,6 +25,7 @@ const testLayer = Layer.mergeAll( | ... | @@ -23,6 +25,7 @@ const testLayer = Layer.mergeAll( |
| 23 | ).pipe(Layer.provideMerge(NodeServices.layer)); | 25 | ).pipe(Layer.provideMerge(NodeServices.layer)); |
| 24 | 26 | ||
| 25 | const attachmentUuid = "00000000-0000-4000-8000-0000000000aa"; | 27 | const attachmentUuid = "00000000-0000-4000-8000-0000000000aa"; |
| 28 | const isClientCommand = Schema.is(ClientOrchestrationCommand); | ||
| 26 | 29 | ||
| 27 | function turnStartCommand(input: { | 30 | function turnStartCommand(input: { |
| 28 | readonly threadId?: string; | 31 | readonly threadId?: string; |
| ... | @@ -358,3 +361,201 @@ describe("normalizeDispatchCommand attachments", () => { | ... | @@ -358,3 +361,201 @@ describe("normalizeDispatchCommand attachments", () => { |
| 358 | }).pipe(Effect.provide(testLayer)), | 361 | }).pipe(Effect.provide(testLayer)), |
| 359 | ); | 362 | ); |
| 360 | }); | 363 | }); |
| 364 | |||
| 365 | describe("question attachments", () => { | ||
| 366 | it.effect("enforces the total response limit and claims duplicate filenames independently", () => | ||
| 367 | Effect.gen(function* () { | ||
| 368 | const config = yield* ServerConfig.ServerConfig; | ||
| 369 | const id = `pending-${attachmentUuid}-txt`; | ||
| 370 | NodeFS.writeFileSync(NodePath.join(config.attachmentsDir, `${id}.txt`), "report"); | ||
| 371 | const attachment = { | ||
| 372 | type: "file" as const, | ||
| 373 | id, | ||
| 374 | name: 'notes "final" ü.txt', | ||
| 375 | mimeType: "text/plain", | ||
| 376 | sizeBytes: 6, | ||
| 377 | }; | ||
| 378 | const command: ClientOrchestrationCommand = { | ||
| 379 | type: "thread.user-input.respond", | ||
| 380 | commandId: CommandId.make("answer-cap"), | ||
| 381 | threadId: ThreadId.make("thread-1"), | ||
| 382 | requestId: ApprovalRequestId.make("request-cap"), | ||
| 383 | answers: { first: "", second: "" }, | ||
| 384 | createdAt: "2026-08-01T00:00:00.000Z", | ||
| 385 | attachmentsByQuestionId: { | ||
| 386 | first: Array.from({ length: 4 }, () => attachment), | ||
| 387 | second: Array.from({ length: 5 }, () => attachment), | ||
| 388 | }, | ||
| 389 | }; | ||
| 390 | const failure = yield* normalizeDispatchCommand(command).pipe(Effect.flip); | ||
| 391 | expect(failure.message).toContain("up to 8"); | ||
| 392 | expect(NodeFS.readdirSync(config.attachmentsDir)).toEqual([`${id}.txt`]); | ||
| 393 | const accepted = { | ||
| 394 | ...command, | ||
| 395 | attachmentsByQuestionId: { | ||
| 396 | ...command.attachmentsByQuestionId, | ||
| 397 | second: Array.from({ length: 4 }, () => attachment), | ||
| 398 | }, | ||
| 399 | }; | ||
| 400 | const normalized = yield* normalizeDispatchCommand(accepted); | ||
| 401 | if (normalized.type !== "thread.user-input.respond") throw new Error("Wrong command"); | ||
| 402 | const attachments = Object.values(normalized.attachmentsByQuestionId!).flat(); | ||
| 403 | expect(attachments).toHaveLength(8); | ||
| 404 | expect(new Set(attachments.map((item) => item.id)).size).toBe(8); | ||
| 405 | for (const item of attachments) { | ||
| 406 | expect(item.name).toBe(attachment.name); | ||
| 407 | expect( | ||
| 408 | NodeFS.readFileSync(NodePath.join(config.attachmentsDir, `${item.id}.txt`), "utf8"), | ||
| 409 | ).toBe("report"); | ||
| 410 | } | ||
| 411 | yield* cleanupFailedUploadedAttachments(accepted, normalized); | ||
| 412 | expect(NodeFS.readdirSync(config.attachmentsDir)).toEqual([`${id}.txt`]); | ||
| 413 | }).pipe(Effect.provide(testLayer)), | ||
| 414 | ); | ||
| 415 | it("requires uploaded metadata for question images, including pasted images", () => { | ||
| 416 | expect( | ||
| 417 | isClientCommand({ | ||
| 418 | type: "thread.user-input.respond", | ||
| 419 | commandId: "answer", | ||
| 420 | threadId: "thread-1", | ||
| 421 | requestId: "request", | ||
| 422 | answers: { q: "" }, | ||
| 423 | createdAt: "2026-08-01T00:00:00.000Z", | ||
| 424 | attachmentsByQuestionId: { | ||
| 425 | q: [ | ||
| 426 | { | ||
| 427 | type: "image", | ||
| 428 | name: "image.png", | ||
| 429 | mimeType: "image/png", | ||
| 430 | sizeBytes: 6, | ||
| 431 | dataUrl: "data:image/png;base64,cGl4ZWxz", | ||
| 432 | }, | ||
| 433 | ], | ||
| 434 | }, | ||
| 435 | }), | ||
| 436 | ).toBe(false); | ||
| 437 | }); | ||
| 438 | |||
| 439 | it.effect("preserves a __proto__ question key and cleans up its claimed files", () => | ||
| 440 | Effect.gen(function* () { | ||
| 441 | const config = yield* ServerConfig.ServerConfig; | ||
| 442 | const id = `pending-${attachmentUuid}`; | ||
| 443 | NodeFS.writeFileSync(NodePath.join(config.attachmentsDir, `${id}.png`), "pixels"); | ||
| 444 | const command: ClientOrchestrationCommand = { | ||
| 445 | type: "thread.user-input.respond", | ||
| 446 | commandId: CommandId.make("answer"), | ||
| 447 | threadId: ThreadId.make("thread-1"), | ||
| 448 | requestId: ApprovalRequestId.make("request"), | ||
| 449 | answers: { ["__proto__"]: "" }, | ||
| 450 | createdAt: "2026-08-01T00:00:00.000Z", | ||
| 451 | attachmentsByQuestionId: { | ||
| 452 | ["__proto__"]: [ | ||
| 453 | { type: "image", id, name: "image.png", mimeType: "image/png", sizeBytes: 6 }, | ||
| 454 | ], | ||
| 455 | }, | ||
| 456 | }; | ||
| 457 | const normalized = yield* normalizeDispatchCommand(command); | ||
| 458 | if (normalized.type !== "thread.user-input.respond") throw new Error("Wrong command"); | ||
| 459 | expect(Object.keys(normalized.attachmentsByQuestionId!)).toEqual(["__proto__"]); | ||
| 460 | const attachment = normalized.attachmentsByQuestionId!["__proto__"]![0]!; | ||
| 461 | const claimedPath = NodePath.join(config.attachmentsDir, `${attachment.id}.png`); | ||
| 462 | expect(NodeFS.existsSync(claimedPath)).toBe(true); | ||
| 463 | yield* cleanupFailedUploadedAttachments(command, normalized); | ||
| 464 | expect(NodeFS.existsSync(claimedPath)).toBe(false); | ||
| 465 | }).pipe(Effect.provide(testLayer)), | ||
| 466 | ); | ||
| 467 | it.effect( | ||
| 468 | "claims images and files by question, preserves answers, and cleans up failed dispatches", | ||
| 469 | () => | ||
| 470 | Effect.gen(function* () { | ||
| 471 | const config = yield* ServerConfig.ServerConfig; | ||
| 472 | const imageId = `pending-${attachmentUuid}`; | ||
| 473 | const fileId = `pending-${attachmentUuid}-txt`; | ||
| 474 | NodeFS.writeFileSync(NodePath.join(config.attachmentsDir, `${imageId}.png`), "pixels"); | ||
| 475 | NodeFS.writeFileSync(NodePath.join(config.attachmentsDir, `${fileId}.txt`), "report"); | ||
| 476 | const command: ClientOrchestrationCommand = { | ||
| 477 | type: "thread.user-input.respond", | ||
| 478 | commandId: CommandId.make("answer"), | ||
| 479 | threadId: ThreadId.make("thread-1"), | ||
| 480 | requestId: ApprovalRequestId.make("request"), | ||
| 481 | answers: { q1: ["Selected option"], q2: "" }, | ||
| 482 | createdAt: "2026-08-01T00:00:00.000Z", | ||
| 483 | attachmentsByQuestionId: { | ||
| 484 | q1: [ | ||
| 485 | { | ||
| 486 | type: "image", | ||
| 487 | id: imageId, | ||
| 488 | name: "image.png", | ||
| 489 | mimeType: "image/png", | ||
| 490 | sizeBytes: 6, | ||
| 491 | }, | ||
| 492 | ], | ||
| 493 | q2: [ | ||
| 494 | { | ||
| 495 | type: "file", | ||
| 496 | id: fileId, | ||
| 497 | name: "report.txt", | ||
| 498 | mimeType: "text/plain", | ||
| 499 | sizeBytes: 6, | ||
| 500 | }, | ||
| 501 | ], | ||
| 502 | }, | ||
| 503 | }; | ||
| 504 | const normalized = yield* normalizeDispatchCommand(command); | ||
| 505 | if (normalized.type !== "thread.user-input.respond") throw new Error("Wrong command"); | ||
| 506 | expect(normalized.answers).toEqual(command.answers); | ||
| 507 | const image = normalized.attachmentsByQuestionId!.q1![0]!; | ||
| 508 | const file = normalized.attachmentsByQuestionId!.q2![0]!; | ||
| 509 | expect( | ||
| 510 | NodeFS.readFileSync(NodePath.join(config.attachmentsDir, `${image.id}.png`), "utf8"), | ||
| 511 | ).toBe("pixels"); | ||
| 512 | expect( | ||
| 513 | NodeFS.readFileSync(NodePath.join(config.attachmentsDir, `${file.id}.txt`), "utf8"), | ||
| 514 | ).toBe("report"); | ||
| 515 | yield* cleanupFailedUploadedAttachments(command, normalized); | ||
| 516 | expect(NodeFS.existsSync(NodePath.join(config.attachmentsDir, `${image.id}.png`))).toBe( | ||
| 517 | false, | ||
| 518 | ); | ||
| 519 | expect(NodeFS.existsSync(NodePath.join(config.attachmentsDir, `${file.id}.txt`))).toBe( | ||
| 520 | false, | ||
| 521 | ); | ||
| 522 | expect(NodeFS.existsSync(NodePath.join(config.attachmentsDir, `${imageId}.png`))).toBe( | ||
| 523 | true, | ||
| 524 | ); | ||
| 525 | const retry = yield* normalizeDispatchCommand(command); | ||
| 526 | expect(retry.type).toBe("thread.user-input.respond"); | ||
| 527 | }).pipe(Effect.provide(testLayer)), | ||
| 528 | ); | ||
| 529 | |||
| 530 | it.effect("removes all claimed copies if a later question upload is missing", () => | ||
| 531 | Effect.gen(function* () { | ||
| 532 | const config = yield* ServerConfig.ServerConfig; | ||
| 533 | const id = `pending-${attachmentUuid}`; | ||
| 534 | NodeFS.writeFileSync(NodePath.join(config.attachmentsDir, `${id}.png`), "pixels"); | ||
| 535 | const result = yield* normalizeDispatchCommand({ | ||
| 536 | type: "thread.user-input.respond", | ||
| 537 | commandId: CommandId.make("answer"), | ||
| 538 | threadId: ThreadId.make("thread-1"), | ||
| 539 | requestId: ApprovalRequestId.make("request"), | ||
| 540 | answers: { q1: "", q2: "" }, | ||
| 541 | createdAt: "2026-08-01T00:00:00.000Z", | ||
| 542 | attachmentsByQuestionId: { | ||
| 543 | q1: [{ type: "image", id, name: "image.png", mimeType: "image/png", sizeBytes: 6 }], | ||
| 544 | q2: [ | ||
| 545 | { | ||
| 546 | type: "file", | ||
| 547 | id: `${id}-txt`, | ||
| 548 | name: "missing.txt", | ||
| 549 | mimeType: "text/plain", | ||
| 550 | sizeBytes: 6, | ||
| 551 | }, | ||
| 552 | ], | ||
| 553 | }, | ||
| 554 | }).pipe(Effect.result); | ||
| 555 | expect(result._tag).toBe("Failure"); | ||
| 556 | expect( | ||
| 557 | NodeFS.readdirSync(config.attachmentsDir).filter((name) => name.startsWith("thread-1-")), | ||
| 558 | ).toEqual([]); | ||
| 559 | }).pipe(Effect.provide(testLayer)), | ||
| 560 | ); | ||
| 561 | }); |
apps/server/src/orchestration/Normalizer.ts+53-7| ... | @@ -4,6 +4,8 @@ import * as FileSystem from "effect/FileSystem"; | ... | @@ -4,6 +4,8 @@ import * as FileSystem from "effect/FileSystem"; |
| 4 | import * as Path from "effect/Path"; | 4 | import * as Path from "effect/Path"; |
| 5 | import { | 5 | import { |
| 6 | type ClientOrchestrationCommand, | 6 | type ClientOrchestrationCommand, |
| 7 | type UserInputAttachments, | ||
| 8 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, | ||
| 7 | type IsoDateTime, | 9 | type IsoDateTime, |
| 8 | type OrchestrationCommand, | 10 | type OrchestrationCommand, |
| 9 | OrchestrationDispatchCommandError, | 11 | OrchestrationDispatchCommandError, |
| ... | @@ -129,13 +131,28 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => | ... | @@ -129,13 +131,28 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => |
| 129 | } satisfies OrchestrationCommand; | 131 | } satisfies OrchestrationCommand; |
| 130 | } | 132 | } |
| 131 | 133 | ||
| 132 | if (canonicalCommand.type !== "thread.turn.start") { | 134 | if ( |
| 135 | canonicalCommand.type !== "thread.turn.start" && | ||
| 136 | canonicalCommand.type !== "thread.user-input.respond" | ||
| 137 | ) { | ||
| 133 | return canonicalCommand as OrchestrationCommand; | 138 | return canonicalCommand as OrchestrationCommand; |
| 134 | } | 139 | } |
| 135 | 140 | ||
| 141 | const attachments = | ||
| 142 | canonicalCommand.type === "thread.turn.start" | ||
| 143 | ? canonicalCommand.message.attachments | ||
| 144 | : Object.values(canonicalCommand.attachmentsByQuestionId ?? {}).flat(); | ||
| 145 | if ( | ||
| 146 | canonicalCommand.type === "thread.user-input.respond" && | ||
| 147 | attachments.length > PROVIDER_SEND_TURN_MAX_ATTACHMENTS | ||
| 148 | ) { | ||
| 149 | return yield* new OrchestrationDispatchCommandError({ | ||
| 150 | message: `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per question response.`, | ||
| 151 | }); | ||
| 152 | } | ||
| 136 | const claimedAttachmentPaths: string[] = []; | 153 | const claimedAttachmentPaths: string[] = []; |
| 137 | const normalizedAttachments = yield* Effect.forEach( | 154 | const normalizedAttachments = yield* Effect.forEach( |
| 138 | canonicalCommand.message.attachments, | 155 | attachments, |
| 139 | (attachment) => | 156 | (attachment) => |
| 140 | Effect.gen(function* () { | 157 | Effect.gen(function* () { |
| 141 | if (!("dataUrl" in attachment)) { | 158 | if (!("dataUrl" in attachment)) { |
| ... | @@ -260,6 +277,25 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => | ... | @@ -260,6 +277,25 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => |
| 260 | { concurrency: 1 }, | 277 | { concurrency: 1 }, |
| 261 | ).pipe(Effect.tapError(() => removeClaimedAttachmentPaths(claimedAttachmentPaths))); | 278 | ).pipe(Effect.tapError(() => removeClaimedAttachmentPaths(claimedAttachmentPaths))); |
| 262 | 279 | ||
| 280 | if (canonicalCommand.type === "thread.user-input.respond") { | ||
| 281 | let index = 0; | ||
| 282 | const attachmentsByQuestionId = Object.fromEntries( | ||
| 283 | Object.entries(canonicalCommand.attachmentsByQuestionId ?? {}).map( | ||
| 284 | ([questionId, original]) => { | ||
| 285 | const claimed = normalizedAttachments.slice( | ||
| 286 | index, | ||
| 287 | index + original.length, | ||
| 288 | ) as UserInputAttachments[string]; | ||
| 289 | index += original.length; | ||
| 290 | return [questionId, claimed]; | ||
| 291 | }, | ||
| 292 | ), | ||
| 293 | ); | ||
| 294 | return { | ||
| 295 | ...canonicalCommand, | ||
| 296 | ...(attachments.length > 0 ? { attachmentsByQuestionId } : {}), | ||
| 297 | }; | ||
| 298 | } | ||
| 263 | return { | 299 | return { |
| 264 | ...canonicalCommand, | 300 | ...canonicalCommand, |
| 265 | message: { | 301 | message: { |
| ... | @@ -272,14 +308,24 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => | ... | @@ -272,14 +308,24 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => |
| 272 | export const cleanupFailedUploadedAttachments = Effect.fn( | 308 | export const cleanupFailedUploadedAttachments = Effect.fn( |
| 273 | "Normalizer.cleanupFailedUploadedAttachments", | 309 | "Normalizer.cleanupFailedUploadedAttachments", |
| 274 | )(function* (command: ClientOrchestrationCommand, normalizedCommand: OrchestrationCommand) { | 310 | )(function* (command: ClientOrchestrationCommand, normalizedCommand: OrchestrationCommand) { |
| 275 | if (command.type !== "thread.turn.start" || normalizedCommand.type !== "thread.turn.start") { | 311 | const originalAttachments = |
| 276 | return; | 312 | command.type === "thread.turn.start" |
| 277 | } | 313 | ? command.message.attachments |
| 314 | : command.type === "thread.user-input.respond" | ||
| 315 | ? Object.values(command.attachmentsByQuestionId ?? {}).flat() | ||
| 316 | : []; | ||
| 317 | const normalizedAttachments = | ||
| 318 | normalizedCommand.type === "thread.turn.start" | ||
| 319 | ? normalizedCommand.message.attachments | ||
| 320 | : normalizedCommand.type === "thread.user-input.respond" | ||
| 321 | ? Object.values(normalizedCommand.attachmentsByQuestionId ?? {}).flat() | ||
| 322 | : []; | ||
| 323 | if (normalizedAttachments.length === 0) return; | ||
| 278 | 324 | ||
| 279 | const serverConfig = yield* ServerConfig; | 325 | const serverConfig = yield* ServerConfig; |
| 280 | const claimedPaths: string[] = []; | 326 | const claimedPaths: string[] = []; |
| 281 | for (const [index, attachment] of normalizedCommand.message.attachments.entries()) { | 327 | for (const [index, attachment] of normalizedAttachments.entries()) { |
| 282 | const original = command.message.attachments[index]; | 328 | const original = originalAttachments[index]; |
| 283 | if ( | 329 | if ( |
| 284 | !original || | 330 | !original || |
| 285 | "dataUrl" in original || | 331 | "dataUrl" in original || |
apps/server/src/orchestration/decider.questionAttachments.test.ts created+130| ... | @@ -0,0 +1,130 @@ | ||
| 1 | import { | ||
| 2 | ApprovalRequestId, | ||
| 3 | EventId, | ||
| 4 | CommandId, | ||
| 5 | ProjectId, | ||
| 6 | ProviderInstanceId, | ||
| 7 | ThreadId, | ||
| 8 | type OrchestrationReadModel, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 11 | import { expect, it } from "@effect/vitest"; | ||
| 12 | import * as Effect from "effect/Effect"; | ||
| 13 | |||
| 14 | import { decideOrchestrationCommand } from "./decider.ts"; | ||
| 15 | |||
| 16 | const UPDATED_AT = "2026-01-01T00:00:00.000Z"; | ||
| 17 | |||
| 18 | const readModel: OrchestrationReadModel = { | ||
| 19 | snapshotSequence: 0, | ||
| 20 | projects: [], | ||
| 21 | threads: [ | ||
| 22 | { | ||
| 23 | id: ThreadId.make("thread-1"), | ||
| 24 | projectId: ProjectId.make("project-1"), | ||
| 25 | title: "Manual title", | ||
| 26 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | ||
| 27 | runtimeMode: "full-access", | ||
| 28 | interactionMode: "default", | ||
| 29 | branch: null, | ||
| 30 | worktreePath: null, | ||
| 31 | latestTurn: null, | ||
| 32 | createdAt: UPDATED_AT, | ||
| 33 | updatedAt: UPDATED_AT, | ||
| 34 | archivedAt: null, | ||
| 35 | settledOverride: null, | ||
| 36 | settledAt: null, | ||
| 37 | snoozedUntil: null, | ||
| 38 | snoozedAt: null, | ||
| 39 | deletedAt: null, | ||
| 40 | messages: [], | ||
| 41 | proposedPlans: [], | ||
| 42 | activities: [], | ||
| 43 | checkpoints: [], | ||
| 44 | session: null, | ||
| 45 | }, | ||
| 46 | ], | ||
| 47 | updatedAt: UPDATED_AT, | ||
| 48 | }; | ||
| 49 | |||
| 50 | const requestId = ApprovalRequestId.make("question-request"); | ||
| 51 | const command = { | ||
| 52 | type: "thread.user-input.respond" as const, | ||
| 53 | commandId: CommandId.make("answer"), | ||
| 54 | threadId: ThreadId.make("thread-1"), | ||
| 55 | requestId, | ||
| 56 | answers: { q: "" }, | ||
| 57 | createdAt: UPDATED_AT, | ||
| 58 | attachmentsByQuestionId: { | ||
| 59 | q: [ | ||
| 60 | { | ||
| 61 | type: "file" as const, | ||
| 62 | id: "thread-1-00000000-0000-4000-8000-0000000000aa-txt", | ||
| 63 | name: "spec.txt", | ||
| 64 | mimeType: "text/plain", | ||
| 65 | sizeBytes: 4, | ||
| 66 | }, | ||
| 67 | ], | ||
| 68 | }, | ||
| 69 | }; | ||
| 70 | const request = { | ||
| 71 | id: EventId.make("question"), | ||
| 72 | kind: "user-input.requested", | ||
| 73 | summary: "Question", | ||
| 74 | tone: "info" as const, | ||
| 75 | turnId: null, | ||
| 76 | createdAt: UPDATED_AT, | ||
| 77 | payload: { | ||
| 78 | requestId, | ||
| 79 | questions: [ | ||
| 80 | { id: "q", header: "Spec", question: "Provide a spec", options: [], allowCustomAnswer: true }, | ||
| 81 | ], | ||
| 82 | }, | ||
| 83 | }; | ||
| 84 | it.layer(NodeServices.layer)("question attachment answers", (it) => { | ||
| 85 | it.effect("persists the original answer with its attachment and emits a provider response", () => | ||
| 86 | Effect.gen(function* () { | ||
| 87 | const result = yield* decideOrchestrationCommand({ | ||
| 88 | readModel, | ||
| 89 | command, | ||
| 90 | userInputActivity: request, | ||
| 91 | }); | ||
| 92 | const events = Array.isArray(result) ? result : [result]; | ||
| 93 | expect(events.map((event) => event.type)).toEqual([ | ||
| 94 | "thread.activity-appended", | ||
| 95 | "thread.user-input-response-requested", | ||
| 96 | ]); | ||
| 97 | expect(events[0]?.payload).toMatchObject({ | ||
| 98 | activity: { | ||
| 99 | kind: "user-input.answer-submitted", | ||
| 100 | payload: { answers: { q: "" }, attachmentsByQuestionId: command.attachmentsByQuestionId }, | ||
| 101 | }, | ||
| 102 | }); | ||
| 103 | expect(events[1]?.payload).toMatchObject({ | ||
| 104 | answers: { q: "" }, | ||
| 105 | attachmentsByQuestionId: command.attachmentsByQuestionId, | ||
| 106 | }); | ||
| 107 | }), | ||
| 108 | ); | ||
| 109 | it.effect("rejects attachments for a resolved or unknown request", () => | ||
| 110 | Effect.gen(function* () { | ||
| 111 | const result = yield* decideOrchestrationCommand({ readModel, command }).pipe(Effect.result); | ||
| 112 | expect(result._tag).toBe("Failure"); | ||
| 113 | }), | ||
| 114 | ); | ||
| 115 | it.effect("rejects unknown question IDs and predefined-choice-only protocols", () => | ||
| 116 | Effect.gen(function* () { | ||
| 117 | for (const question of [ | ||
| 118 | { ...request.payload.questions[0], id: "other" }, | ||
| 119 | { ...request.payload.questions[0], allowCustomAnswer: false }, | ||
| 120 | ]) { | ||
| 121 | const result = yield* decideOrchestrationCommand({ | ||
| 122 | readModel, | ||
| 123 | command, | ||
| 124 | userInputActivity: { ...request, payload: { ...request.payload, questions: [question] } }, | ||
| 125 | }).pipe(Effect.result); | ||
| 126 | expect(result._tag).toBe("Failure"); | ||
| 127 | } | ||
| 128 | }), | ||
| 129 | ); | ||
| 130 | }); | ||
apps/server/src/orchestration/decider.ts+76-8| ... | @@ -1222,6 +1222,35 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" | ... | @@ -1222,6 +1222,35 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 1222 | threadId: command.threadId, | 1222 | threadId: command.threadId, |
| 1223 | }); | 1223 | }); |
| 1224 | const request = userInputActivity; | 1224 | const request = userInputActivity; |
| 1225 | const attachments = Object.values(command.attachmentsByQuestionId ?? {}).flat(); | ||
| 1226 | let questionTextById: Record<string, string> = {}; | ||
| 1227 | if (attachments.length > 0) { | ||
| 1228 | const payload = | ||
| 1229 | request?.kind === "user-input.requested" | ||
| 1230 | ? decodeUserInputRequestedPayload(request.payload) | ||
| 1231 | : Option.none(); | ||
| 1232 | if (Option.isNone(payload)) { | ||
| 1233 | return yield* new OrchestrationCommandInvariantError({ | ||
| 1234 | commandType: command.type, | ||
| 1235 | detail: | ||
| 1236 | request?.kind === "user-input.resolved" | ||
| 1237 | ? "This question has already been answered." | ||
| 1238 | : "This question is no longer pending.", | ||
| 1239 | }); | ||
| 1240 | } | ||
| 1241 | questionTextById = Object.fromEntries( | ||
| 1242 | payload.value.questions.map((question) => [question.id, question.question]), | ||
| 1243 | ); | ||
| 1244 | for (const questionId of Object.keys(command.attachmentsByQuestionId ?? {})) { | ||
| 1245 | const question = payload.value.questions.find((question) => question.id === questionId); | ||
| 1246 | if (!question || question.allowCustomAnswer === false) { | ||
| 1247 | return yield* new OrchestrationCommandInvariantError({ | ||
| 1248 | commandType: command.type, | ||
| 1249 | detail: "This question does not accept file references.", | ||
| 1250 | }); | ||
| 1251 | } | ||
| 1252 | } | ||
| 1253 | } | ||
| 1225 | if ( | 1254 | if ( |
| 1226 | request && | 1255 | request && |
| 1227 | Predicate.isObject(request.payload) && | 1256 | Predicate.isObject(request.payload) && |
| ... | @@ -1237,13 +1266,22 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" | ... | @@ -1237,13 +1266,22 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 1237 | const replies: string[] = []; | 1266 | const replies: string[] = []; |
| 1238 | for (const question of payload.value.questions) { | 1267 | for (const question of payload.value.questions) { |
| 1239 | const answer = command.answers[question.id]; | 1268 | const answer = command.answers[question.id]; |
| 1240 | if (typeof answer !== "string" || answer.trim().length === 0) { | 1269 | if ( |
| 1270 | typeof answer !== "string" || | ||
| 1271 | (answer.trim().length === 0 && !command.attachmentsByQuestionId?.[question.id]?.length) | ||
| 1272 | ) { | ||
| 1241 | return yield* new OrchestrationCommandInvariantError({ | 1273 | return yield* new OrchestrationCommandInvariantError({ |
| 1242 | commandType: command.type, | 1274 | commandType: command.type, |
| 1243 | detail: "Answer each question before sending.", | 1275 | detail: "Answer each question before sending.", |
| 1244 | }); | 1276 | }); |
| 1245 | } | 1277 | } |
| 1246 | replies.push(`${question.question}\n${answer.trim()}`); | 1278 | const questionAttachments = command.attachmentsByQuestionId?.[question.id] ?? []; |
| 1279 | const attachmentLabels = questionAttachments | ||
| 1280 | .map((attachment) => `Attached file: ${attachment.name} (${attachment.id})`) | ||
| 1281 | .join("\n"); | ||
| 1282 | replies.push( | ||
| 1283 | [`${question.question}\n${answer.trim()}`, attachmentLabels].filter(Boolean).join("\n"), | ||
| 1284 | ); | ||
| 1247 | } | 1285 | } |
| 1248 | // Commit the answer and its message together. The normal turn path | 1286 | // Commit the answer and its message together. The normal turn path |
| 1249 | // steers a running agent or resumes an idle session. | 1287 | // steers a running agent or resumes an idle session. |
| ... | @@ -1266,6 +1304,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" | ... | @@ -1266,6 +1304,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 1266 | requestId: command.requestId, | 1304 | requestId: command.requestId, |
| 1267 | responseMode: "message", | 1305 | responseMode: "message", |
| 1268 | answers: command.answers, | 1306 | answers: command.answers, |
| 1307 | ...(command.attachmentsByQuestionId | ||
| 1308 | ? { attachmentsByQuestionId: command.attachmentsByQuestionId } | ||
| 1309 | : {}), | ||
| 1269 | }, | 1310 | }, |
| 1270 | }, | 1311 | }, |
| 1271 | }, | 1312 | }, |
| ... | @@ -1280,30 +1321,57 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" | ... | @@ -1280,30 +1321,57 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 1280 | messageId: MessageId.make(`async-answer:${command.requestId}`), | 1321 | messageId: MessageId.make(`async-answer:${command.requestId}`), |
| 1281 | role: "user", | 1322 | role: "user", |
| 1282 | text: replies.join("\n\n"), | 1323 | text: replies.join("\n\n"), |
| 1283 | attachments: [], | 1324 | attachments, |
| 1284 | }, | 1325 | }, |
| 1285 | }, | 1326 | }, |
| 1286 | ], | 1327 | ], |
| 1287 | }); | 1328 | }); |
| 1288 | } | 1329 | } |
| 1289 | return { | 1330 | const responseEvent = { |
| 1290 | ...(yield* withEventBase({ | 1331 | ...(yield* withEventBase({ |
| 1291 | aggregateKind: "thread", | 1332 | aggregateKind: "thread", |
| 1292 | aggregateId: command.threadId, | 1333 | aggregateId: command.threadId, |
| 1293 | occurredAt: command.createdAt, | 1334 | occurredAt: command.createdAt, |
| 1294 | commandId: command.commandId, | 1335 | commandId: command.commandId, |
| 1295 | metadata: { | 1336 | metadata: { requestId: command.requestId }, |
| 1296 | requestId: command.requestId, | ||
| 1297 | }, | ||
| 1298 | })), | 1337 | })), |
| 1299 | type: "thread.user-input-response-requested", | 1338 | type: "thread.user-input-response-requested" as const, |
| 1300 | payload: { | 1339 | payload: { |
| 1301 | threadId: command.threadId, | 1340 | threadId: command.threadId, |
| 1302 | requestId: command.requestId, | 1341 | requestId: command.requestId, |
| 1303 | answers: command.answers, | 1342 | answers: command.answers, |
| 1343 | ...(command.attachmentsByQuestionId | ||
| 1344 | ? { attachmentsByQuestionId: command.attachmentsByQuestionId } | ||
| 1345 | : {}), | ||
| 1304 | createdAt: command.createdAt, | 1346 | createdAt: command.createdAt, |
| 1305 | }, | 1347 | }, |
| 1306 | }; | 1348 | }; |
| 1349 | if (attachments.length === 0) return responseEvent; | ||
| 1350 | const historyEvent = yield* decideOrchestrationCommand({ | ||
| 1351 | readModel, | ||
| 1352 | command: { | ||
| 1353 | type: "thread.activity.append", | ||
| 1354 | commandId: command.commandId, | ||
| 1355 | threadId: command.threadId, | ||
| 1356 | createdAt: command.createdAt, | ||
| 1357 | activity: { | ||
| 1358 | id: EventId.make(`question-answer:${command.commandId}`), | ||
| 1359 | kind: "user-input.answer-submitted", | ||
| 1360 | summary: "Question answer submitted", | ||
| 1361 | tone: "info", | ||
| 1362 | turnId: request?.turnId ?? null, | ||
| 1363 | createdAt: command.createdAt, | ||
| 1364 | payload: { | ||
| 1365 | requestId: command.requestId, | ||
| 1366 | answers: command.answers, | ||
| 1367 | questionTextById, | ||
| 1368 | attachmentsByQuestionId: command.attachmentsByQuestionId, | ||
| 1369 | detail: attachments.map((attachment) => attachment.name).join("\n"), | ||
| 1370 | }, | ||
| 1371 | }, | ||
| 1372 | }, | ||
| 1373 | }); | ||
| 1374 | return [...(Array.isArray(historyEvent) ? historyEvent : [historyEvent]), responseEvent]; | ||
| 1307 | } | 1375 | } |
| 1308 | 1376 | ||
| 1309 | case "thread.user-input.dismiss": { | 1377 | case "thread.user-input.dismiss": { |
apps/server/src/provider/Layers/ProviderService.ts+6-1| ... | @@ -49,6 +49,7 @@ import * as Schema from "effect/Schema"; | ... | @@ -49,6 +49,7 @@ import * as Schema from "effect/Schema"; |
| 49 | import * as SchemaIssue from "effect/SchemaIssue"; | 49 | import * as SchemaIssue from "effect/SchemaIssue"; |
| 50 | import * as Stream from "effect/Stream"; | 50 | import * as Stream from "effect/Stream"; |
| 51 | 51 | ||
| 52 | import { appendUserInputAttachmentPaths } from "../userInputAttachments.ts"; | ||
| 52 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; | 53 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; |
| 53 | import * as ServerConfig from "../../config.ts"; | 54 | import * as ServerConfig from "../../config.ts"; |
| 54 | import { | 55 | import { |
| ... | @@ -1919,7 +1920,11 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( | ... | @@ -1919,7 +1920,11 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 1919 | "provider.thread_id": input.threadId, | 1920 | "provider.thread_id": input.threadId, |
| 1920 | "provider.request_id": input.requestId, | 1921 | "provider.request_id": input.requestId, |
| 1921 | }); | 1922 | }); |
| 1922 | yield* routed.adapter.respondToUserInput(routed.threadId, input.requestId, input.answers); | 1923 | const answers = yield* appendUserInputAttachmentPaths({ |
| 1924 | ...input, | ||
| 1925 | attachmentsDir: serverConfig.attachmentsDir, | ||
| 1926 | }).pipe(Effect.provideService(FileSystem.FileSystem, fileSystem)); | ||
| 1927 | yield* routed.adapter.respondToUserInput(routed.threadId, input.requestId, answers); | ||
| 1923 | }).pipe( | 1928 | }).pipe( |
| 1924 | withMetrics({ | 1929 | withMetrics({ |
| 1925 | counter: providerTurnsTotal, | 1930 | counter: providerTurnsTotal, |
apps/server/src/provider/userInputAttachments.test.ts created+61| ... | @@ -0,0 +1,61 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off | ||
| 2 | import * as NodeFS from "node:fs"; | ||
| 3 | import * as NodePath from "node:path"; | ||
| 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 5 | import { describe, expect, it } from "@effect/vitest"; | ||
| 6 | import * as Effect from "effect/Effect"; | ||
| 7 | import * as Layer from "effect/Layer"; | ||
| 8 | import * as ServerConfig from "../config.ts"; | ||
| 9 | import { appendUserInputAttachmentPaths } from "./userInputAttachments.ts"; | ||
| 10 | |||
| 11 | const layer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-answer-paths-" }).pipe( | ||
| 12 | Layer.provideMerge(NodeServices.layer), | ||
| 13 | ); | ||
| 14 | const attachment = { | ||
| 15 | type: "file" as const, | ||
| 16 | id: "thread-1-00000000-0000-4000-8000-0000000000aa-txt", | ||
| 17 | name: 'spec "final".txt', | ||
| 18 | mimeType: "text/plain", | ||
| 19 | sizeBytes: 4, | ||
| 20 | }; | ||
| 21 | describe("question answer paths", () => { | ||
| 22 | it.effect("keeps selected values intact and appends an actual readable server path", () => | ||
| 23 | Effect.gen(function* () { | ||
| 24 | const config = yield* ServerConfig.ServerConfig; | ||
| 25 | const path = NodePath.join(config.attachmentsDir, `${attachment.id}.txt`); | ||
| 26 | NodeFS.writeFileSync(path, "spec"); | ||
| 27 | const original = { q: ["First", "Second"], other: "No file" }; | ||
| 28 | const answers = yield* appendUserInputAttachmentPaths({ | ||
| 29 | answers: original, | ||
| 30 | attachmentsDir: config.attachmentsDir, | ||
| 31 | attachmentsByQuestionId: { q: [attachment] }, | ||
| 32 | }); | ||
| 33 | expect(answers.q).toEqual([ | ||
| 34 | "First", | ||
| 35 | "Second", | ||
| 36 | `Attached file "spec \\"final\\".txt": "${path}"`, | ||
| 37 | ]); | ||
| 38 | expect(answers.other).toBe("No file"); | ||
| 39 | expect(original.q).toEqual(["First", "Second"]); | ||
| 40 | expect(NodeFS.readFileSync(path, "utf8")).toBe("spec"); | ||
| 41 | const specialKey = yield* appendUserInputAttachmentPaths({ | ||
| 42 | answers: {}, | ||
| 43 | attachmentsDir: config.attachmentsDir, | ||
| 44 | attachmentsByQuestionId: { ["__proto__"]: [attachment] }, | ||
| 45 | }); | ||
| 46 | expect(Object.keys(specialKey)).toEqual(["__proto__"]); | ||
| 47 | expect(specialKey["__proto__"]).toContain(path); | ||
| 48 | }).pipe(Effect.provide(layer)), | ||
| 49 | ); | ||
| 50 | it.effect("does not send an unavailable attachment path", () => | ||
| 51 | Effect.gen(function* () { | ||
| 52 | const config = yield* ServerConfig.ServerConfig; | ||
| 53 | const result = yield* appendUserInputAttachmentPaths({ | ||
| 54 | answers: { q: "" }, | ||
| 55 | attachmentsDir: config.attachmentsDir, | ||
| 56 | attachmentsByQuestionId: { q: [attachment] }, | ||
| 57 | }).pipe(Effect.result); | ||
| 58 | expect(result._tag).toBe("Failure"); | ||
| 59 | }).pipe(Effect.provide(layer)), | ||
| 60 | ); | ||
| 61 | }); | ||
apps/server/src/provider/userInputAttachments.ts created+59| ... | @@ -0,0 +1,59 @@ | ||
| 1 | import type { ProviderUserInputAnswers, UserInputAttachments } from "@t3tools/contracts"; | ||
| 2 | import * as Schema from "effect/Schema"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as FileSystem from "effect/FileSystem"; | ||
| 5 | import { resolveAttachmentPath } from "../attachmentStore.ts"; | ||
| 6 | import { ProviderValidationError } from "./Errors.ts"; | ||
| 7 | |||
| 8 | const quoteReference = Schema.encodeSync(Schema.fromJsonString(Schema.String)); | ||
| 9 | |||
| 10 | /** Keep provider answer protocols unchanged; paths refer to files on the provider's server. */ | ||
| 11 | export const appendUserInputAttachmentPaths = Effect.fn("appendUserInputAttachmentPaths")( | ||
| 12 | function* (input: { | ||
| 13 | answers: ProviderUserInputAnswers; | ||
| 14 | attachmentsByQuestionId?: UserInputAttachments | undefined; | ||
| 15 | attachmentsDir: string; | ||
| 16 | }) { | ||
| 17 | const answers = new Map(Object.entries(input.answers)); | ||
| 18 | const fs = yield* FileSystem.FileSystem; | ||
| 19 | for (const [questionId, attachments] of Object.entries(input.attachmentsByQuestionId ?? {})) { | ||
| 20 | if (attachments.length === 0) continue; | ||
| 21 | const references: string[] = []; | ||
| 22 | for (const attachment of attachments) { | ||
| 23 | const path = resolveAttachmentPath({ attachmentsDir: input.attachmentsDir, attachment }); | ||
| 24 | if ( | ||
| 25 | !path || | ||
| 26 | !(yield* fs.exists(path).pipe( | ||
| 27 | Effect.mapError( | ||
| 28 | (cause) => | ||
| 29 | new ProviderValidationError({ | ||
| 30 | operation: "respondToUserInput", | ||
| 31 | issue: `Could not access attachment '${attachment.name}'.`, | ||
| 32 | cause, | ||
| 33 | }), | ||
| 34 | ), | ||
| 35 | )) | ||
| 36 | ) { | ||
| 37 | return yield* new ProviderValidationError({ | ||
| 38 | operation: "respondToUserInput", | ||
| 39 | issue: `Attachment '${attachment.name}' is no longer available. Attach it again.`, | ||
| 40 | }); | ||
| 41 | } | ||
| 42 | references.push( | ||
| 43 | `Attached ${attachment.type} ${quoteReference(attachment.name)}: ${quoteReference(path)}`, | ||
| 44 | ); | ||
| 45 | } | ||
| 46 | const answer = answers.get(questionId); | ||
| 47 | const text = references.join("\n"); | ||
| 48 | answers.set( | ||
| 49 | questionId, | ||
| 50 | Array.isArray(answer) | ||
| 51 | ? [...answer, text] | ||
| 52 | : typeof answer === "string" && answer.length > 0 | ||
| 53 | ? `${answer}\n\n${text}` | ||
| 54 | : text, | ||
| 55 | ); | ||
| 56 | } | ||
| 57 | return Object.fromEntries(answers); | ||
| 58 | }, | ||
| 59 | ); | ||
apps/web/src/components/ChatView.tsx+189-22| ... | @@ -8,6 +8,13 @@ import { | ... | @@ -8,6 +8,13 @@ import { |
| 8 | import { feedbackBannerItem } from "./chat/ComposerFeedback"; | 8 | import { feedbackBannerItem } from "./chat/ComposerFeedback"; |
| 9 | import { usageLimitsBannerItem } from "./chat/ComposerUsageLimits"; | 9 | import { usageLimitsBannerItem } from "./chat/ComposerUsageLimits"; |
| 10 | import { derivePendingRequests } from "@t3tools/client-runtime/pending-requests"; | 10 | import { derivePendingRequests } from "@t3tools/client-runtime/pending-requests"; |
| 11 | import { | ||
| 12 | questionAttachmentDraftId, | ||
| 13 | questionAttachmentDraftPrefix, | ||
| 14 | clearQuestionAttachmentDraft, | ||
| 15 | useQuestionAttachmentPreparation, | ||
| 16 | } from "../questionAttachments"; | ||
| 17 | import { useAttachmentUploadStore } from "../lib/attachmentUploadQueue"; | ||
| 11 | import { | 18 | import { |
| 12 | type AssistantCitation, | 19 | type AssistantCitation, |
| 13 | type ApprovalRequestId, | 20 | type ApprovalRequestId, |
| ... | @@ -264,7 +271,7 @@ import { | ... | @@ -264,7 +271,7 @@ import { |
| 264 | finalizePromotedDraftThreadByRef, | 271 | finalizePromotedDraftThreadByRef, |
| 265 | markPromotedDraftThreadByRef, | 272 | markPromotedDraftThreadByRef, |
| 266 | useComposerDraftStore, | 273 | useComposerDraftStore, |
| 267 | type DraftId, | 274 | DraftId, |
| 268 | } from "../composerDraftStore"; | 275 | } from "../composerDraftStore"; |
| 269 | import { | 276 | import { |
| 270 | appendTerminalContextsToPrompt, | 277 | appendTerminalContextsToPrompt, |
| ... | @@ -1610,6 +1617,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -1610,6 +1617,7 @@ export default function ChatView(props: ChatViewProps) { |
| 1610 | null, | 1617 | null, |
| 1611 | ); | 1618 | ); |
| 1612 | const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]); | 1619 | const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]); |
| 1620 | const userInputResponsesInFlight = useRef(new Set<string>()); | ||
| 1613 | const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState< | 1621 | const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState< |
| 1614 | ApprovalRequestId[] | 1622 | ApprovalRequestId[] |
| 1615 | >([]); | 1623 | >([]); |
| ... | @@ -2310,6 +2318,8 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -2310,6 +2318,8 @@ export default function ChatView(props: ChatViewProps) { |
| 2310 | const supportsPullRequests = serverConfig?.environment.capabilities.pullRequests === true; | 2318 | const supportsPullRequests = serverConfig?.environment.capabilities.pullRequests === true; |
| 2311 | const attachmentEnvironmentConfig = environmentById.get(environmentId)?.serverConfig ?? null; | 2319 | const attachmentEnvironmentConfig = environmentById.get(environmentId)?.serverConfig ?? null; |
| 2312 | const attachmentUploadsCapabilityKnown = attachmentEnvironmentConfig !== null; | 2320 | const attachmentUploadsCapabilityKnown = attachmentEnvironmentConfig !== null; |
| 2321 | const supportsQuestionAttachments = | ||
| 2322 | attachmentEnvironmentConfig?.environment.capabilities.questionAttachments === true; | ||
| 2313 | const supportsAttachmentUploads = | 2323 | const supportsAttachmentUploads = |
| 2314 | attachmentEnvironmentConfig?.environment.capabilities.attachmentUploads === true; | 2324 | attachmentEnvironmentConfig?.environment.capabilities.attachmentUploads === true; |
| 2315 | const advertisedFileAttachmentBytes = | 2325 | const advertisedFileAttachmentBytes = |
| ... | @@ -2627,16 +2637,120 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -2627,16 +2637,120 @@ export default function ChatView(props: ChatViewProps) { |
| 2627 | [threadActivities], | 2637 | [threadActivities], |
| 2628 | ); | 2638 | ); |
| 2629 | const activePendingUserInput = pendingUserInputs[0] ?? null; | 2639 | const activePendingUserInput = pendingUserInputs[0] ?? null; |
| 2630 | const activePendingDraftAnswers = useMemo( | 2640 | const activePendingRequestKey = JSON.stringify([ |
| 2641 | environmentId, | ||
| 2642 | activeThreadId, | ||
| 2643 | activePendingUserInput?.requestId, | ||
| 2644 | ]); | ||
| 2645 | const pendingQuestionDraftKeys = useMemo( | ||
| 2631 | () => | 2646 | () => |
| 2632 | activePendingUserInput | 2647 | activeThreadId |
| 2633 | ? (pendingUserInputAnswersByRequestId[activePendingUserInput.requestId] ?? | 2648 | ? pendingUserInputs.flatMap((request) => |
| 2634 | EMPTY_PENDING_USER_INPUT_ANSWERS) | 2649 | request.questions.map((question) => |
| 2635 | : EMPTY_PENDING_USER_INPUT_ANSWERS, | 2650 | questionAttachmentDraftId( |
| 2636 | [activePendingUserInput, pendingUserInputAnswersByRequestId], | 2651 | environmentId, |
| 2652 | activeThreadId, | ||
| 2653 | request.requestId, | ||
| 2654 | question.id, | ||
| 2655 | ), | ||
| 2656 | ), | ||
| 2657 | ) | ||
| 2658 | : [], | ||
| 2659 | [activeThreadId, environmentId, pendingUserInputs], | ||
| 2637 | ); | 2660 | ); |
| 2661 | const questionComposerDrafts = useComposerDraftStore( | ||
| 2662 | useShallow((state) => | ||
| 2663 | Object.fromEntries( | ||
| 2664 | pendingQuestionDraftKeys.map((key) => [key, state.draftsByThreadKey[key]]), | ||
| 2665 | ), | ||
| 2666 | ), | ||
| 2667 | ); | ||
| 2668 | const questionUploadsBlocked = useAttachmentUploadStore( | ||
| 2669 | useShallow((state) => | ||
| 2670 | Object.fromEntries( | ||
| 2671 | pendingQuestionDraftKeys.map((key) => { | ||
| 2672 | const draft = questionComposerDrafts[key]; | ||
| 2673 | const attachments = draft ? [...draft.images, ...draft.files] : []; | ||
| 2674 | return [ | ||
| 2675 | key, | ||
| 2676 | attachments.some((attachment) => { | ||
| 2677 | const upload = state.uploadsByImageId[attachment.id]; | ||
| 2678 | return upload?.status !== "ready" || upload.environmentId !== environmentId; | ||
| 2679 | }), | ||
| 2680 | ]; | ||
| 2681 | }), | ||
| 2682 | ), | ||
| 2683 | ), | ||
| 2684 | ); | ||
| 2685 | const questionPreparations = useQuestionAttachmentPreparation( | ||
| 2686 | useShallow((state) => | ||
| 2687 | Object.fromEntries(pendingQuestionDraftKeys.map((key) => [key, state.counts[key] ?? 0])), | ||
| 2688 | ), | ||
| 2689 | ); | ||
| 2690 | useEffect(() => { | ||
| 2691 | if (routeThreadState.status !== "live" || routeThreadState.data._tag !== "Some") return; | ||
| 2692 | const questionThread = routeThreadState.data.value; | ||
| 2693 | const { userInputs: currentRequests } = derivePendingRequests(questionThread.activities); | ||
| 2694 | const prefix = questionAttachmentDraftPrefix(environmentId, questionThread.id); | ||
| 2695 | const retained = new Set( | ||
| 2696 | currentRequests.flatMap((request) => | ||
| 2697 | request.questions.map((question) => | ||
| 2698 | questionAttachmentDraftId( | ||
| 2699 | environmentId, | ||
| 2700 | questionThread.id, | ||
| 2701 | request.requestId, | ||
| 2702 | question.id, | ||
| 2703 | ), | ||
| 2704 | ), | ||
| 2705 | ), | ||
| 2706 | ); | ||
| 2707 | const keys = new Set([ | ||
| 2708 | ...Object.keys(useComposerDraftStore.getState().draftsByThreadKey), | ||
| 2709 | ...Object.keys(useQuestionAttachmentPreparation.getState().counts), | ||
| 2710 | ]); | ||
| 2711 | for (const key of keys) { | ||
| 2712 | if (key.startsWith(prefix) && !retained.has(DraftId.make(key))) | ||
| 2713 | clearQuestionAttachmentDraft(DraftId.make(key)); | ||
| 2714 | } | ||
| 2715 | }, [environmentId, routeThreadState.data, routeThreadState.status]); | ||
| 2716 | const activePendingDraftAnswers = useMemo(() => { | ||
| 2717 | if (!activePendingUserInput || !activeThreadId) return EMPTY_PENDING_USER_INPUT_ANSWERS; | ||
| 2718 | return Object.fromEntries( | ||
| 2719 | activePendingUserInput.questions.map((question) => { | ||
| 2720 | const key = questionAttachmentDraftId( | ||
| 2721 | environmentId, | ||
| 2722 | activeThreadId, | ||
| 2723 | activePendingUserInput.requestId, | ||
| 2724 | question.id, | ||
| 2725 | ); | ||
| 2726 | const draft = questionComposerDrafts[key]; | ||
| 2727 | const attachments = draft ? [...draft.images, ...draft.files] : []; | ||
| 2728 | return [ | ||
| 2729 | question.id, | ||
| 2730 | { | ||
| 2731 | ...pendingUserInputAnswersByRequestId[activePendingRequestKey]?.[question.id], | ||
| 2732 | attachmentCount: attachments.length, | ||
| 2733 | attachmentsBlocked: | ||
| 2734 | (attachments.length > 0 && !supportsQuestionAttachments) || | ||
| 2735 | (questionPreparations[key] ?? 0) > 0 || | ||
| 2736 | questionUploadsBlocked[key] === true, | ||
| 2737 | }, | ||
| 2738 | ]; | ||
| 2739 | }), | ||
| 2740 | ); | ||
| 2741 | }, [ | ||
| 2742 | activePendingUserInput, | ||
| 2743 | activeThreadId, | ||
| 2744 | environmentId, | ||
| 2745 | questionComposerDrafts, | ||
| 2746 | questionUploadsBlocked, | ||
| 2747 | supportsQuestionAttachments, | ||
| 2748 | questionPreparations, | ||
| 2749 | pendingUserInputAnswersByRequestId, | ||
| 2750 | activePendingRequestKey, | ||
| 2751 | ]); | ||
| 2638 | const activePendingQuestionIndex = activePendingUserInput | 2752 | const activePendingQuestionIndex = activePendingUserInput |
| 2639 | ? (pendingUserInputQuestionIndexByRequestId[activePendingUserInput.requestId] ?? 0) | 2753 | ? (pendingUserInputQuestionIndexByRequestId[activePendingRequestKey] ?? 0) |
| 2640 | : 0; | 2754 | : 0; |
| 2641 | const activePendingProgress = useMemo( | 2755 | const activePendingProgress = useMemo( |
| 2642 | () => | 2756 | () => |
| ... | @@ -7061,8 +7175,38 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -7061,8 +7175,38 @@ export default function ChatView(props: ChatViewProps) { |
| 7061 | 7175 | ||
| 7062 | const onRespondToUserInput = useCallback( | 7176 | const onRespondToUserInput = useCallback( |
| 7063 | async (requestId: ApprovalRequestId, answers: Record<string, unknown>) => { | 7177 | async (requestId: ApprovalRequestId, answers: Record<string, unknown>) => { |
| 7064 | if (!activeThreadId) return; | 7178 | if (!activeThreadId || !activePendingUserInput || activePendingIsResponding) return; |
| 7065 | 7179 | const responseKey = JSON.stringify([environmentId, activeThreadId, requestId]); | |
| 7180 | if (userInputResponsesInFlight.current.has(responseKey)) return; | ||
| 7181 | const attachmentsByQuestionId = new Map< | ||
| 7182 | string, | ||
| 7183 | import("@t3tools/contracts").UserInputAttachments[string] | ||
| 7184 | >(); | ||
| 7185 | for (const question of activePendingUserInput.questions) { | ||
| 7186 | const target = questionAttachmentDraftId( | ||
| 7187 | environmentId, | ||
| 7188 | activeThreadId, | ||
| 7189 | requestId, | ||
| 7190 | question.id, | ||
| 7191 | ); | ||
| 7192 | if ((useQuestionAttachmentPreparation.getState().counts[target] ?? 0) > 0) return; | ||
| 7193 | const draft = useComposerDraftStore.getState().getComposerDraft(target); | ||
| 7194 | const attachments = draft ? [...draft.images, ...draft.files] : []; | ||
| 7195 | if (attachments.length === 0) continue; | ||
| 7196 | const uploaded = getUploadedAttachments({ environmentId, images: attachments }); | ||
| 7197 | if (!uploaded) { | ||
| 7198 | setThreadError( | ||
| 7199 | activeThreadId, | ||
| 7200 | "Wait for attachments to finish uploading, or remove failed uploads.", | ||
| 7201 | ); | ||
| 7202 | return; | ||
| 7203 | } | ||
| 7204 | attachmentsByQuestionId.set( | ||
| 7205 | question.id, | ||
| 7206 | uploaded as import("@t3tools/contracts").UserInputAttachments[string], | ||
| 7207 | ); | ||
| 7208 | } | ||
| 7209 | userInputResponsesInFlight.current.add(responseKey); | ||
| 7066 | setRespondingUserInputRequestIds((existing) => | 7210 | setRespondingUserInputRequestIds((existing) => |
| 7067 | existing.includes(requestId) ? existing : [...existing, requestId], | 7211 | existing.includes(requestId) ? existing : [...existing, requestId], |
| 7068 | ); | 7212 | ); |
| ... | @@ -7072,6 +7216,9 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -7072,6 +7216,9 @@ export default function ChatView(props: ChatViewProps) { |
| 7072 | threadId: activeThreadId, | 7216 | threadId: activeThreadId, |
| 7073 | requestId, | 7217 | requestId, |
| 7074 | answers, | 7218 | answers, |
| 7219 | ...(attachmentsByQuestionId.size > 0 | ||
| 7220 | ? { attachmentsByQuestionId: Object.fromEntries(attachmentsByQuestionId) } | ||
| 7221 | : {}), | ||
| 7075 | }, | 7222 | }, |
| 7076 | }); | 7223 | }); |
| 7077 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { | 7224 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { |
| ... | @@ -7081,10 +7228,18 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -7081,10 +7228,18 @@ export default function ChatView(props: ChatViewProps) { |
| 7081 | error instanceof Error ? error.message : "Failed to submit user input.", | 7228 | error instanceof Error ? error.message : "Failed to submit user input.", |
| 7082 | ); | 7229 | ); |
| 7083 | } | 7230 | } |
| 7231 | userInputResponsesInFlight.current.delete(responseKey); | ||
| 7084 | setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId)); | 7232 | setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId)); |
| 7085 | return result; | 7233 | return result; |
| 7086 | }, | 7234 | }, |
| 7087 | [activeThreadId, environmentId, respondToThreadUserInput, setThreadError], | 7235 | [ |
| 7236 | activeThreadId, | ||
| 7237 | activePendingUserInput, | ||
| 7238 | activePendingIsResponding, | ||
| 7239 | environmentId, | ||
| 7240 | respondToThreadUserInput, | ||
| 7241 | setThreadError, | ||
| 7242 | ], | ||
| 7088 | ); | 7243 | ); |
| 7089 | 7244 | ||
| 7090 | // Closes an async question without messaging the agent. The server records | 7245 | // Closes an async question without messaging the agent. The server records |
| ... | @@ -7120,10 +7275,10 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -7120,10 +7275,10 @@ export default function ChatView(props: ChatViewProps) { |
| 7120 | } | 7275 | } |
| 7121 | setPendingUserInputQuestionIndexByRequestId((existing) => ({ | 7276 | setPendingUserInputQuestionIndexByRequestId((existing) => ({ |
| 7122 | ...existing, | 7277 | ...existing, |
| 7123 | [activePendingUserInput.requestId]: nextQuestionIndex, | 7278 | [activePendingRequestKey]: nextQuestionIndex, |
| 7124 | })); | 7279 | })); |
| 7125 | }, | 7280 | }, |
| 7126 | [activePendingUserInput], | 7281 | [activePendingUserInput, activePendingRequestKey], |
| 7127 | ); | 7282 | ); |
| 7128 | 7283 | ||
| 7129 | const onSelectActivePendingUserInputOption = useCallback( | 7284 | const onSelectActivePendingUserInputOption = useCallback( |
| ... | @@ -7143,11 +7298,11 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -7143,11 +7298,11 @@ export default function ChatView(props: ChatViewProps) { |
| 7143 | 7298 | ||
| 7144 | return { | 7299 | return { |
| 7145 | ...existing, | 7300 | ...existing, |
| 7146 | [activePendingUserInput.requestId]: { | 7301 | [activePendingRequestKey]: { |
| 7147 | ...existing[activePendingUserInput.requestId], | 7302 | ...existing[activePendingRequestKey], |
| 7148 | [questionId]: togglePendingUserInputOptionSelection( | 7303 | [questionId]: togglePendingUserInputOptionSelection( |
| 7149 | question, | 7304 | question, |
| 7150 | existing[activePendingUserInput.requestId]?.[questionId], | 7305 | existing[activePendingRequestKey]?.[questionId], |
| 7151 | optionValue, | 7306 | optionValue, |
| 7152 | ), | 7307 | ), |
| 7153 | }, | 7308 | }, |
| ... | @@ -7156,7 +7311,12 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -7156,7 +7311,12 @@ export default function ChatView(props: ChatViewProps) { |
| 7156 | promptRef.current = ""; | 7311 | promptRef.current = ""; |
| 7157 | composerRef.current?.resetCursorState({ cursor: 0 }); | 7312 | composerRef.current?.resetCursorState({ cursor: 0 }); |
| 7158 | }, | 7313 | }, |
| 7159 | [activePendingProgress?.activeQuestion, activePendingUserInput, composerRef], | 7314 | [ |
| 7315 | activePendingProgress?.activeQuestion, | ||
| 7316 | activePendingUserInput, | ||
| 7317 | activePendingRequestKey, | ||
| 7318 | composerRef, | ||
| 7319 | ], | ||
| 7160 | ); | 7320 | ); |
| 7161 | 7321 | ||
| 7162 | const onChangeActivePendingUserInputCustomAnswer = useCallback( | 7322 | const onChangeActivePendingUserInputCustomAnswer = useCallback( |
| ... | @@ -7177,10 +7337,10 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -7177,10 +7337,10 @@ export default function ChatView(props: ChatViewProps) { |
| 7177 | promptRef.current = value; | 7337 | promptRef.current = value; |
| 7178 | setPendingUserInputAnswersByRequestId((existing) => ({ | 7338 | setPendingUserInputAnswersByRequestId((existing) => ({ |
| 7179 | ...existing, | 7339 | ...existing, |
| 7180 | [activePendingUserInput.requestId]: { | 7340 | [activePendingRequestKey]: { |
| 7181 | ...existing[activePendingUserInput.requestId], | 7341 | ...existing[activePendingRequestKey], |
| 7182 | [questionId]: setPendingUserInputCustomAnswer( | 7342 | [questionId]: setPendingUserInputCustomAnswer( |
| 7183 | existing[activePendingUserInput.requestId]?.[questionId], | 7343 | existing[activePendingRequestKey]?.[questionId], |
| 7184 | value, | 7344 | value, |
| 7185 | ), | 7345 | ), |
| 7186 | }, | 7346 | }, |
| ... | @@ -7194,11 +7354,16 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -7194,11 +7354,16 @@ export default function ChatView(props: ChatViewProps) { |
| 7194 | composerRef.current?.focusAt(nextCursor); | 7354 | composerRef.current?.focusAt(nextCursor); |
| 7195 | } | 7355 | } |
| 7196 | }, | 7356 | }, |
| 7197 | [activePendingUserInput, composerRef], | 7357 | [activePendingUserInput, activePendingRequestKey, composerRef], |
| 7198 | ); | 7358 | ); |
| 7199 | 7359 | ||
| 7200 | const onAdvanceActivePendingUserInput = useCallback(() => { | 7360 | const onAdvanceActivePendingUserInput = useCallback(() => { |
| 7201 | if (!activePendingUserInput || !activePendingProgress) { | 7361 | if ( |
| 7362 | !activePendingUserInput || | ||
| 7363 | !activePendingProgress || | ||
| 7364 | !activePendingProgress.canAdvance || | ||
| 7365 | activePendingIsResponding | ||
| 7366 | ) { | ||
| 7202 | return; | 7367 | return; |
| 7203 | } | 7368 | } |
| 7204 | if (activePendingProgress.isLastQuestion) { | 7369 | if (activePendingProgress.isLastQuestion) { |
| ... | @@ -7212,6 +7377,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -7212,6 +7377,7 @@ export default function ChatView(props: ChatViewProps) { |
| 7212 | activePendingProgress, | 7377 | activePendingProgress, |
| 7213 | activePendingResolvedAnswers, | 7378 | activePendingResolvedAnswers, |
| 7214 | activePendingUserInput, | 7379 | activePendingUserInput, |
| 7380 | activePendingIsResponding, | ||
| 7215 | onRespondToUserInput, | 7381 | onRespondToUserInput, |
| 7216 | setActivePendingUserInputQuestionIndex, | 7382 | setActivePendingUserInputQuestionIndex, |
| 7217 | ]); | 7383 | ]); |
| ... | @@ -8119,6 +8285,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -8119,6 +8285,7 @@ export default function ChatView(props: ChatViewProps) { |
| 8119 | environmentId={environmentId} | 8285 | environmentId={environmentId} |
| 8120 | attachmentUploadsCapabilityKnown={attachmentUploadsCapabilityKnown} | 8286 | attachmentUploadsCapabilityKnown={attachmentUploadsCapabilityKnown} |
| 8121 | supportsAttachmentUploads={supportsAttachmentUploads} | 8287 | supportsAttachmentUploads={supportsAttachmentUploads} |
| 8288 | supportsQuestionAttachments={supportsQuestionAttachments} | ||
| 8122 | maxFileAttachmentBytes={maxFileAttachmentBytes} | 8289 | maxFileAttachmentBytes={maxFileAttachmentBytes} |
| 8123 | routeKind={routeKind} | 8290 | routeKind={routeKind} |
| 8124 | routeThreadRef={routeThreadRef} | 8291 | routeThreadRef={routeThreadRef} |
apps/web/src/components/chat/ChatComposer.tsx+156-47| ... | @@ -1,4 +1,9 @@ | ... | @@ -1,4 +1,9 @@ |
| 1 | import { RefreshIcon } from "~/components/ui/refresh-icon"; | 1 | import { RefreshIcon } from "~/components/ui/refresh-icon"; |
| 2 | import { | ||
| 3 | questionAttachmentDraftId, | ||
| 4 | useQuestionAttachmentPreparation, | ||
| 5 | changeQuestionAttachmentPreparation, | ||
| 6 | } from "../../questionAttachments"; | ||
| 2 | import type { | 7 | import type { |
| 3 | ApprovalRequestId, | 8 | ApprovalRequestId, |
| 4 | AssistantCitation, | 9 | AssistantCitation, |
| ... | @@ -1227,6 +1232,7 @@ export interface ChatComposerProps { | ... | @@ -1227,6 +1232,7 @@ export interface ChatComposerProps { |
| 1227 | environmentId: EnvironmentId; | 1232 | environmentId: EnvironmentId; |
| 1228 | attachmentUploadsCapabilityKnown: boolean; | 1233 | attachmentUploadsCapabilityKnown: boolean; |
| 1229 | supportsAttachmentUploads: boolean; | 1234 | supportsAttachmentUploads: boolean; |
| 1235 | supportsQuestionAttachments: boolean; | ||
| 1230 | maxFileAttachmentBytes: number | null; | 1236 | maxFileAttachmentBytes: number | null; |
| 1231 | routeKind: "server" | "draft"; | 1237 | routeKind: "server" | "draft"; |
| 1232 | routeThreadRef: ScopedThreadRef; | 1238 | routeThreadRef: ScopedThreadRef; |
| ... | @@ -1376,6 +1382,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -1376,6 +1382,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1376 | environmentId, | 1382 | environmentId, |
| 1377 | attachmentUploadsCapabilityKnown, | 1383 | attachmentUploadsCapabilityKnown, |
| 1378 | supportsAttachmentUploads, | 1384 | supportsAttachmentUploads, |
| 1385 | supportsQuestionAttachments, | ||
| 1379 | maxFileAttachmentBytes, | 1386 | maxFileAttachmentBytes, |
| 1380 | routeKind, | 1387 | routeKind, |
| 1381 | routeThreadRef, | 1388 | routeThreadRef, |
| ... | @@ -1468,9 +1475,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -1468,9 +1475,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1468 | // happened while they awaited. | 1475 | // happened while they awaited. |
| 1469 | const composerDraftTargetKeyRef = useRef(""); | 1476 | const composerDraftTargetKeyRef = useRef(""); |
| 1470 | composerDraftTargetKeyRef.current = composerTargetKey(composerDraftTarget); | 1477 | composerDraftTargetKeyRef.current = composerTargetKey(composerDraftTarget); |
| 1478 | const questionAttachmentTarget = | ||
| 1479 | pendingUserInputs[0] && activePendingProgress?.activeQuestion | ||
| 1480 | ? questionAttachmentDraftId( | ||
| 1481 | environmentId, | ||
| 1482 | activeThreadId!, | ||
| 1483 | pendingUserInputs[0].requestId, | ||
| 1484 | activePendingProgress.activeQuestion.id, | ||
| 1485 | ) | ||
| 1486 | : null; | ||
| 1487 | const attachmentDraftTarget = questionAttachmentTarget ?? composerDraftTarget; | ||
| 1488 | const attachmentDraft = useComposerThreadDraft(attachmentDraftTarget); | ||
| 1489 | const attachmentTargetKey = composerTargetKey(attachmentDraftTarget); | ||
| 1490 | const questionPreparations = useQuestionAttachmentPreparation((state) => state.counts); | ||
| 1471 | const prompt = composerDraft.prompt; | 1491 | const prompt = composerDraft.prompt; |
| 1472 | const composerImages = composerDraft.images; | 1492 | const composerImages = attachmentDraft.images; |
| 1473 | const composerFiles = composerDraft.files; | 1493 | const composerFiles = attachmentDraft.files; |
| 1474 | const composerVideos = composerFiles.filter((file) => | 1494 | const composerVideos = composerFiles.filter((file) => |
| 1475 | isPreviewableComposerVideo(file, environmentId), | 1495 | isPreviewableComposerVideo(file, environmentId), |
| 1476 | ); | 1496 | ); |
| ... | @@ -1500,7 +1520,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -1500,7 +1520,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1500 | ); | 1520 | ); |
| 1501 | return composerImages.filter((image) => !previewAnnotationIds.has(image.id)); | 1521 | return composerImages.filter((image) => !previewAnnotationIds.has(image.id)); |
| 1502 | }, [composerImages, composerPreviewAnnotations]); | 1522 | }, [composerImages, composerPreviewAnnotations]); |
| 1503 | const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds; | 1523 | const nonPersistedComposerImageIds = attachmentDraft.nonPersistedImageIds; |
| 1504 | const uploadsByImageId = useAttachmentUploadStore((state) => state.uploadsByImageId); | 1524 | const uploadsByImageId = useAttachmentUploadStore((state) => state.uploadsByImageId); |
| 1505 | const needsReattachFileCount = composerFiles.filter(composerFileNeedsReattach).length; | 1525 | const needsReattachFileCount = composerFiles.filter(composerFileNeedsReattach).length; |
| 1506 | const fileStagingLimit = fileAttachmentStagingLimit({ | 1526 | const fileStagingLimit = fileAttachmentStagingLimit({ |
| ... | @@ -1515,6 +1535,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -1515,6 +1535,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1515 | maxFileAttachmentBytes, | 1535 | maxFileAttachmentBytes, |
| 1516 | }); | 1536 | }); |
| 1517 | const attachmentBlockReason = | 1537 | const attachmentBlockReason = |
| 1538 | (questionAttachmentTarget && | ||
| 1539 | !supportsQuestionAttachments && | ||
| 1540 | (composerImages.length > 0 || composerFiles.length > 0) | ||
| 1541 | ? "Update this server to send files with question answers" | ||
| 1542 | : null) ?? | ||
| 1518 | fileCapabilityBlockReason ?? | 1543 | fileCapabilityBlockReason ?? |
| 1519 | (supportsAttachmentUploads | 1544 | (supportsAttachmentUploads |
| 1520 | ? needsReattachFileCount > 0 | 1545 | ? needsReattachFileCount > 0 |
| ... | @@ -1528,7 +1553,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -1528,7 +1553,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1528 | }) | 1553 | }) |
| 1529 | : null); | 1554 | : null); |
| 1530 | const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); | 1555 | const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); |
| 1531 | const addComposerDraftImage = useComposerDraftStore((store) => store.addImage); | ||
| 1532 | const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); | 1556 | const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); |
| 1533 | const removeComposerDraftImage = useComposerDraftStore((store) => store.removeImage); | 1557 | const removeComposerDraftImage = useComposerDraftStore((store) => store.removeImage); |
| 1534 | const addComposerDraftFiles = useComposerDraftStore((store) => store.addFiles); | 1558 | const addComposerDraftFiles = useComposerDraftStore((store) => store.addFiles); |
| ... | @@ -1596,11 +1620,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -1596,11 +1620,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1596 | if (attachment.type === "file" && composerFileNeedsReattach(attachment)) { | 1620 | if (attachment.type === "file" && composerFileNeedsReattach(attachment)) { |
| 1597 | continue; | 1621 | continue; |
| 1598 | } | 1622 | } |
| 1599 | startAttachmentUpload({ environmentId, image: attachment, draftTarget: composerDraftTarget }); | 1623 | startAttachmentUpload({ |
| 1624 | environmentId, | ||
| 1625 | image: attachment, | ||
| 1626 | draftTarget: attachmentDraftTarget, | ||
| 1627 | }); | ||
| 1600 | } | 1628 | } |
| 1601 | }, [ | 1629 | }, [ |
| 1602 | attachmentUploadsCapabilityKnown, | 1630 | attachmentUploadsCapabilityKnown, |
| 1603 | composerDraftTarget, | 1631 | attachmentDraftTarget, |
| 1604 | composerFiles, | 1632 | composerFiles, |
| 1605 | composerImages, | 1633 | composerImages, |
| 1606 | environmentId, | 1634 | environmentId, |
| ... | @@ -1621,7 +1649,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -1621,7 +1649,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1621 | const upload = uploadsByImageId[file.id]; | 1649 | const upload = uploadsByImageId[file.id]; |
| 1622 | if (upload?.status === "ready" && upload.environmentId === environmentId) { | 1650 | if (upload?.status === "ready" && upload.environmentId === environmentId) { |
| 1623 | setComposerDraftFileUpload( | 1651 | setComposerDraftFileUpload( |
| 1624 | composerDraftTarget, | 1652 | attachmentDraftTarget, |
| 1625 | file.id, | 1653 | file.id, |
| 1626 | environmentId, | 1654 | environmentId, |
| 1627 | upload.attachmentId, | 1655 | upload.attachmentId, |
| ... | @@ -1630,7 +1658,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -1630,7 +1658,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1630 | } | 1658 | } |
| 1631 | }, [ | 1659 | }, [ |
| 1632 | attachmentUploadsCapabilityKnown, | 1660 | attachmentUploadsCapabilityKnown, |
| 1633 | composerDraftTarget, | 1661 | attachmentDraftTarget, |
| 1634 | composerFiles, | 1662 | composerFiles, |
| 1635 | environmentId, | 1663 | environmentId, |
| 1636 | maxFileAttachmentBytes, | 1664 | maxFileAttachmentBytes, |
| ... | @@ -1713,7 +1741,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -1713,7 +1741,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1713 | ); | 1741 | ); |
| 1714 | const sendDisabledReason = | 1742 | const sendDisabledReason = |
| 1715 | externalSendDisabledReason ?? | 1743 | externalSendDisabledReason ?? |
| 1716 | (activePendingProgress ? null : (attachmentBlockReason ?? providerSendBlockReason)); | 1744 | (activePendingProgress |
| 1745 | ? attachmentBlockReason | ||
| 1746 | : (attachmentBlockReason ?? providerSendBlockReason)); | ||
| 1717 | const isSendDisabled = sendDisabledReason !== null; | 1747 | const isSendDisabled = sendDisabledReason !== null; |
| 1718 | const selectedProviderStatus = useMemo( | 1748 | const selectedProviderStatus = useMemo( |
| 1719 | () => selectedProviderEntry?.snapshot ?? null, | 1749 | () => selectedProviderEntry?.snapshot ?? null, |
| ... | @@ -1938,7 +1968,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -1938,7 +1968,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1938 | * and checked before sending or compacting so an image cannot move into | 1968 | * and checked before sending or compacting so an image cannot move into |
| 1939 | * the next draft. | 1969 | * the next draft. |
| 1940 | */ | 1970 | */ |
| 1941 | const pendingImageCompressionsRef = useRef<Map<ThreadId, number>>(new Map()); | 1971 | const pendingImageCompressionsRef = useRef<Map<string, number>>(new Map()); |
| 1942 | 1972 | ||
| 1943 | // ------------------------------------------------------------------ | 1973 | // ------------------------------------------------------------------ |
| 1944 | // Derived: composer send state | 1974 | // Derived: composer send state |
| ... | @@ -2127,7 +2157,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2127,7 +2157,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2127 | (!isComposerCollapsedMobile && showPlanFollowUpPrompt && activeProposedPlan !== null); | 2157 | (!isComposerCollapsedMobile && showPlanFollowUpPrompt && activeProposedPlan !== null); |
| 2128 | const showCollapsedMobilePromptRow = | 2158 | const showCollapsedMobilePromptRow = |
| 2129 | isComposerCollapsedMobile && !isComposerApprovalState && pendingUserInputs.length === 0; | 2159 | isComposerCollapsedMobile && !isComposerApprovalState && pendingUserInputs.length === 0; |
| 2130 | const showComposerAttachAction = fileStagingLimit !== null && pendingUserInputs.length === 0; | 2160 | const showComposerAttachAction = |
| 2161 | fileStagingLimit !== null && | ||
| 2162 | (!activePendingProgress || | ||
| 2163 | (supportsQuestionAttachments && | ||
| 2164 | activePendingProgress.activeQuestion?.allowCustomAnswer !== false)); | ||
| 2131 | const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null; | 2165 | const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null; |
| 2132 | const composerFooterActionLayoutKey = useMemo(() => { | 2166 | const composerFooterActionLayoutKey = useMemo(() => { |
| 2133 | if (activePendingProgress) { | 2167 | if (activePendingProgress) { |
| ... | @@ -2219,12 +2253,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2219,12 +2253,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2219 | ? { | 2253 | ? { |
| 2220 | questionIndex: activePendingProgress.questionIndex, | 2254 | questionIndex: activePendingProgress.questionIndex, |
| 2221 | isLastQuestion: activePendingProgress.isLastQuestion, | 2255 | isLastQuestion: activePendingProgress.isLastQuestion, |
| 2222 | canAdvance: activePendingProgress.canAdvance, | 2256 | canAdvance: |
| 2257 | activePendingProgress.canAdvance && | ||
| 2258 | !attachmentBlockReason && | ||
| 2259 | !(questionPreparations[attachmentTargetKey] ?? 0), | ||
| 2223 | isResponding: activePendingIsResponding, | 2260 | isResponding: activePendingIsResponding, |
| 2224 | isComplete: Boolean(activePendingResolvedAnswers), | 2261 | isComplete: Boolean(activePendingResolvedAnswers), |
| 2225 | } | 2262 | } |
| 2226 | : null, | 2263 | : null, |
| 2227 | [activePendingIsResponding, activePendingProgress, activePendingResolvedAnswers], | 2264 | [ |
| 2265 | activePendingIsResponding, | ||
| 2266 | activePendingProgress, | ||
| 2267 | activePendingResolvedAnswers, | ||
| 2268 | attachmentBlockReason, | ||
| 2269 | questionPreparations, | ||
| 2270 | attachmentTargetKey, | ||
| 2271 | ], | ||
| 2228 | ); | 2272 | ); |
| 2229 | const collapsedComposerPrimaryActionDisabled = | 2273 | const collapsedComposerPrimaryActionDisabled = |
| 2230 | phase === "running" || | 2274 | phase === "running" || |
| ... | @@ -2251,35 +2295,42 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2251,35 +2295,42 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2251 | 2295 | ||
| 2252 | const addComposerImage = useCallback( | 2296 | const addComposerImage = useCallback( |
| 2253 | (image: ComposerImageAttachment) => { | 2297 | (image: ComposerImageAttachment) => { |
| 2254 | addComposerDraftImage(composerDraftTarget, image); | 2298 | addComposerDraftImages(attachmentDraftTarget, [image]); |
| 2255 | }, | 2299 | }, |
| 2256 | [composerDraftTarget, addComposerDraftImage], | 2300 | [attachmentDraftTarget, addComposerDraftImages], |
| 2257 | ); | 2301 | ); |
| 2258 | 2302 | ||
| 2259 | const addComposerImagesToDraft = useCallback( | 2303 | const addComposerImagesToDraft = useCallback( |
| 2260 | (images: ComposerImageAttachment[]) => { | 2304 | (images: ComposerImageAttachment[]) => { |
| 2261 | addComposerDraftImages(composerDraftTarget, images); | 2305 | addComposerDraftImages(attachmentDraftTarget, images); |
| 2262 | }, | 2306 | }, |
| 2263 | [composerDraftTarget, addComposerDraftImages], | 2307 | [attachmentDraftTarget, addComposerDraftImages], |
| 2264 | ); | 2308 | ); |
| 2265 | 2309 | ||
| 2266 | const addComposerFilesToDraft = useCallback( | 2310 | const addComposerFilesToDraft = useCallback( |
| 2267 | (files: ComposerFileAttachment[]) => { | 2311 | (files: ComposerFileAttachment[]) => { |
| 2268 | addComposerDraftFiles(composerDraftTarget, files); | 2312 | addComposerDraftFiles(attachmentDraftTarget, files); |
| 2269 | }, | 2313 | }, |
| 2270 | [addComposerDraftFiles, composerDraftTarget], | 2314 | [addComposerDraftFiles, attachmentDraftTarget], |
| 2271 | ); | 2315 | ); |
| 2272 | 2316 | ||
| 2273 | const removeComposerImageFromDraft = useCallback( | 2317 | const removeComposerImageFromDraft = useCallback( |
| 2274 | (imageId: string) => { | 2318 | (imageId: string) => { |
| 2319 | if (questionAttachmentTarget && activePendingIsResponding) return; | ||
| 2275 | releaseAttachmentUpload(imageId); | 2320 | releaseAttachmentUpload(imageId); |
| 2276 | removeComposerDraftImage(composerDraftTarget, imageId); | 2321 | removeComposerDraftImage(attachmentDraftTarget, imageId); |
| 2277 | }, | 2322 | }, |
| 2278 | [composerDraftTarget, removeComposerDraftImage], | 2323 | [ |
| 2324 | attachmentDraftTarget, | ||
| 2325 | questionAttachmentTarget, | ||
| 2326 | activePendingIsResponding, | ||
| 2327 | removeComposerDraftImage, | ||
| 2328 | ], | ||
| 2279 | ); | 2329 | ); |
| 2280 | 2330 | ||
| 2281 | const removeComposerFileFromDraft = useCallback( | 2331 | const removeComposerFileFromDraft = useCallback( |
| 2282 | (fileId: string) => { | 2332 | (fileId: string) => { |
| 2333 | if (questionAttachmentTarget && activePendingIsResponding) return; | ||
| 2283 | // Release by the draft attachment, not the bare queue key: a hydrated | 2334 | // Release by the draft attachment, not the bare queue key: a hydrated |
| 2284 | // file's upload lives server-side under its persisted attachment id. | 2335 | // file's upload lives server-side under its persisted attachment id. |
| 2285 | const file = composerFilesRef.current.find((candidate) => candidate.id === fileId); | 2336 | const file = composerFilesRef.current.find((candidate) => candidate.id === fileId); |
| ... | @@ -2288,9 +2339,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2288,9 +2339,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2288 | } else { | 2339 | } else { |
| 2289 | releaseAttachmentUpload(fileId); | 2340 | releaseAttachmentUpload(fileId); |
| 2290 | } | 2341 | } |
| 2291 | removeComposerDraftFile(composerDraftTarget, fileId); | 2342 | removeComposerDraftFile(attachmentDraftTarget, fileId); |
| 2292 | }, | 2343 | }, |
| 2293 | [composerDraftTarget, composerFilesRef, removeComposerDraftFile], | 2344 | [ |
| 2345 | attachmentDraftTarget, | ||
| 2346 | questionAttachmentTarget, | ||
| 2347 | activePendingIsResponding, | ||
| 2348 | composerFilesRef, | ||
| 2349 | removeComposerDraftFile, | ||
| 2350 | ], | ||
| 2294 | ); | 2351 | ); |
| 2295 | 2352 | ||
| 2296 | const removeComposerTerminalContextFromDraft = useCallback( | 2353 | const removeComposerTerminalContextFromDraft = useCallback( |
| ... | @@ -2525,11 +2582,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2525,11 +2582,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2525 | let cancelled = false; | 2582 | let cancelled = false; |
| 2526 | void (async () => { | 2583 | void (async () => { |
| 2527 | if (composerImages.length === 0) { | 2584 | if (composerImages.length === 0) { |
| 2528 | clearComposerDraftPersistedAttachments(composerDraftTarget); | 2585 | clearComposerDraftPersistedAttachments(attachmentDraftTarget); |
| 2529 | return; | 2586 | return; |
| 2530 | } | 2587 | } |
| 2531 | const getPersistedAttachmentsForThread = () => | 2588 | const getPersistedAttachmentsForThread = () => |
| 2532 | getComposerDraft(composerDraftTarget)?.persistedAttachments ?? []; | 2589 | getComposerDraft(attachmentDraftTarget)?.persistedAttachments ?? []; |
| 2533 | try { | 2590 | try { |
| 2534 | const currentPersistedAttachments = getPersistedAttachmentsForThread(); | 2591 | const currentPersistedAttachments = getPersistedAttachmentsForThread(); |
| 2535 | const existingPersistedById = new Map( | 2592 | const existingPersistedById = new Map( |
| ... | @@ -2558,7 +2615,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2558,7 +2615,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2558 | ); | 2615 | ); |
| 2559 | const serialized = Array.from(stagedAttachmentById.values()); | 2616 | const serialized = Array.from(stagedAttachmentById.values()); |
| 2560 | if (cancelled) return; | 2617 | if (cancelled) return; |
| 2561 | syncComposerDraftPersistedAttachments(composerDraftTarget, serialized); | 2618 | syncComposerDraftPersistedAttachments(attachmentDraftTarget, serialized); |
| 2562 | } catch { | 2619 | } catch { |
| 2563 | const currentImageIds = new Set(composerImages.map((image) => image.id)); | 2620 | const currentImageIds = new Set(composerImages.map((image) => image.id)); |
| 2564 | const fallbackPersistedAttachments = getPersistedAttachmentsForThread(); | 2621 | const fallbackPersistedAttachments = getPersistedAttachmentsForThread(); |
| ... | @@ -2573,14 +2630,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2573,14 +2630,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2573 | fallbackPersistedIdSet.has(attachment.id), | 2630 | fallbackPersistedIdSet.has(attachment.id), |
| 2574 | ); | 2631 | ); |
| 2575 | if (cancelled) return; | 2632 | if (cancelled) return; |
| 2576 | syncComposerDraftPersistedAttachments(composerDraftTarget, fallbackAttachments); | 2633 | syncComposerDraftPersistedAttachments(attachmentDraftTarget, fallbackAttachments); |
| 2577 | } | 2634 | } |
| 2578 | })(); | 2635 | })(); |
| 2579 | return () => { | 2636 | return () => { |
| 2580 | cancelled = true; | 2637 | cancelled = true; |
| 2581 | }; | 2638 | }; |
| 2582 | }, [ | 2639 | }, [ |
| 2583 | composerDraftTarget, | 2640 | attachmentDraftTarget, |
| 2584 | clearComposerDraftPersistedAttachments, | 2641 | clearComposerDraftPersistedAttachments, |
| 2585 | composerImages, | 2642 | composerImages, |
| 2586 | getComposerDraft, | 2643 | getComposerDraft, |
| ... | @@ -2946,7 +3003,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2946,7 +3003,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2946 | // image: the turn snapshot wouldn't include it, and it would surface | 3003 | // image: the turn snapshot wouldn't include it, and it would surface |
| 2947 | // in the *next* draft instead. Only oversized images hit this — small | 3004 | // in the *next* draft instead. Only oversized images hit this — small |
| 2948 | // files clear the pending counter within a microtask. | 3005 | // files clear the pending counter within a microtask. |
| 2949 | if (activeThreadId && (pendingImageCompressionsRef.current.get(activeThreadId) ?? 0) > 0) { | 3006 | if ( |
| 3007 | activeThreadId && | ||
| 3008 | (pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) > 0 | ||
| 3009 | ) { | ||
| 2950 | event?.preventDefault(); | 3010 | event?.preventDefault(); |
| 2951 | toastManager.add({ | 3011 | toastManager.add({ |
| 2952 | type: "info", | 3012 | type: "info", |
| ... | @@ -2976,6 +3036,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2976,6 +3036,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2976 | [ | 3036 | [ |
| 2977 | activeThreadId, | 3037 | activeThreadId, |
| 2978 | activePendingProgress, | 3038 | activePendingProgress, |
| 3039 | attachmentTargetKey, | ||
| 2979 | blurMobileComposerAfterSend, | 3040 | blurMobileComposerAfterSend, |
| 2980 | isSendDisabled, | 3041 | isSendDisabled, |
| 2981 | noProviderAvailable, | 3042 | noProviderAvailable, |
| ... | @@ -3009,7 +3070,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -3009,7 +3070,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3009 | // The compact buttons cannot see the compression counter (it lives in | 3070 | // The compact buttons cannot see the compression counter (it lives in |
| 3010 | // a ref), so they render enabled during a paste; toast instead of | 3071 | // a ref), so they render enabled during a paste; toast instead of |
| 3011 | // silently ignoring the click. | 3072 | // silently ignoring the click. |
| 3012 | if ((pendingImageCompressionsRef.current.get(activeThreadId) ?? 0) > 0) { | 3073 | if ((pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) > 0) { |
| 3013 | toastManager.add({ | 3074 | toastManager.add({ |
| 3014 | type: "info", | 3075 | type: "info", |
| 3015 | title: "Still compressing a pasted image.", | 3076 | title: "Still compressing a pasted image.", |
| ... | @@ -4255,10 +4316,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -4255,10 +4316,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4255 | // ------------------------------------------------------------------ | 4316 | // ------------------------------------------------------------------ |
| 4256 | const addComposerAttachments = async (files: File[]) => { | 4317 | const addComposerAttachments = async (files: File[]) => { |
| 4257 | if (!activeThreadId || files.length === 0) return; | 4318 | if (!activeThreadId || files.length === 0) return; |
| 4258 | if (pendingUserInputs.length > 0) { | 4319 | if ( |
| 4320 | pendingUserInputs.length > 0 && | ||
| 4321 | (!supportsQuestionAttachments || | ||
| 4322 | activePendingProgress?.activeQuestion?.allowCustomAnswer === false || | ||
| 4323 | activePendingIsResponding) | ||
| 4324 | ) { | ||
| 4259 | toastManager.add({ | 4325 | toastManager.add({ |
| 4260 | type: "error", | 4326 | type: "error", |
| 4261 | title: "Attach files after answering pending questions.", | 4327 | title: "This question cannot accept attachments.", |
| 4262 | }); | 4328 | }); |
| 4263 | return; | 4329 | return; |
| 4264 | } | 4330 | } |
| ... | @@ -4270,9 +4336,31 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -4270,9 +4336,31 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4270 | // Validation happens synchronously so concurrent pastes see each other: | 4336 | // Validation happens synchronously so concurrent pastes see each other: |
| 4271 | // accepted files reserve their attachment slots (via the pending counter) | 4337 | // accepted files reserve their attachment slots (via the pending counter) |
| 4272 | // before the first await, keeping the total under the limit. | 4338 | // before the first await, keeping the total under the limit. |
| 4273 | const pendingCount = pendingImageCompressionsRef.current.get(threadId) ?? 0; | 4339 | const pendingCount = pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0; |
| 4340 | const otherQuestionAttachments = | ||
| 4341 | questionAttachmentTarget && pendingUserInputs[0] | ||
| 4342 | ? pendingUserInputs[0].questions.reduce((count, question) => { | ||
| 4343 | const target = questionAttachmentDraftId( | ||
| 4344 | environmentId, | ||
| 4345 | threadId, | ||
| 4346 | pendingUserInputs[0]!.requestId, | ||
| 4347 | question.id, | ||
| 4348 | ); | ||
| 4349 | if (target === questionAttachmentTarget) return count; | ||
| 4350 | const draft = getComposerDraft(target); | ||
| 4351 | return ( | ||
| 4352 | count + | ||
| 4353 | (draft?.images.length ?? 0) + | ||
| 4354 | (draft?.files.length ?? 0) + | ||
| 4355 | (useQuestionAttachmentPreparation.getState().counts[target] ?? 0) | ||
| 4356 | ); | ||
| 4357 | }, 0) | ||
| 4358 | : 0; | ||
| 4274 | let reservedCount = | 4359 | let reservedCount = |
| 4275 | composerImagesRef.current.length + composerFilesRef.current.length + pendingCount; | 4360 | composerImagesRef.current.length + |
| 4361 | composerFilesRef.current.length + | ||
| 4362 | pendingCount + | ||
| 4363 | otherQuestionAttachments; | ||
| 4276 | // A pick that matches a needs-reattach marker replaces it in the draft, so | 4364 | // A pick that matches a needs-reattach marker replaces it in the draft, so |
| 4277 | // it must not consume a slot; a draft full of markers would otherwise hit | 4365 | // it must not consume a slot; a draft full of markers would otherwise hit |
| 4278 | // the capacity error before the replacement path could run. | 4366 | // the capacity error before the replacement path could run. |
| ... | @@ -4351,7 +4439,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -4351,7 +4439,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4351 | } | 4439 | } |
| 4352 | if (acceptedImages.length === 0) return; | 4440 | if (acceptedImages.length === 0) return; |
| 4353 | 4441 | ||
| 4354 | pendingImageCompressionsRef.current.set(threadId, pendingCount + acceptedImages.length); | 4442 | pendingImageCompressionsRef.current.set( |
| 4443 | attachmentTargetKey, | ||
| 4444 | pendingCount + acceptedImages.length, | ||
| 4445 | ); | ||
| 4446 | if (questionAttachmentTarget) | ||
| 4447 | changeQuestionAttachmentPreparation(questionAttachmentTarget, acceptedImages.length); | ||
| 4355 | try { | 4448 | try { |
| 4356 | const nextImages: ComposerImageAttachment[] = []; | 4449 | const nextImages: ComposerImageAttachment[] = []; |
| 4357 | let compressionError: string | null = null; | 4450 | let compressionError: string | null = null; |
| ... | @@ -4381,6 +4474,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -4381,6 +4474,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4381 | file: attachmentFile, | 4474 | file: attachmentFile, |
| 4382 | }); | 4475 | }); |
| 4383 | } | 4476 | } |
| 4477 | if ( | ||
| 4478 | questionAttachmentTarget && | ||
| 4479 | !useQuestionAttachmentPreparation.getState().counts[questionAttachmentTarget] | ||
| 4480 | ) { | ||
| 4481 | for (const image of nextImages) URL.revokeObjectURL(image.previewUrl); | ||
| 4482 | return; | ||
| 4483 | } | ||
| 4384 | if (nextImages.length === 1 && nextImages[0]) { | 4484 | if (nextImages.length === 1 && nextImages[0]) { |
| 4385 | addComposerImage(nextImages[0]); | 4485 | addComposerImage(nextImages[0]); |
| 4386 | } else if (nextImages.length > 1) { | 4486 | } else if (nextImages.length > 1) { |
| ... | @@ -4394,12 +4494,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -4394,12 +4494,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4394 | setThreadError(threadId, compressionError); | 4494 | setThreadError(threadId, compressionError); |
| 4395 | } | 4495 | } |
| 4396 | } finally { | 4496 | } finally { |
| 4497 | if (questionAttachmentTarget) | ||
| 4498 | changeQuestionAttachmentPreparation(questionAttachmentTarget, -acceptedImages.length); | ||
| 4397 | const remaining = | 4499 | const remaining = |
| 4398 | (pendingImageCompressionsRef.current.get(threadId) ?? 0) - acceptedImages.length; | 4500 | (pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) - acceptedImages.length; |
| 4399 | if (remaining > 0) { | 4501 | if (remaining > 0) { |
| 4400 | pendingImageCompressionsRef.current.set(threadId, remaining); | 4502 | pendingImageCompressionsRef.current.set(attachmentTargetKey, remaining); |
| 4401 | } else { | 4503 | } else { |
| 4402 | pendingImageCompressionsRef.current.delete(threadId); | 4504 | pendingImageCompressionsRef.current.delete(attachmentTargetKey); |
| 4403 | } | 4505 | } |
| 4404 | } | 4506 | } |
| 4405 | }; | 4507 | }; |
| ... | @@ -4909,7 +5011,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -4909,7 +5011,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4909 | ) : !isComposerCollapsedMobile && pendingUserInputs.length > 0 ? ( | 5011 | ) : !isComposerCollapsedMobile && pendingUserInputs.length > 0 ? ( |
| 4910 | <ComposerPendingUserInputPanel | 5012 | <ComposerPendingUserInputPanel |
| 4911 | pendingUserInputs={pendingUserInputs} | 5013 | pendingUserInputs={pendingUserInputs} |
| 4912 | respondingRequestIds={respondingRequestIds} | 5014 | respondingRequestIds={ |
| 5015 | activePendingIsResponding && activePendingUserInput | ||
| 5016 | ? [...respondingRequestIds, activePendingUserInput.requestId] | ||
| 5017 | : respondingRequestIds | ||
| 5018 | } | ||
| 4913 | answers={activePendingDraftAnswers} | 5019 | answers={activePendingDraftAnswers} |
| 4914 | questionIndex={activePendingQuestionIndex} | 5020 | questionIndex={activePendingQuestionIndex} |
| 4915 | onToggleOption={onSelectActivePendingUserInputOption} | 5021 | onToggleOption={onSelectActivePendingUserInputOption} |
| ... | @@ -4925,7 +5031,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -4925,7 +5031,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4925 | <div data-chat-composer-collapsed-controls="true"> | 5031 | <div data-chat-composer-collapsed-controls="true"> |
| 4926 | <ComposerPendingUserInputPanel | 5032 | <ComposerPendingUserInputPanel |
| 4927 | pendingUserInputs={pendingUserInputs} | 5033 | pendingUserInputs={pendingUserInputs} |
| 4928 | respondingRequestIds={respondingRequestIds} | 5034 | respondingRequestIds={ |
| 5035 | activePendingIsResponding && activePendingUserInput | ||
| 5036 | ? [...respondingRequestIds, activePendingUserInput.requestId] | ||
| 5037 | : respondingRequestIds | ||
| 5038 | } | ||
| 4929 | answers={activePendingDraftAnswers} | 5039 | answers={activePendingDraftAnswers} |
| 4930 | questionIndex={activePendingQuestionIndex} | 5040 | questionIndex={activePendingQuestionIndex} |
| 4931 | onToggleOption={onSelectActivePendingUserInputOption} | 5041 | onToggleOption={onSelectActivePendingUserInputOption} |
| ... | @@ -5148,7 +5258,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -5148,7 +5258,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 5148 | retryAttachmentUpload({ | 5258 | retryAttachmentUpload({ |
| 5149 | environmentId, | 5259 | environmentId, |
| 5150 | image, | 5260 | image, |
| 5151 | draftTarget: composerDraftTarget, | 5261 | draftTarget: attachmentDraftTarget, |
| 5152 | }), | 5262 | }), |
| 5153 | } | 5263 | } |
| 5154 | : {})} | 5264 | : {})} |
| ... | @@ -5192,7 +5302,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -5192,7 +5302,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 5192 | 5302 | ||
| 5193 | {!isComposerCollapsedMobile && | 5303 | {!isComposerCollapsedMobile && |
| 5194 | !isComposerApprovalState && | 5304 | !isComposerApprovalState && |
| 5195 | pendingUserInputs.length === 0 && | ||
| 5196 | (uncommittedSnapShotIds.length > 0 || | 5305 | (uncommittedSnapShotIds.length > 0 || |
| 5197 | composerVideos.length > 0 || | 5306 | composerVideos.length > 0 || |
| 5198 | expandedComposerImages.length > 0) && ( | 5307 | expandedComposerImages.length > 0) && ( |
| ... | @@ -5310,7 +5419,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -5310,7 +5419,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 5310 | retryAttachmentUpload({ | 5419 | retryAttachmentUpload({ |
| 5311 | environmentId, | 5420 | environmentId, |
| 5312 | image, | 5421 | image, |
| 5313 | draftTarget: composerDraftTarget, | 5422 | draftTarget: attachmentDraftTarget, |
| 5314 | }) | 5423 | }) |
| 5315 | } | 5424 | } |
| 5316 | aria-label={`Retry upload for ${image.name}`} | 5425 | aria-label={`Retry upload for ${image.name}`} |
| ... | @@ -5410,7 +5519,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -5410,7 +5519,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 5410 | retryAttachmentUpload({ | 5519 | retryAttachmentUpload({ |
| 5411 | environmentId, | 5520 | environmentId, |
| 5412 | image: file, | 5521 | image: file, |
| 5413 | draftTarget: composerDraftTarget, | 5522 | draftTarget: attachmentDraftTarget, |
| 5414 | }) | 5523 | }) |
| 5415 | } | 5524 | } |
| 5416 | aria-label={`Retry upload for ${file.name}`} | 5525 | aria-label={`Retry upload for ${file.name}`} |
| ... | @@ -5444,7 +5553,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -5444,7 +5553,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 5444 | 5553 | ||
| 5445 | {!isComposerCollapsedMobile && | 5554 | {!isComposerCollapsedMobile && |
| 5446 | !isComposerApprovalState && | 5555 | !isComposerApprovalState && |
| 5447 | pendingUserInputs.length === 0 && | ||
| 5448 | composerOtherFiles.length > 0 && ( | 5556 | composerOtherFiles.length > 0 && ( |
| 5449 | <div className="mb-3 flex flex-col gap-1"> | 5557 | <div className="mb-3 flex flex-col gap-1"> |
| 5450 | {composerOtherFiles.map((file) => { | 5558 | {composerOtherFiles.map((file) => { |
| ... | @@ -5487,7 +5595,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -5487,7 +5595,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 5487 | retryAttachmentUpload({ | 5595 | retryAttachmentUpload({ |
| 5488 | environmentId, | 5596 | environmentId, |
| 5489 | image: file, | 5597 | image: file, |
| 5490 | draftTarget: composerDraftTarget, | 5598 | draftTarget: attachmentDraftTarget, |
| 5491 | }) | 5599 | }) |
| 5492 | } | 5600 | } |
| 5493 | aria-label={`Retry upload for ${file.name}`} | 5601 | aria-label={`Retry upload for ${file.name}`} |
| ... | @@ -5587,7 +5695,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -5587,7 +5695,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 5587 | isConnecting || | 5695 | isConnecting || |
| 5588 | isComposerApprovalState || | 5696 | isComposerApprovalState || |
| 5589 | projectSelectionRequired || | 5697 | projectSelectionRequired || |
| 5590 | isChoiceOnlyPendingQuestion | 5698 | isChoiceOnlyPendingQuestion || |
| 5699 | activePendingIsResponding | ||
| 5591 | } | 5700 | } |
| 5592 | /> | 5701 | /> |
| 5593 | {isComposerResting ? collapsedComposerImagePreviews : null} | 5702 | {isComposerResting ? collapsedComposerImagePreviews : null} |
apps/web/src/components/chat/MessagesTimeline.test.tsx+76-1| ... | @@ -1,4 +1,10 @@ | ... | @@ -1,4 +1,10 @@ |
| 1 | import { CheckpointRef, EnvironmentId, MessageId, TurnId } from "@t3tools/contracts"; | 1 | import { |
| 2 | ApprovalRequestId, | ||
| 3 | CheckpointRef, | ||
| 4 | EnvironmentId, | ||
| 5 | MessageId, | ||
| 6 | TurnId, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 2 | import { act, createRef, useLayoutEffect, type ReactNode, type Ref } from "react"; | 8 | import { act, createRef, useLayoutEffect, type ReactNode, type Ref } from "react"; |
| 3 | import { renderToStaticMarkup } from "react-dom/server"; | 9 | import { renderToStaticMarkup } from "react-dom/server"; |
| 4 | import { create, type ReactTestRenderer } from "react-test-renderer"; | 10 | import { create, type ReactTestRenderer } from "react-test-renderer"; |
| ... | @@ -266,6 +272,75 @@ function buildSnapShotTimelineEntry(previewUrl?: string) { | ... | @@ -266,6 +272,75 @@ function buildSnapShotTimelineEntry(previewUrl?: string) { |
| 266 | } | 272 | } |
| 267 | 273 | ||
| 268 | describe("MessagesTimeline", () => { | 274 | describe("MessagesTimeline", () => { |
| 275 | // Expanding history uses this suite's existing test renderer, deprecated in | ||
| 276 | // React 19. Migrate these interaction tests together when a DOM test setup is added. | ||
| 277 | it.each([{}, { text: "Text-only answer", file: "Answer with a file" }])( | ||
| 278 | "renders attachment-only question history alongside text answers: %j", | ||
| 279 | async (answers) => { | ||
| 280 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | ||
| 281 | vi.stubGlobal("requestAnimationFrame", () => 0); | ||
| 282 | vi.stubGlobal("cancelAnimationFrame", () => {}); | ||
| 283 | let renderer: ReactTestRenderer | undefined; | ||
| 284 | try { | ||
| 285 | await act(() => { | ||
| 286 | renderer = create( | ||
| 287 | <MessagesTimeline | ||
| 288 | {...buildProps()} | ||
| 289 | timelineEntries={[ | ||
| 290 | { | ||
| 291 | id: "answer-entry", | ||
| 292 | kind: "work", | ||
| 293 | createdAt: MESSAGE_CREATED_AT, | ||
| 294 | entry: { | ||
| 295 | id: "answer-work", | ||
| 296 | createdAt: MESSAGE_CREATED_AT, | ||
| 297 | label: "Question answer submitted", | ||
| 298 | tone: "info", | ||
| 299 | questionAnswer: { | ||
| 300 | requestId: ApprovalRequestId.make("question-request"), | ||
| 301 | answers, | ||
| 302 | questionTextById: { file: "Provide a spec", image: "Provide a screenshot" }, | ||
| 303 | attachmentsByQuestionId: { | ||
| 304 | file: [ | ||
| 305 | { | ||
| 306 | type: "file", | ||
| 307 | id: "spec", | ||
| 308 | name: "spec.txt", | ||
| 309 | mimeType: "text/plain", | ||
| 310 | sizeBytes: 4, | ||
| 311 | }, | ||
| 312 | ], | ||
| 313 | image: [ | ||
| 314 | { | ||
| 315 | type: "image", | ||
| 316 | id: "shot", | ||
| 317 | name: "shot.png", | ||
| 318 | mimeType: "image/png", | ||
| 319 | sizeBytes: 4, | ||
| 320 | }, | ||
| 321 | ], | ||
| 322 | }, | ||
| 323 | }, | ||
| 324 | }, | ||
| 325 | }, | ||
| 326 | ]} | ||
| 327 | />, | ||
| 328 | ); | ||
| 329 | }); | ||
| 330 | const toggle = renderer!.root.findByProps({ "aria-expanded": false }); | ||
| 331 | await act(() => toggle.props.onClick()); | ||
| 332 | const markup = JSON.stringify(renderer!.toJSON()); | ||
| 333 | expect(markup.match(/Provide a spec/g)).toHaveLength(1); | ||
| 334 | expect(markup.match(/spec\.txt/g)).toHaveLength(1); | ||
| 335 | expect(markup).toContain("Provide a screenshot"); | ||
| 336 | expect(markup).toContain("shot.png"); | ||
| 337 | for (const answer of Object.values(answers)) expect(markup).toContain(answer); | ||
| 338 | } finally { | ||
| 339 | await act(() => renderer?.unmount()); | ||
| 340 | } | ||
| 341 | }, | ||
| 342 | ); | ||
| 343 | |||
| 269 | it.each([ | 344 | it.each([ |
| 270 | { toolLifecycleStatus: "inProgress", isAtEnd: true }, | 345 | { toolLifecycleStatus: "inProgress", isAtEnd: true }, |
| 271 | { toolLifecycleStatus: "inProgress", isAtEnd: false }, | 346 | { toolLifecycleStatus: "inProgress", isAtEnd: false }, |
apps/web/src/components/chat/MessagesTimeline.tsx+70-1| ... | @@ -3216,7 +3216,9 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { | ... | @@ -3216,7 +3216,9 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { |
| 3216 | showWarningIndicator || showDestructiveRowStyle | 3216 | showWarningIndicator || showDestructiveRowStyle |
| 3217 | ? undefined | 3217 | ? undefined |
| 3218 | : (workEntry.toolIcon ?? workEntry.toolSource?.icon); | 3218 | : (workEntry.toolIcon ?? workEntry.toolSource?.icon); |
| 3219 | const previewText = displayLabel ?? workEntryDisplayLabel(workEntry, workspaceRoot); | 3219 | const previewText = workEntry.questionAnswer |
| 3220 | ? "Question answer submitted" | ||
| 3221 | : (displayLabel ?? workEntryDisplayLabel(workEntry, workspaceRoot)); | ||
| 3220 | const viewedImagePath = workEntryViewedImagePath(workEntry); | 3222 | const viewedImagePath = workEntryViewedImagePath(workEntry); |
| 3221 | const viewedImage = | 3223 | const viewedImage = |
| 3222 | viewedImagePath && threadRef | 3224 | viewedImagePath && threadRef |
| ... | @@ -3362,6 +3364,9 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { | ... | @@ -3362,6 +3364,9 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { |
| 3362 | /> | 3364 | /> |
| 3363 | </div> | 3365 | </div> |
| 3364 | ) : null} | 3366 | ) : null} |
| 3367 | {workEntry.questionAnswer ? ( | ||
| 3368 | <QuestionAnswerHistory answer={workEntry.questionAnswer} /> | ||
| 3369 | ) : null} | ||
| 3365 | {expanded && canExpand && expandedBody ? ( | 3370 | {expanded && canExpand && expandedBody ? ( |
| 3366 | <div | 3371 | <div |
| 3367 | className="mt-1 ms-7 cursor-default rounded-md bg-muted/40 px-3 py-2" | 3372 | className="mt-1 ms-7 cursor-default rounded-md bg-muted/40 px-3 py-2" |
| ... | @@ -3374,3 +3379,67 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { | ... | @@ -3374,3 +3379,67 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { |
| 3374 | </div> | 3379 | </div> |
| 3375 | ); | 3380 | ); |
| 3376 | }); | 3381 | }); |
| 3382 | |||
| 3383 | function QuestionAnswerHistory({ | ||
| 3384 | answer, | ||
| 3385 | }: { | ||
| 3386 | answer: import("@t3tools/contracts").UserInputAttachmentAnswerPayload; | ||
| 3387 | }) { | ||
| 3388 | const { activeThreadEnvironmentId } = use(TimelineRowCtx); | ||
| 3389 | const attachments = useMemo(() => Object.values(answer.attachmentsByQuestionId).flat(), [answer]); | ||
| 3390 | const resources = useMemo( | ||
| 3391 | () => | ||
| 3392 | attachments.map((attachment) => ({ | ||
| 3393 | _tag: "attachment" as const, | ||
| 3394 | attachmentId: attachment.id, | ||
| 3395 | })), | ||
| 3396 | [attachments], | ||
| 3397 | ); | ||
| 3398 | const urls = useAssetUrls(activeThreadEnvironmentId, resources); | ||
| 3399 | return ( | ||
| 3400 | <div className="ms-7 mt-2 space-y-2" onClick={stopRowToggle}> | ||
| 3401 | {[ | ||
| 3402 | ...new Set([ | ||
| 3403 | ...Object.keys(answer.answers), | ||
| 3404 | ...Object.keys(answer.attachmentsByQuestionId), | ||
| 3405 | ]), | ||
| 3406 | ].map((questionId) => ( | ||
| 3407 | <div key={questionId} className="space-y-1"> | ||
| 3408 | {answer.questionTextById?.[questionId] ? ( | ||
| 3409 | <p className="text-sm text-muted-foreground">{answer.questionTextById[questionId]}</p> | ||
| 3410 | ) : null} | ||
| 3411 | <p className="whitespace-pre-wrap text-sm"> | ||
| 3412 | {[answer.answers[questionId]] | ||
| 3413 | .flat() | ||
| 3414 | .filter((value): value is string => typeof value === "string") | ||
| 3415 | .join(", ")} | ||
| 3416 | </p> | ||
| 3417 | <div className="flex flex-wrap gap-2"> | ||
| 3418 | {(answer.attachmentsByQuestionId[questionId] ?? []).map((attachment) => { | ||
| 3419 | const url = urls[attachments.indexOf(attachment)]; | ||
| 3420 | return ( | ||
| 3421 | <a | ||
| 3422 | key={attachment.id} | ||
| 3423 | href={url ?? undefined} | ||
| 3424 | target="_blank" | ||
| 3425 | rel="noreferrer" | ||
| 3426 | className="text-sm underline" | ||
| 3427 | > | ||
| 3428 | {attachment.type === "image" && url ? ( | ||
| 3429 | <img | ||
| 3430 | src={url} | ||
| 3431 | alt={attachment.name} | ||
| 3432 | className="h-20 max-w-32 rounded object-contain" | ||
| 3433 | /> | ||
| 3434 | ) : ( | ||
| 3435 | attachment.name | ||
| 3436 | )} | ||
| 3437 | </a> | ||
| 3438 | ); | ||
| 3439 | })} | ||
| 3440 | </div> | ||
| 3441 | </div> | ||
| 3442 | ))} | ||
| 3443 | </div> | ||
| 3444 | ); | ||
| 3445 | } |
apps/web/src/lib/attachmentUploadQueue.test.ts+26| ... | @@ -339,6 +339,32 @@ describe("attachmentUploadQueue", () => { | ... | @@ -339,6 +339,32 @@ describe("attachmentUploadQueue", () => { |
| 339 | } | 339 | } |
| 340 | }); | 340 | }); |
| 341 | 341 | ||
| 342 | it("persists a retried question upload without a mounted composer", async () => { | ||
| 343 | const draftId = DraftId.make("question-retry-upload"); | ||
| 344 | const file = makeFile("question-retry"); | ||
| 345 | const store = useComposerDraftStore.getState(); | ||
| 346 | store.addFiles(draftId, [file]); | ||
| 347 | try { | ||
| 348 | startAttachmentUpload({ environmentId: firstEnvironment, image: file, draftTarget: draftId }); | ||
| 349 | await Promise.resolve(); | ||
| 350 | let settled = awaitAttachmentUploads([file.id]); | ||
| 351 | TestXmlHttpRequest.requests[0]!.complete(500); | ||
| 352 | await settled; | ||
| 353 | expect(store.getComposerDraft(draftId)?.files[0]?.uploadedAttachmentId).toBeUndefined(); | ||
| 354 | retryAttachmentUpload({ environmentId: firstEnvironment, image: file, draftTarget: draftId }); | ||
| 355 | await Promise.resolve(); | ||
| 356 | settled = awaitAttachmentUploads([file.id]); | ||
| 357 | TestXmlHttpRequest.requests[1]!.complete(); | ||
| 358 | await settled; | ||
| 359 | expect(store.getComposerDraft(draftId)?.files[0]).toMatchObject({ | ||
| 360 | uploadedAttachmentId: "pending-environment-1-question-retry.pdf", | ||
| 361 | uploadEnvironmentId: firstEnvironment, | ||
| 362 | }); | ||
| 363 | } finally { | ||
| 364 | store.clearComposerContent(draftId); | ||
| 365 | } | ||
| 366 | }); | ||
| 367 | |||
| 342 | it("verifies an uploaded file reference before restoring it", async () => { | 368 | it("verifies an uploaded file reference before restoring it", async () => { |
| 343 | const file: ComposerFileAttachment = { | 369 | const file: ComposerFileAttachment = { |
| 344 | ...makeFile("restored"), | 370 | ...makeFile("restored"), |
apps/web/src/pendingUserInput.test.ts+19| ... | @@ -302,3 +302,22 @@ describe("pending user input question progress", () => { | ... | @@ -302,3 +302,22 @@ describe("pending user input question progress", () => { |
| 302 | }); | 302 | }); |
| 303 | }); | 303 | }); |
| 304 | }); | 304 | }); |
| 305 | |||
| 306 | it("accepts attachment-only answers after every upload finishes", () => { | ||
| 307 | const questions = [ | ||
| 308 | { id: "spec", header: "Spec", question: "Provide a spec", options: [], multiSelect: false }, | ||
| 309 | ]; | ||
| 310 | expect(buildPendingUserInputAnswers(questions, { spec: { attachmentCount: 1 } })).toEqual({ | ||
| 311 | spec: "", | ||
| 312 | }); | ||
| 313 | expect( | ||
| 314 | buildPendingUserInputAnswers(questions, { | ||
| 315 | spec: { attachmentCount: 1, attachmentsBlocked: true }, | ||
| 316 | }), | ||
| 317 | ).toBeNull(); | ||
| 318 | expect( | ||
| 319 | buildPendingUserInputAnswers([{ ...questions[0]!, allowCustomAnswer: false }], { | ||
| 320 | spec: { attachmentCount: 1 }, | ||
| 321 | }), | ||
| 322 | ).toBeNull(); | ||
| 323 | }); |
apps/web/src/pendingUserInput.ts+12-2| ... | @@ -3,6 +3,8 @@ import type { UserInputQuestion } from "@t3tools/contracts"; | ... | @@ -3,6 +3,8 @@ import type { UserInputQuestion } from "@t3tools/contracts"; |
| 3 | export interface PendingUserInputDraftAnswer { | 3 | export interface PendingUserInputDraftAnswer { |
| 4 | selectedOptionValues?: string[]; | 4 | selectedOptionValues?: string[]; |
| 5 | customAnswer?: string; | 5 | customAnswer?: string; |
| 6 | attachmentCount?: number; | ||
| 7 | attachmentsBlocked?: boolean; | ||
| 6 | } | 8 | } |
| 7 | 9 | ||
| 8 | export interface PendingUserInputProgress { | 10 | export interface PendingUserInputProgress { |
| ... | @@ -41,6 +43,7 @@ export function resolvePendingUserInputAnswer( | ... | @@ -41,6 +43,7 @@ export function resolvePendingUserInputAnswer( |
| 41 | question: UserInputQuestion, | 43 | question: UserInputQuestion, |
| 42 | draft: PendingUserInputDraftAnswer | undefined, | 44 | draft: PendingUserInputDraftAnswer | undefined, |
| 43 | ): string | string[] | null { | 45 | ): string | string[] | null { |
| 46 | if (draft?.attachmentsBlocked) return null; | ||
| 44 | const customAnswer = | 47 | const customAnswer = |
| 45 | question.allowCustomAnswer === false ? null : normalizeDraftAnswer(draft?.customAnswer); | 48 | question.allowCustomAnswer === false ? null : normalizeDraftAnswer(draft?.customAnswer); |
| 46 | if (customAnswer) { | 49 | if (customAnswer) { |
| ... | @@ -51,10 +54,17 @@ export function resolvePendingUserInputAnswer( | ... | @@ -51,10 +54,17 @@ export function resolvePendingUserInputAnswer( |
| 51 | (value) => question.options.some((option) => (option.value ?? option.label) === value), | 54 | (value) => question.options.some((option) => (option.value ?? option.label) === value), |
| 52 | ); | 55 | ); |
| 53 | if (question.multiSelect) { | 56 | if (question.multiSelect) { |
| 54 | return selectedOptionValues.length > 0 ? selectedOptionValues : null; | 57 | return selectedOptionValues.length > 0 |
| 58 | ? selectedOptionValues | ||
| 59 | : question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 | ||
| 60 | ? "" | ||
| 61 | : null; | ||
| 55 | } | 62 | } |
| 56 | 63 | ||
| 57 | return selectedOptionValues[0] ?? null; | 64 | return ( |
| 65 | selectedOptionValues[0] ?? | ||
| 66 | (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null) | ||
| 67 | ); | ||
| 58 | } | 68 | } |
| 59 | 69 | ||
| 60 | export function setPendingUserInputCustomAnswer( | 70 | export function setPendingUserInputCustomAnswer( |
apps/web/src/questionAttachments.test.ts created+100| ... | @@ -0,0 +1,100 @@ | ||
| 1 | import { ApprovalRequestId, EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| 2 | import { beforeEach, expect, it, vi } from "vite-plus/test"; | ||
| 3 | import { useComposerDraftStore } from "./composerDraftStore"; | ||
| 4 | import { | ||
| 5 | questionAttachmentDraftId, | ||
| 6 | questionAttachmentDraftPrefix, | ||
| 7 | changeQuestionAttachmentPreparation, | ||
| 8 | clearQuestionAttachmentDraft, | ||
| 9 | useQuestionAttachmentPreparation, | ||
| 10 | } from "./questionAttachments"; | ||
| 11 | |||
| 12 | const release = vi.hoisted(() => vi.fn()); | ||
| 13 | vi.mock("./lib/attachmentUploadQueue", () => ({ releaseDraftAttachments: release })); | ||
| 14 | const environmentId = EnvironmentId.make("environment-1"); | ||
| 15 | const threadId = ThreadId.make("thread-1"); | ||
| 16 | const requestId = ApprovalRequestId.make("request-1"); | ||
| 17 | beforeEach(() => { | ||
| 18 | useComposerDraftStore.setState({ draftsByThreadKey: {}, draftThreadsByThreadKey: {} }); | ||
| 19 | useQuestionAttachmentPreparation.setState({ counts: {} }); | ||
| 20 | release.mockClear(); | ||
| 21 | }); | ||
| 22 | it("keeps question files separate from the normal draft and other questions", () => { | ||
| 23 | const revoke = vi.spyOn(URL, "revokeObjectURL"); | ||
| 24 | const store = useComposerDraftStore.getState(); | ||
| 25 | const first = questionAttachmentDraftId(environmentId, threadId, requestId, "first"); | ||
| 26 | const second = questionAttachmentDraftId(environmentId, threadId, requestId, "second"); | ||
| 27 | store.setPrompt({ environmentId, threadId }, "Unsent prompt"); | ||
| 28 | const image = { | ||
| 29 | type: "image" as const, | ||
| 30 | id: "image-1", | ||
| 31 | name: "image.png", | ||
| 32 | mimeType: "image/png", | ||
| 33 | sizeBytes: 5, | ||
| 34 | previewUrl: "blob:question-image", | ||
| 35 | file: new File(["image"], "image.png", { type: "image/png" }), | ||
| 36 | }; | ||
| 37 | store.addImages(first, [image]); | ||
| 38 | store.addFiles(second, [ | ||
| 39 | { | ||
| 40 | type: "file", | ||
| 41 | id: "file-1", | ||
| 42 | name: "spec.txt", | ||
| 43 | mimeType: "text/plain", | ||
| 44 | sizeBytes: 4, | ||
| 45 | file: new File(["spec"], "spec.txt", { type: "text/plain" }), | ||
| 46 | }, | ||
| 47 | ]); | ||
| 48 | changeQuestionAttachmentPreparation(first, 1); | ||
| 49 | expect(store.getComposerDraft(first)?.images).toHaveLength(1); | ||
| 50 | expect(store.getComposerDraft(second)?.files).toHaveLength(1); | ||
| 51 | clearQuestionAttachmentDraft(first); | ||
| 52 | expect(store.getComposerDraft(first)).toBeNull(); | ||
| 53 | expect(store.getComposerDraft(second)?.files).toHaveLength(1); | ||
| 54 | expect(store.getComposerDraft({ environmentId, threadId })?.prompt).toBe("Unsent prompt"); | ||
| 55 | expect(useQuestionAttachmentPreparation.getState().counts[first]).toBeUndefined(); | ||
| 56 | expect(release).toHaveBeenCalledWith([image]); | ||
| 57 | expect(revoke).toHaveBeenCalledWith(image.previewUrl); | ||
| 58 | revoke.mockRestore(); | ||
| 59 | }); | ||
| 60 | it("scopes provider request ids to their environment and thread", () => { | ||
| 61 | const keys = [ | ||
| 62 | questionAttachmentDraftId(environmentId, threadId, requestId, "q"), | ||
| 63 | questionAttachmentDraftId(EnvironmentId.make("environment-2"), threadId, requestId, "q"), | ||
| 64 | questionAttachmentDraftId(environmentId, ThreadId.make("thread-2"), requestId, "q"), | ||
| 65 | ]; | ||
| 66 | expect(new Set(keys).size).toBe(3); | ||
| 67 | }); | ||
| 68 | it("does not match drafts belonging to threads with a shared id prefix", () => { | ||
| 69 | const prefix = questionAttachmentDraftPrefix(environmentId, threadId); | ||
| 70 | expect( | ||
| 71 | questionAttachmentDraftId(environmentId, threadId, requestId, "q").startsWith(prefix), | ||
| 72 | ).toBe(true); | ||
| 73 | for (const suffix of ["-extra", ":extra", "/extra", "%extra"]) { | ||
| 74 | expect( | ||
| 75 | questionAttachmentDraftId( | ||
| 76 | environmentId, | ||
| 77 | ThreadId.make(`${threadId}${suffix}`), | ||
| 78 | requestId, | ||
| 79 | "q", | ||
| 80 | ).startsWith(prefix), | ||
| 81 | ).toBe(false); | ||
| 82 | } | ||
| 83 | }); | ||
| 84 | |||
| 85 | it("does not clear another environment whose id contains a question prefix", () => { | ||
| 86 | const otherEnvironment = EnvironmentId.make( | ||
| 87 | `${environmentId}:question-${encodeURIComponent(JSON.stringify(threadId))}-nested`, | ||
| 88 | ); | ||
| 89 | const otherKey = questionAttachmentDraftId(otherEnvironment, threadId, requestId, "q"); | ||
| 90 | const ownKey = questionAttachmentDraftId(environmentId, threadId, requestId, "q"); | ||
| 91 | const store = useComposerDraftStore.getState(); | ||
| 92 | store.setPrompt(otherKey, "Keep this answer"); | ||
| 93 | store.setPrompt(ownKey, "Discard this answer"); | ||
| 94 | const prefix = questionAttachmentDraftPrefix(environmentId, threadId); | ||
| 95 | for (const key of [ownKey, otherKey]) { | ||
| 96 | if (key.startsWith(prefix)) clearQuestionAttachmentDraft(key); | ||
| 97 | } | ||
| 98 | expect(store.getComposerDraft(ownKey)).toBeNull(); | ||
| 99 | expect(store.getComposerDraft(otherKey)?.prompt).toBe("Keep this answer"); | ||
| 100 | }); | ||
apps/web/src/questionAttachments.ts created+53| ... | @@ -0,0 +1,53 @@ | ||
| 1 | import type { ApprovalRequestId, EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| 2 | import { create } from "zustand"; | ||
| 3 | import { DraftId, useComposerDraftStore } from "./composerDraftStore"; | ||
| 4 | import { releaseDraftAttachments } from "./lib/attachmentUploadQueue"; | ||
| 5 | |||
| 6 | export function questionAttachmentDraftPrefix( | ||
| 7 | environmentId: EnvironmentId, | ||
| 8 | threadId: ThreadId, | ||
| 9 | ): string { | ||
| 10 | return `${encodeURIComponent(JSON.stringify(environmentId))}:question-${encodeURIComponent(JSON.stringify(threadId))}-`; | ||
| 11 | } | ||
| 12 | |||
| 13 | export function questionAttachmentDraftId( | ||
| 14 | environmentId: EnvironmentId, | ||
| 15 | threadId: ThreadId, | ||
| 16 | requestId: ApprovalRequestId, | ||
| 17 | questionId: string, | ||
| 18 | ): DraftId { | ||
| 19 | return DraftId.make( | ||
| 20 | `${questionAttachmentDraftPrefix(environmentId, threadId)}${encodeURIComponent(JSON.stringify([requestId, questionId]))}`, | ||
| 21 | ); | ||
| 22 | } | ||
| 23 | |||
| 24 | export const useQuestionAttachmentPreparation = create<{ counts: Record<string, number> }>(() => ({ | ||
| 25 | counts: {}, | ||
| 26 | })); | ||
| 27 | |||
| 28 | export function changeQuestionAttachmentPreparation(key: DraftId, delta: number): void { | ||
| 29 | useQuestionAttachmentPreparation.setState((state) => | ||
| 30 | delta < 0 && !(key in state.counts) | ||
| 31 | ? state | ||
| 32 | : { | ||
| 33 | counts: { ...state.counts, [key]: Math.max(0, (state.counts[key] ?? 0) + delta) }, | ||
| 34 | }, | ||
| 35 | ); | ||
| 36 | } | ||
| 37 | |||
| 38 | export function clearQuestionAttachmentDraft(key: DraftId): void { | ||
| 39 | const store = useComposerDraftStore.getState(); | ||
| 40 | const draft = store.getComposerDraft(key); | ||
| 41 | if (draft) { | ||
| 42 | releaseDraftAttachments([...draft.images, ...draft.files]); | ||
| 43 | for (const image of draft.images) { | ||
| 44 | if (image.previewUrl.startsWith("blob:")) URL.revokeObjectURL(image.previewUrl); | ||
| 45 | } | ||
| 46 | } | ||
| 47 | store.clearComposerContent(key); | ||
| 48 | useQuestionAttachmentPreparation.setState(({ counts }) => { | ||
| 49 | const next = { ...counts }; | ||
| 50 | delete next[key]; | ||
| 51 | return { counts: next }; | ||
| 52 | }); | ||
| 53 | } | ||
apps/web/src/session-logic.ts+9| ... | @@ -2,7 +2,9 @@ import { | ... | @@ -2,7 +2,9 @@ import { |
| 2 | requestKindFromRequestType, | 2 | requestKindFromRequestType, |
| 3 | type PendingApproval, | 3 | type PendingApproval, |
| 4 | } from "@t3tools/client-runtime/pending-requests"; | 4 | } from "@t3tools/client-runtime/pending-requests"; |
| 5 | import { UserInputAttachmentAnswerPayload } from "@t3tools/contracts"; | ||
| 5 | import * as Option from "effect/Option"; | 6 | import * as Option from "effect/Option"; |
| 7 | import * as Schema from "effect/Schema"; | ||
| 6 | import * as Arr from "effect/Array"; | 8 | import * as Arr from "effect/Array"; |
| 7 | import { shallow } from "zustand/vanilla/shallow"; | 9 | import { shallow } from "zustand/vanilla/shallow"; |
| 8 | import { isBackgroundTaskActivity } from "@t3tools/client-runtime/state/subagentRuntime"; | 10 | import { isBackgroundTaskActivity } from "@t3tools/client-runtime/state/subagentRuntime"; |
| ... | @@ -51,6 +53,7 @@ export { | ... | @@ -51,6 +53,7 @@ export { |
| 51 | } from "@t3tools/client-runtime/work-log/presentation"; | 53 | } from "@t3tools/client-runtime/work-log/presentation"; |
| 52 | 54 | ||
| 53 | export interface WorkLogEntry { | 55 | export interface WorkLogEntry { |
| 56 | questionAnswer?: UserInputAttachmentAnswerPayload; | ||
| 54 | id: string; | 57 | id: string; |
| 55 | createdAt: string; | 58 | createdAt: string; |
| 56 | turnId?: TurnId | null; | 59 | turnId?: TurnId | null; |
| ... | @@ -492,6 +495,8 @@ function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): bool | ... | @@ -492,6 +495,8 @@ function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): bool |
| 492 | return typeof payload?.detail === "string" && payload.detail.startsWith("ExitPlanMode:"); | 495 | return typeof payload?.detail === "string" && payload.detail.startsWith("ExitPlanMode:"); |
| 493 | } | 496 | } |
| 494 | 497 | ||
| 498 | const decodeQuestionAttachmentAnswer = Schema.decodeUnknownOption(UserInputAttachmentAnswerPayload); | ||
| 499 | |||
| 495 | function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWorkLogEntry { | 500 | function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWorkLogEntry { |
| 496 | const cachedEntry = derivedWorkLogEntryByActivity.get(activity); | 501 | const cachedEntry = derivedWorkLogEntryByActivity.get(activity); |
| 497 | if (cachedEntry) { | 502 | if (cachedEntry) { |
| ... | @@ -543,6 +548,10 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo | ... | @@ -543,6 +548,10 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo |
| 543 | : activity.tone, | 548 | : activity.tone, |
| 544 | sourceActivityKind: activity.kind, | 549 | sourceActivityKind: activity.kind, |
| 545 | }; | 550 | }; |
| 551 | if (activity.kind === "user-input.answer-submitted") { | ||
| 552 | const answer = decodeQuestionAttachmentAnswer(payload); | ||
| 553 | if (Option.isSome(answer)) entry.questionAnswer = answer.value; | ||
| 554 | } | ||
| 546 | const itemType = extractWorkLogItemType(payload); | 555 | const itemType = extractWorkLogItemType(payload); |
| 547 | const requestKind = extractWorkLogRequestKind(payload); | 556 | const requestKind = extractWorkLogRequestKind(payload); |
| 548 | const viewedImagePath = asTrimmedString(asRecord(payload?.data)?.imagePath); | 557 | const viewedImagePath = asTrimmedString(asRecord(payload?.data)?.imagePath); |
docs/user/question-attachments.md created+9| ... | @@ -0,0 +1,9 @@ | ||
| 1 | # Files in question answers | ||
| 2 | |||
| 3 | When an agent asks a question that accepts a custom answer, use **Attach files** or paste an image into the answer field. On mobile, use the attachment button to choose photos or files. You can send a file by itself, with a selected option, or with a typed answer. | ||
| 4 | |||
| 5 | Each question keeps its own attachments as you move between questions. Your normal prompt draft stays separate. Wait for uploads to finish before submitting; retry or remove any failed upload. A failed response keeps the draft available to try again. | ||
| 6 | |||
| 7 | Files upload to the environment running the thread, including remote environments. The agent receives paths to those saved files with your answer and can open them with its available tools. Questions that only accept predefined choices do not offer attachments. Older servers need updating before this option appears. | ||
| 8 | |||
| 9 | Submitted attachments remain in the thread history. Reverting a thread removes attachments belonging to discarded answers; deleting the thread uses the same file cleanup as other attachments. You can attach up to eight files across one set of answers, using the same file size limits as the normal composer. | ||
packages/contracts/src/environment.ts+2| ... | @@ -80,6 +80,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ | ... | @@ -80,6 +80,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ |
| 80 | connectionProbe: Schema.optionalKey(Schema.Boolean), | 80 | connectionProbe: Schema.optionalKey(Schema.Boolean), |
| 81 | /** Missing on older servers, which still accept inline image attachments. */ | 81 | /** Missing on older servers, which still accept inline image attachments. */ |
| 82 | attachmentUploads: Schema.optionalKey(Schema.Boolean), | 82 | attachmentUploads: Schema.optionalKey(Schema.Boolean), |
| 83 | /** Uploaded files may accompany question answers. */ | ||
| 84 | questionAttachments: Schema.optionalKey(Schema.Boolean), | ||
| 83 | /** Missing on servers that only accept image attachments. */ | 85 | /** Missing on servers that only accept image attachments. */ |
| 84 | fileAttachments: Schema.optionalKey( | 86 | fileAttachments: Schema.optionalKey( |
| 85 | Schema.Struct({ | 87 | Schema.Struct({ |
packages/contracts/src/orchestration.ts+18| ... | @@ -354,6 +354,22 @@ export const ChatAttachment = Schema.Union([ | ... | @@ -354,6 +354,22 @@ export const ChatAttachment = Schema.Union([ |
| 354 | ChatUnknownAttachment, | 354 | ChatUnknownAttachment, |
| 355 | ]); | 355 | ]); |
| 356 | export type ChatAttachment = typeof ChatAttachment.Type; | 356 | export type ChatAttachment = typeof ChatAttachment.Type; |
| 357 | |||
| 358 | export const UserInputAttachments = Schema.Record( | ||
| 359 | Schema.String, | ||
| 360 | Schema.Array(Schema.Union([ChatImageAttachment, ChatFileAttachment])).pipe( | ||
| 361 | Schema.check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)), | ||
| 362 | ), | ||
| 363 | ); | ||
| 364 | export type UserInputAttachments = typeof UserInputAttachments.Type; | ||
| 365 | |||
| 366 | export const UserInputAttachmentAnswerPayload = Schema.Struct({ | ||
| 367 | requestId: ApprovalRequestId, | ||
| 368 | questionTextById: Schema.optional(Schema.Record(Schema.String, Schema.String)), | ||
| 369 | answers: ProviderUserInputAnswers, | ||
| 370 | attachmentsByQuestionId: UserInputAttachments, | ||
| 371 | }); | ||
| 372 | export type UserInputAttachmentAnswerPayload = typeof UserInputAttachmentAnswerPayload.Type; | ||
| 357 | const UploadChatAttachment = Schema.Union([UploadChatImageAttachment]); | 373 | const UploadChatAttachment = Schema.Union([UploadChatImageAttachment]); |
| 358 | export type UploadChatAttachment = typeof UploadChatAttachment.Type; | 374 | export type UploadChatAttachment = typeof UploadChatAttachment.Type; |
| 359 | 375 | ||
| ... | @@ -1139,6 +1155,7 @@ const ThreadUserInputRespondCommand = Schema.Struct({ | ... | @@ -1139,6 +1155,7 @@ const ThreadUserInputRespondCommand = Schema.Struct({ |
| 1139 | threadId: ThreadId, | 1155 | threadId: ThreadId, |
| 1140 | requestId: ApprovalRequestId, | 1156 | requestId: ApprovalRequestId, |
| 1141 | answers: ProviderUserInputAnswers, | 1157 | answers: ProviderUserInputAnswers, |
| 1158 | attachmentsByQuestionId: Schema.optional(UserInputAttachments), | ||
| 1142 | createdAt: IsoDateTime, | 1159 | createdAt: IsoDateTime, |
| 1143 | }); | 1160 | }); |
| 1144 | 1161 | ||
| ... | @@ -1585,6 +1602,7 @@ const ThreadUserInputResponseRequestedPayload = Schema.Struct({ | ... | @@ -1585,6 +1602,7 @@ const ThreadUserInputResponseRequestedPayload = Schema.Struct({ |
| 1585 | threadId: ThreadId, | 1602 | threadId: ThreadId, |
| 1586 | requestId: ApprovalRequestId, | 1603 | requestId: ApprovalRequestId, |
| 1587 | answers: ProviderUserInputAnswers, | 1604 | answers: ProviderUserInputAnswers, |
| 1605 | attachmentsByQuestionId: Schema.optional(UserInputAttachments), | ||
| 1588 | createdAt: IsoDateTime, | 1606 | createdAt: IsoDateTime, |
| 1589 | }); | 1607 | }); |
| 1590 | 1608 |
packages/contracts/src/provider.ts+2| ... | @@ -19,6 +19,7 @@ import { | ... | @@ -19,6 +19,7 @@ import { |
| 19 | ProviderRequestKind, | 19 | ProviderRequestKind, |
| 20 | ProviderSandboxMode, | 20 | ProviderSandboxMode, |
| 21 | ProviderUserInputAnswers, | 21 | ProviderUserInputAnswers, |
| 22 | UserInputAttachments, | ||
| 22 | RuntimeMode, | 23 | RuntimeMode, |
| 23 | } from "./orchestration.ts"; | 24 | } from "./orchestration.ts"; |
| 24 | import { ProviderInstanceId, ProviderDriverKind } from "./providerInstance.ts"; | 25 | import { ProviderInstanceId, ProviderDriverKind } from "./providerInstance.ts"; |
| ... | @@ -110,6 +111,7 @@ export const ProviderRespondToUserInputInput = Schema.Struct({ | ... | @@ -110,6 +111,7 @@ export const ProviderRespondToUserInputInput = Schema.Struct({ |
| 110 | threadId: ThreadId, | 111 | threadId: ThreadId, |
| 111 | requestId: ApprovalRequestId, | 112 | requestId: ApprovalRequestId, |
| 112 | answers: ProviderUserInputAnswers, | 113 | answers: ProviderUserInputAnswers, |
| 114 | attachmentsByQuestionId: Schema.optional(UserInputAttachments), | ||
| 113 | }); | 115 | }); |
| 114 | export type ProviderRespondToUserInputInput = typeof ProviderRespondToUserInputInput.Type; | 116 | export type ProviderRespondToUserInputInput = typeof ProviderRespondToUserInputInput.Type; |
| 115 | 117 |
pnpm-lock.yaml+3| ... | @@ -473,6 +473,9 @@ importers: | ... | @@ -473,6 +473,9 @@ importers: |
| 473 | '@types/react': | 473 | '@types/react': |
| 474 | specifier: ~19.2.0 | 474 | specifier: ~19.2.0 |
| 475 | version: 19.2.16 | 475 | version: 19.2.16 |
| 476 | '@types/react-dom': | ||
| 477 | specifier: ~19.2.3 | ||
| 478 | version: 19.2.3(@types/react@19.2.16) | ||
| 476 | babel-preset-expo: | 479 | babel-preset-expo: |
| 477 | specifier: ~57.0.9 | 480 | specifier: ~57.0.9 |
| 478 | version: 57.0.9(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo-widgets@57.0.15)(expo@57.0.18)(react-refresh@0.14.2) | 481 | version: 57.0.9(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo-widgets@57.0.15)(expo@57.0.18)(react-refresh@0.14.2) |