authorgravatar for 91240327+shivamhwp@users.noreply.github.comshivam <91240327+shivamhwp@users.noreply.github.com> 2026-09-08 12:13:38+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-08 12:13:38+05:30
log7220dfe2c949476eaa7d21eccbcd3a0ce0eddb49
treeec8f6c6fba758f61a36606fa123b53837d4edd84
parent349ce3014233352e073a5fc1b3f12bd786160913
signature Signed by PGP key B5690EEEBB952194

feat(chat): attach files to question answers (#9871)


40 files changed, 2315 insertions(+), 153 deletions(-)

apps/mobile/package.json+1
......@@ -128,6 +128,7 @@
128128 "@effect/vitest": "catalog:",
129129 "@pierre/trees": "1.0.0-beta.4",
130130 "@types/react": "~19.2.0",
131 "@types/react-dom": "~19.2.3",
131132 "babel-preset-expo": "~57.0.9",
132133 "tailwindcss": "^4.0.0",
133134 "typescript": "catalog:"
apps/mobile/src/features/threads/PendingUserInputCard.tsx+13-13
......@@ -1,3 +1,4 @@
1import { QuestionAttachments } from "./QuestionAttachments";
12import type { ApprovalRequestId, UserInputQuestion } from "@t3tools/contracts";
23import { useCallback, useRef } from "react";
34import { Platform, Pressable, ScrollView, View, type LayoutChangeEvent } from "react-native";
......@@ -15,7 +16,7 @@ import Animated, {
1516import { USER_INPUT_TOGGLE_DURATION_MS } from "./pendingUserInputLayout";
1617
1718import { SymbolView } from "../../components/AppSymbol";
18import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
19import { AppText as Text } from "../../components/AppText";
1920import { ControlPill } from "../../components/ControlPill";
2021import { cn } from "../../lib/cn";
2122import {
......@@ -305,18 +306,17 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {
305306 );
306307 })}
307308 </View>
308 {question.allowCustomAnswer !== false ? (
309 <TextInput
310 value={draft?.customAnswer ?? ""}
311 onChangeText={(value) =>
312 props.onChangeCustomAnswer(props.pendingUserInput.requestId, question.id, value)
313 }
314 onFocus={() => props.onInputFocusChange?.(true)}
315 onBlur={() => props.onInputFocusChange?.(false)}
316 placeholder="Or type a custom answer"
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 />
319 ) : null}
309 <QuestionAttachments
310 requestId={props.pendingUserInput.requestId}
311 question={question}
312 questions={props.pendingUserInput.questions}
313 disabled={props.respondingUserInputId === props.pendingUserInput.requestId}
314 value={draft?.customAnswer ?? ""}
315 onChangeText={(value) =>
316 props.onChangeCustomAnswer(props.pendingUserInput.requestId, question.id, value)
317 }
318 onInputFocusChange={props.onInputFocusChange}
319 />
320320 </View>
321321 );
322322 })}
apps/mobile/src/features/threads/QuestionAnswerHistory.test.tsx created+58
......@@ -0,0 +1,58 @@
1import { ApprovalRequestId, EnvironmentId } from "@t3tools/contracts";
2import type { ReactNode } from "react";
3import { renderToStaticMarkup } from "react-dom/server";
4import { describe, expect, it, vi } from "vite-plus/test";
5
6vi.mock("react-native", () => ({
7 View: "div",
8 Pressable: ({ children }: { children: ReactNode }) => <button>{children}</button>,
9 Image: "img",
10 Linking: { openURL: vi.fn() },
11}));
12vi.mock("../../components/AppText", () => ({ AppText: "span" }));
13vi.mock("../../state/assets", () => ({ useAssetUrl: () => null }));
14
15import { QuestionAnswerHistory } from "./QuestionAnswerHistory";
16
17describe("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 @@
1import type {
2 EnvironmentId,
3 UserInputAttachmentAnswerPayload,
4 UserInputAttachments,
5} from "@t3tools/contracts";
6import { Image, Linking, Pressable, View } from "react-native";
7import { AppText as Text } from "../../components/AppText";
8import { useAssetUrl } from "../../state/assets";
9
10function 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
36export 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 @@
1import { TextInputWrapper } from "expo-paste-input";
2import { AppTextInput as TextInput } from "../../components/AppText";
3import { useNativePaste } from "../../lib/useNativePaste";
4import { convertPastedImagesToAttachments } from "../../lib/composerImages";
5import {
6 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
7 type ApprovalRequestId,
8 type UserInputQuestion,
9} from "@t3tools/contracts";
10import { useAtomValue } from "@effect/atom-react";
11import { Alert, View } from "react-native";
12import { useEffect, useRef } from "react";
13import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton";
14import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";
15import { pickComposerFiles, pickComposerMedia } from "../../lib/composerImages";
16import { useThreadSelection } from "../../state/use-thread-selection";
17import { useServerConfigs } from "../../state/entities";
18import { appAtomRegistry } from "../../state/atom-registry";
19import {
20 appendComposerDraftAttachments,
21 composerDraftsAtom,
22 removeComposerDraftAttachment,
23 releaseUnusedComposerAttachmentFiles,
24} from "../../state/use-composer-drafts";
25import {
26 changeQuestionAttachmentPreparation,
27 questionAttachmentDraftKey,
28 questionAttachmentPreparationAtom,
29} from "../../state/question-attachments";
30
31export 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 @@
1import { QuestionAnswerHistory } from "./QuestionAnswerHistory";
12import * as Haptics from "expo-haptics";
23import { Image } from "expo-image";
34import { type AppSymbolName, SymbolView } from "../../components/AppSymbol";
......@@ -849,13 +850,19 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
849850 </View>
850851 </Pressable>
851852
852 {expanded && (fullDetail || viewedImagePath) ? (
853 {expanded && (fullDetail || viewedImagePath || row.workEntry.questionAnswer) ? (
853854 <Animated.View
854855 entering={WORK_LOG_DETAIL_ENTER_TRANSITION}
855856 exiting={WORK_LOG_DETAIL_EXIT_TRANSITION}
856857 layout={WORK_LOG_LAYOUT_TRANSITION}
857858 className="ml-7 border-l border-adaptive-neutral-300-a60-white-a12 pb-1 pl-3 pt-0.5"
858859 >
860 {row.workEntry.questionAnswer ? (
861 <QuestionAnswerHistory
862 environmentId={props.environmentId}
863 answer={row.workEntry.questionAnswer}
864 />
865 ) : null}
859866 {viewedImagePath ? (
860867 <View className="pb-1.5">
861868 {props.renderImage({ href: viewedImagePath, alt: null, title: null })}
apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts+26-1
......@@ -1,5 +1,10 @@
1import { EnvironmentId } from "@t3tools/contracts";
1import { ApprovalRequestId, EnvironmentId, ThreadId } from "@t3tools/contracts";
22import { describe, expect, it, vi } from "vite-plus/test";
3import {
4 questionAttachmentDraftKey,
5 questionAttachmentDraftPrefix,
6} from "../state/question-attachments";
7vi.mock("../state/atom-registry", () => ({ appAtomRegistry: {} }));
38
49import {
510 composerAttachmentUploadBlockReason,
......@@ -217,6 +222,26 @@ describe("composer attachment upload queue", () => {
217222});
218223
219224describe("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 });
220245 it("resolves thread, new-task, and queued-task drafts without crossing environments", () => {
221246 expect(composerDraftEnvironmentId("environment-1:thread", [])).toBe(environmentId);
222247 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"
22import { describe, expect, it } from "vite-plus/test";
33
44import {
5 ApprovalRequestId,
56 EventId,
67 MessageId,
78 ProjectId,
......@@ -3314,3 +3315,72 @@ describe("quiet timeline: nested agents", () => {
33143315 ]);
33153316 });
33163317});
3318
3319it("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
3347it("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 @@
1import * as Option from "effect/Option";
2import * as Schema from "effect/Schema";
13import {
24 requestKindFromRequestType,
35 type PendingApproval,
46} from "@t3tools/client-runtime/pending-requests";
5import { isToolLifecycleItemType } from "@t3tools/contracts";
7import { UserInputAttachmentAnswerPayload, isToolLifecycleItemType } from "@t3tools/contracts";
68import type {
79 OrchestrationLatestTurn,
810 OrchestrationThread,
......@@ -41,6 +43,8 @@ export type { PendingApproval, PendingUserInput } from "@t3tools/client-runtime/
4143export interface PendingUserInputDraftAnswer {
4244 readonly selectedOptionValues?: ReadonlyArray<string>;
4345 readonly customAnswer?: string;
46 readonly attachmentCount?: number;
47 readonly attachmentsBlocked?: boolean;
4448}
4549
4650export interface ThreadFeedActivity {
......@@ -76,6 +80,7 @@ export interface ThreadFeedActivity {
7680}
7781
7882export interface WorkLogEntry {
83 readonly questionAnswer?: UserInputAttachmentAnswerPayload;
7984 id: string;
8085 createdAt: string;
8186 turnId: TurnId | null;
......@@ -302,6 +307,7 @@ function resolvePendingUserInputAnswer(
302307 question: UserInputQuestion,
303308 draft: PendingUserInputDraftAnswer | undefined,
304309): string | ReadonlyArray<string> | null {
310 if (draft?.attachmentsBlocked) return null;
305311 const customAnswer =
306312 question.allowCustomAnswer === false ? null : normalizeDraftAnswer(draft?.customAnswer);
307313 if (customAnswer) {
......@@ -310,9 +316,16 @@ function resolvePendingUserInputAnswer(
310316
311317 const selectedOptionValues = normalizeSelectedOptionValues(question, draft?.selectedOptionValues);
312318 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;
314324 }
315 return selectedOptionValues[0] ?? null;
325 return (
326 selectedOptionValues[0] ??
327 (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null)
328 );
316329}
317330
318331/** Some providers settle agents through task.updated instead of task.completed. */
......@@ -435,6 +448,8 @@ function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): bool
435448 return typeof payload?.detail === "string" && payload.detail.startsWith("ExitPlanMode:");
436449}
437450
451const decodeQuestionAttachmentAnswer = Schema.decodeUnknownOption(UserInputAttachmentAnswerPayload);
452
438453function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWorkLogEntry {
439454 const payload =
440455 activity.payload && typeof activity.payload === "object"
......@@ -480,6 +495,11 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
480495 ? "info"
481496 : activity.tone,
482497 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 })(),
483503 };
484504 const toolCallId =
485505 asTrimmedString(payload?.toolCallId) ?? asTrimmedString(asRecord(payload?.data)?.toolCallId);
......@@ -969,6 +989,7 @@ function buildWorkEntryExpandedBody(entry: WorkLogEntry): string | null {
969989 * for every row (see the deferred-expansion test).
970990 */
971991function workEntryCanExpand(entry: WorkLogEntry): boolean {
992 if (entry.questionAnswer) return true;
972993 if (entry.agentSpawn) return agentSpawnMembers(entry.agentSpawn).length > 0;
973994 if (entry.itemType === "mcp_tool_call" && entry.toolData !== undefined) return true;
974995 if (entry.changedFiles?.some((path) => path.trim().length > 0)) return true;
apps/mobile/src/state/question-attachments.ts created+29
......@@ -0,0 +1,29 @@
1import type { ApprovalRequestId, EnvironmentId, ThreadId } from "@t3tools/contracts";
2import { Atom } from "effect/unstable/reactivity";
3import { appAtomRegistry } from "./atom-registry";
4
5export function questionAttachmentDraftPrefix(
6 environmentId: EnvironmentId,
7 threadId: ThreadId,
8): string {
9 return `${environmentId}:question-${encodeURIComponent(JSON.stringify(threadId))}-`;
10}
11export 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}
19export const questionAttachmentPreparationAtom = Atom.make<Record<string, number>>({}).pipe(
20 Atom.keepAlive,
21);
22export 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 {
925925export function appendComposerDraftAttachments(
926926 draftKey: string,
927927 attachments: ReadonlyArray<DraftComposerAttachment>,
928 options?: { readonly allowOverflow?: boolean },
928 options?: { readonly allowOverflow?: boolean; readonly maxAttachments?: number },
929929): number {
930930 if (attachments.length === 0) {
931931 return 0;
......@@ -935,7 +935,13 @@ export function appendComposerDraftAttachments(
935935 const existing = normalizeDraft(current[draftKey]);
936936 const remaining = options?.allowOverflow
937937 ? 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 );
939945 const accepted = attachments.slice(0, remaining);
940946 rejected = attachments.slice(remaining);
941947 if (accepted.length === 0) {
apps/mobile/src/state/use-selected-thread-requests.test.tsx created+140
......@@ -0,0 +1,140 @@
1import { renderToStaticMarkup } from "react-dom/server";
2import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
3
4const 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}));
10vi.mock("react-native", () => ({ Alert: { alert: vi.fn() } }));
11vi.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}));
21vi.mock("./use-composer-drafts", () => ({
22 composerDraftsAtom: "drafts",
23 clearComposerDraft: vi.fn(),
24}));
25vi.mock("./composer-attachment-uploads", async () => ({
26 ...(await import("../lib/composerAttachmentUploadQueue")),
27 composerAttachmentUploadsAtom: "uploads",
28}));
29vi.mock("./question-attachments", async (importOriginal) => ({
30 ...(await importOriginal<typeof import("./question-attachments")>()),
31 questionAttachmentPreparationAtom: fixture.preparationAtom,
32}));
33vi.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}));
50vi.mock("./threads", () => ({ threadEnvironment: {} }));
51vi.mock("./use-atom-command", () => ({ useAtomCommand: () => vi.fn() }));
52vi.mock("./use-thread-selection", () => ({
53 useThreadSelection: () => ({
54 selectedThread: { environmentId: "environment-1", id: "thread-1" },
55 }),
56}));
57vi.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
79import { ApprovalRequestId, EnvironmentId, ThreadId } from "@t3tools/contracts";
80import { questionAttachmentDraftKey } from "./question-attachments";
81import { useSelectedThreadRequests } from "./use-selected-thread-requests";
82
83const environmentId = EnvironmentId.make("environment-1");
84const key = (question: string) =>
85 questionAttachmentDraftKey(
86 environmentId,
87 ThreadId.make("thread-1"),
88 ApprovalRequestId.make("request-1"),
89 question,
90 );
91function submitButtonMarkup() {
92 function Probe() {
93 const { activePendingUserInputAnswers } = useSelectedThreadRequests();
94 return <button disabled={activePendingUserInputAnswers === null}>Submit answers</button>;
95 }
96 return renderToStaticMarkup(<Probe />);
97}
98beforeEach(() => {
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});
119describe("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 @@
11import { derivePendingRequests } from "@t3tools/client-runtime/pending-requests";
2import { useServerConfigs } from "./entities";
3import { Alert } from "react-native";
4import {
5 questionAttachmentDraftKey,
6 questionAttachmentDraftPrefix,
7 questionAttachmentPreparationAtom,
8} from "./question-attachments";
9import { composerDraftsAtom, clearComposerDraft } from "./use-composer-drafts";
10import {
11 composerAttachmentUploadsAtom,
12 composerAttachmentUploadBlockReason,
13 composerAttachmentsStillUploading,
14} from "./composer-attachment-uploads";
215import { useAtomValue } from "@effect/atom-react";
3import { useCallback, useMemo, useState } from "react";
16import { useCallback, useEffect, useMemo, useRef, useState } from "react";
417
518import {
619 ApprovalRequestId,
......@@ -81,6 +94,7 @@ export function useSelectedThreadRequests() {
8194 const selectedThread = useSelectedThreadDetail();
8295 const userInputDraftsByRequestKey = useAtomValue(userInputDraftsByRequestKeyAtom);
8396 const [respondingApprovalId, setRespondingApprovalId] = useState<ApprovalRequestId | null>(null);
97 const userInputResponsesInFlight = useRef(new Set<string>());
8498 const [respondingUserInputId, setRespondingUserInputId] = useState<ApprovalRequestId | null>(
8599 null,
86100 );
......@@ -91,11 +105,81 @@ export function useSelectedThreadRequests() {
91105 );
92106 const activePendingApproval = activePendingApprovals[0] ?? null;
93107 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]);
94142 const activePendingUserInputDrafts =
95143 activePendingUserInput && selectedThreadShell
96 ? (userInputDraftsByRequestKey[
97 scopedRequestKey(selectedThreadShell.environmentId, activePendingUserInput.requestId)
98 ] ?? {})
144 ? Object.fromEntries(
145 activePendingUserInput.questions.map((question) => {
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 )
99183 : {};
100184 const activePendingUserInputAnswers = activePendingUserInput
101185 ? buildPendingUserInputAnswers(activePendingUserInput.questions, activePendingUserInputDrafts)
......@@ -154,6 +238,52 @@ export function useSelectedThreadRequests() {
154238 return;
155239 }
156240
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);
157287 setRespondingUserInputId(activePendingUserInput.requestId);
158288 const result = await respondToUserInput({
159289 environmentId: selectedThreadShell.environmentId,
......@@ -161,8 +291,12 @@ export function useSelectedThreadRequests() {
161291 threadId: selectedThreadShell.id,
162292 requestId: activePendingUserInput.requestId,
163293 answers: activePendingUserInputAnswers,
294 ...(attachmentsByQuestionId.size > 0
295 ? { attachmentsByQuestionId: Object.fromEntries(attachmentsByQuestionId) }
296 : {}),
164297 },
165298 });
299 userInputResponsesInFlight.current.delete(responseKey);
166300 setRespondingUserInputId((current) =>
167301 current === activePendingUserInput.requestId ? null : current,
168302 );
apps/server/src/environment/ServerEnvironment.ts+1
......@@ -215,6 +215,7 @@ export const make = Effect.gen(function* () {
215215 repositoryIdentity: true,
216216 connectionProbe: true,
217217 attachmentUploads: true,
218 questionAttachments: true,
218219 fileAttachments: { maxUploadBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES },
219220 pullRequests: true,
220221 threadSettlement: true,
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+13-1
......@@ -239,6 +239,17 @@ describe("OrchestrationEngine", () => {
239239 threadId,
240240 requestId,
241241 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 },
242253 createdAt: "2026-01-01T00:00:02.000Z",
243254 };
244255 await expect(
......@@ -256,8 +267,9 @@ describe("OrchestrationEngine", () => {
256267 (message) => message.role === "user",
257268 );
258269 expect(userMessages).toHaveLength(1);
270 expect(userMessages?.[0]?.attachments).toEqual(response.attachmentsByQuestionId["1"]);
259271 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)",
261273 );
262274 expect(
263275 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) => {
411411 FROM projection_state
412412 ORDER BY projector ASC
413413 `;
414 assert.equal(stateRows.length, Object.keys(ORCHESTRATION_PROJECTOR_NAMES).length);
414 assert.equal(stateRows.length, Object.keys(ORCHESTRATION_PROJECTOR_NAMES).length + 1);
415415 for (const row of stateRows) {
416416 assert.equal(row.lastAppliedSequence, 3);
417417 }
......@@ -1164,14 +1164,18 @@ it.layer(
11641164
11651165 yield* projectionPipeline.bootstrap;
11661166 yield* projectionPipeline.bootstrap;
1167 assert.deepEqual(
1168 yield* projectionState.listAll(),
1169 cursorsBeforeFailure.map((cursor) => ({
1167 assert.deepEqual(yield* projectionState.listAll(), [
1168 {
1169 projector: "projection.attachment-cleanup",
1170 lastAppliedSequence: pendingEvent.sequence,
1171 updatedAt: pendingEvent.occurredAt,
1172 },
1173 ...cursorsBeforeFailure.map((cursor) => ({
11701174 ...cursor,
11711175 lastAppliedSequence: pendingEvent.sequence,
11721176 updatedAt: pendingEvent.occurredAt,
11731177 })),
1174 );
1178 ]);
11751179 const replayedMessages = yield* sql<{ readonly text: string }>`
11761180 SELECT text FROM projection_thread_messages WHERE message_id = 'message-rollback'
11771181 `;
......@@ -1364,10 +1368,53 @@ it.layer(
13641368 },
13651369 });
13661370
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 }
13671409 const keepPath = path.join(attachmentsDir, `${keepAttachmentId}.png`);
13681410 const keepFilePath = path.join(attachmentsDir, `${keepFileAttachmentId}.pdf`);
13691411 const removePath = path.join(attachmentsDir, `${removeAttachmentId}.png`);
13701412 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 );
13711418 yield* fileSystem.writeFileString(keepPath, "keep");
13721419 yield* fileSystem.writeFileString(keepFilePath, "keep");
13731420 yield* fileSystem.writeFileString(removePath, "remove");
......@@ -1460,9 +1507,33 @@ it.layer(
14601507
14611508 assert.isTrue(yield* exists(keepPath));
14621509 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`)));
14631512 assert.isFalse(yield* exists(removePath));
14641513 assert.isTrue(yield* exists(laterPath));
14651514 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`)));
14661537 }),
14671538 );
14681539});
......@@ -1934,6 +2005,15 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
19342005 },
19352006 });
19362007
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`;
19372017 yield* projectionPipeline.bootstrap;
19382018 yield* projectionPipeline.bootstrap;
19392019
......@@ -3884,7 +3964,7 @@ const engineLayer = it.layer(
38843964 Layer.provideMerge(OrchestrationProjectionSnapshotQueryLive),
38853965 Layer.provide(ThreadBackgroundLiveness.layer),
38863966 Layer.provide(ThreadPlanProgress.layer),
3887 Layer.provide(OrchestrationProjectionPipelineLive),
3967 Layer.provideMerge(OrchestrationProjectionPipelineLive),
38883968 Layer.provide(OrchestrationEventStoreLive),
38893969 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
38903970 Layer.provide(RepositoryIdentityResolver.layer),
......@@ -4236,7 +4316,10 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => {
42364316 yield* readCursors,
42374317 cursorsBeforeFailure.map((cursor) => ({
42384318 ...cursor,
4239 lastAppliedSequence: result.sequence,
4319 lastAppliedSequence:
4320 cursor.projector === "projection.attachment-cleanup"
4321 ? cursor.lastAppliedSequence
4322 : result.sequence,
42404323 })),
42414324 );
42424325 assert.isFalse(yield* exists(attachmentPath));
......@@ -4266,6 +4349,25 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => {
42664349 WHERE command_id = ${cleanupFailureCommandId}
42674350 `;
42684351 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 );
42694371 }),
42704372 );
42714373});
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+89-31
......@@ -1,6 +1,7 @@
11import {
22 ApprovalRequestId,
33 isImportedAgentSessionMessageId,
4 UserInputAttachmentAnswerPayload,
45 type ChatAttachment,
56 type OrchestrationEvent,
67 type OrchestrationSessionStatus,
......@@ -12,6 +13,7 @@ import * as FileSystem from "effect/FileSystem";
1213import * as Layer from "effect/Layer";
1314import * as Option from "effect/Option";
1415import * as Path from "effect/Path";
16import * as Schema from "effect/Schema";
1517import * as Stream from "effect/Stream";
1618import * as SqlClient from "effect/unstable/sql/SqlClient";
1719
......@@ -327,6 +329,8 @@ function retainProjectionProposedPlansAfterRevert(
327329 );
328330}
329331
332const decodeQuestionAttachmentAnswer = Schema.decodeUnknownOption(UserInputAttachmentAnswerPayload);
333
330334function collectThreadAttachmentRelativePaths(
331335 threadId: string,
332336 messages: ReadonlyArray<ProjectionThreadMessage>,
......@@ -359,9 +363,9 @@ const runAttachmentSideEffects = Effect.fn("runAttachmentSideEffects")(function*
359363 const path = yield* Effect.service(Path.Path);
360364
361365 const attachmentsRootDir = serverConfig.attachmentsDir;
362 const readAttachmentRootEntries = fileSystem
363 .readDirectory(attachmentsRootDir, { recursive: false })
364 .pipe(Effect.orElseSucceed(() => [] as Array<string>));
366 const readAttachmentRootEntries = fileSystem.readDirectory(attachmentsRootDir, {
367 recursive: false,
368 });
365369
366370 const removeDeletedThreadAttachmentEntry = Effect.fn("removeDeletedThreadAttachmentEntry")(
367371 function* (threadSegment: string, entry: string) {
......@@ -423,7 +427,7 @@ const runAttachmentSideEffects = Effect.fn("runAttachmentSideEffects")(function*
423427 }
424428
425429 const absolutePath = path.join(attachmentsRootDir, relativePath);
426 const fileInfo = yield* fileSystem.stat(absolutePath).pipe(Effect.orElseSucceed(() => null));
430 const fileInfo = yield* fileSystem.stat(absolutePath);
427431 if (!fileInfo || fileInfo.type !== "File") {
428432 return;
429433 }
......@@ -1156,7 +1160,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
11561160
11571161 const applyThreadActivitiesProjection: ProjectorDefinition["apply"] = Effect.fn(
11581162 "applyThreadActivitiesProjection",
1159 )(function* (event, _attachmentSideEffects) {
1163 )(function* (event, attachmentSideEffects) {
11601164 switch (event.type) {
11611165 case "thread.created":
11621166 yield* projectionThreadActivityRepository.deleteByThreadId({
......@@ -1204,6 +1208,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
12041208 yield* Effect.forEach(keptRows, projectionThreadActivityRepository.upsert, {
12051209 concurrency: 1,
12061210 }).pipe(Effect.asVoid);
1211 attachmentSideEffects.prunedThreadRelativePaths.set(event.payload.threadId, new Set());
12071212 return;
12081213 }
12091214
......@@ -1866,10 +1871,20 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
18661871 const messages = yield* projectionThreadMessageRepository.listByThreadId({
18671872 threadId: ThreadId.make(threadId),
18681873 });
1869 prunedThreadRelativePaths.set(
1870 threadId,
1871 collectThreadAttachmentRelativePaths(threadId, messages),
1872 );
1874 const retainedPaths = collectThreadAttachmentRelativePaths(threadId, messages);
1875 const activities = yield* projectionThreadActivityRepository.listByThreadId({
1876 threadId: ThreadId.make(threadId),
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);
18731888 }
18741889
18751890 yield* runAttachmentSideEffects({ deletedThreadIds, prunedThreadRelativePaths });
......@@ -1879,29 +1894,17 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
18791894 Effect.provideService(ServerConfig, serverConfig),
18801895 (effect, event) =>
18811896 effect.pipe(
1897 Effect.as(true),
18821898 Effect.catch((cause) =>
18831899 Effect.logWarning("failed to apply projected attachment side-effects", {
18841900 sequence: event.sequence,
18851901 eventType: event.type,
18861902 cause,
1887 }),
1903 }).pipe(Effect.as(false)),
18881904 ),
18891905 ),
18901906 );
18911907
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
19051908 const runProjectorForEvent = Effect.fn("runProjectorForEvent")(function* (
19061909 projector: ProjectorDefinition,
19071910 event: OrchestrationEvent,
......@@ -1911,8 +1914,16 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
19111914 prunedThreadRelativePaths: new Map<string, Set<string>>(),
19121915 };
19131916
1914 yield* sql.withTransaction(applyProjectorForEvent(projector, event, attachmentSideEffects));
1915 yield* applyAttachmentSideEffects(event, attachmentSideEffects);
1917 yield* sql.withTransaction(
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 );
19161927 });
19171928
19181929 const bootstrapProjector = (projector: ProjectorDefinition) =>
......@@ -1958,7 +1969,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
19581969 );
19591970 // Return the cleanup effect so the caller runs it after the outer transaction commits.
19601971 // @effect-diagnostics-next-line returnEffectInGen:off
1961 return applyAttachmentSideEffects(event, attachmentSideEffects);
1972 return applyAttachmentSideEffects(event, attachmentSideEffects).pipe(Effect.asVoid);
19621973 },
19631974 Effect.provideService(FileSystem.FileSystem, fileSystem),
19641975 Effect.provideService(Path.Path, path),
......@@ -1975,11 +1986,58 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
19751986 yield* cleanup;
19761987 });
19771988
1978 const bootstrap: OrchestrationProjectionPipelineShape["bootstrap"] = Effect.forEach(
1979 projectors,
1980 bootstrapProjector,
1981 { concurrency: 1 },
1982 ).pipe(
1989 const bootstrap: OrchestrationProjectionPipelineShape["bootstrap"] = Effect.gen(function* () {
1990 const cleanupProjector = "projection.attachment-cleanup";
1991 const states = yield* projectionStateRepository.listAll();
1992 const byProjector = new Map(states.map((state) => [state.projector, state]));
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(
19832041 Effect.provideService(FileSystem.FileSystem, fileSystem),
19842042 Effect.provideService(Path.Path, path),
19852043 Effect.provideService(ServerConfig, serverConfig),
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+3
......@@ -1609,6 +1609,9 @@ const make = Effect.gen(function* () {
16091609 threadId: event.payload.threadId,
16101610 requestId: event.payload.requestId,
16111611 answers: event.payload.answers,
1612 ...(event.payload.attachmentsByQuestionId
1613 ? { attachmentsByQuestionId: event.payload.attachmentsByQuestionId }
1614 : {}),
16121615 })
16131616 .pipe(
16141617 Effect.catchCause((cause) =>
apps/server/src/orchestration/Normalizer.attachments.test.ts+202-1
......@@ -5,13 +5,15 @@ import * as NodePath from "node:path";
55import * as NodeServices from "@effect/platform-node/NodeServices";
66import { describe, expect, it } from "@effect/vitest";
77import {
8 type ClientOrchestrationCommand,
8 ClientOrchestrationCommand,
99 CommandId,
10 ApprovalRequestId,
1011 MessageId,
1112 ThreadId,
1213} from "@t3tools/contracts";
1314import * as Effect from "effect/Effect";
1415import * as Layer from "effect/Layer";
16import * as Schema from "effect/Schema";
1517
1618import * as ServerConfig from "../config.ts";
1719import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
......@@ -23,6 +25,7 @@ const testLayer = Layer.mergeAll(
2325).pipe(Layer.provideMerge(NodeServices.layer));
2426
2527const attachmentUuid = "00000000-0000-4000-8000-0000000000aa";
28const isClientCommand = Schema.is(ClientOrchestrationCommand);
2629
2730function turnStartCommand(input: {
2831 readonly threadId?: string;
......@@ -358,3 +361,201 @@ describe("normalizeDispatchCommand attachments", () => {
358361 }).pipe(Effect.provide(testLayer)),
359362 );
360363});
364
365describe("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";
44import * as Path from "effect/Path";
55import {
66 type ClientOrchestrationCommand,
7 type UserInputAttachments,
8 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
79 type IsoDateTime,
810 type OrchestrationCommand,
911 OrchestrationDispatchCommandError,
......@@ -129,13 +131,28 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) =>
129131 } satisfies OrchestrationCommand;
130132 }
131133
132 if (canonicalCommand.type !== "thread.turn.start") {
134 if (
135 canonicalCommand.type !== "thread.turn.start" &&
136 canonicalCommand.type !== "thread.user-input.respond"
137 ) {
133138 return canonicalCommand as OrchestrationCommand;
134139 }
135140
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 }
136153 const claimedAttachmentPaths: string[] = [];
137154 const normalizedAttachments = yield* Effect.forEach(
138 canonicalCommand.message.attachments,
155 attachments,
139156 (attachment) =>
140157 Effect.gen(function* () {
141158 if (!("dataUrl" in attachment)) {
......@@ -260,6 +277,25 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) =>
260277 { concurrency: 1 },
261278 ).pipe(Effect.tapError(() => removeClaimedAttachmentPaths(claimedAttachmentPaths)));
262279
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 }
263299 return {
264300 ...canonicalCommand,
265301 message: {
......@@ -272,14 +308,24 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) =>
272308export const cleanupFailedUploadedAttachments = Effect.fn(
273309 "Normalizer.cleanupFailedUploadedAttachments",
274310)(function* (command: ClientOrchestrationCommand, normalizedCommand: OrchestrationCommand) {
275 if (command.type !== "thread.turn.start" || normalizedCommand.type !== "thread.turn.start") {
276 return;
277 }
311 const originalAttachments =
312 command.type === "thread.turn.start"
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;
278324
279325 const serverConfig = yield* ServerConfig;
280326 const claimedPaths: string[] = [];
281 for (const [index, attachment] of normalizedCommand.message.attachments.entries()) {
282 const original = command.message.attachments[index];
327 for (const [index, attachment] of normalizedAttachments.entries()) {
328 const original = originalAttachments[index];
283329 if (
284330 !original ||
285331 "dataUrl" in original ||
apps/server/src/orchestration/decider.questionAttachments.test.ts created+130
......@@ -0,0 +1,130 @@
1import {
2 ApprovalRequestId,
3 EventId,
4 CommandId,
5 ProjectId,
6 ProviderInstanceId,
7 ThreadId,
8 type OrchestrationReadModel,
9} from "@t3tools/contracts";
10import * as NodeServices from "@effect/platform-node/NodeServices";
11import { expect, it } from "@effect/vitest";
12import * as Effect from "effect/Effect";
13
14import { decideOrchestrationCommand } from "./decider.ts";
15
16const UPDATED_AT = "2026-01-01T00:00:00.000Z";
17
18const 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
50const requestId = ApprovalRequestId.make("question-request");
51const 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};
70const 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};
84it.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"
12221222 threadId: command.threadId,
12231223 });
12241224 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 }
12251254 if (
12261255 request &&
12271256 Predicate.isObject(request.payload) &&
......@@ -1237,13 +1266,22 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
12371266 const replies: string[] = [];
12381267 for (const question of payload.value.questions) {
12391268 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 ) {
12411273 return yield* new OrchestrationCommandInvariantError({
12421274 commandType: command.type,
12431275 detail: "Answer each question before sending.",
12441276 });
12451277 }
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 );
12471285 }
12481286 // Commit the answer and its message together. The normal turn path
12491287 // steers a running agent or resumes an idle session.
......@@ -1266,6 +1304,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
12661304 requestId: command.requestId,
12671305 responseMode: "message",
12681306 answers: command.answers,
1307 ...(command.attachmentsByQuestionId
1308 ? { attachmentsByQuestionId: command.attachmentsByQuestionId }
1309 : {}),
12691310 },
12701311 },
12711312 },
......@@ -1280,30 +1321,57 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
12801321 messageId: MessageId.make(`async-answer:${command.requestId}`),
12811322 role: "user",
12821323 text: replies.join("\n\n"),
1283 attachments: [],
1324 attachments,
12841325 },
12851326 },
12861327 ],
12871328 });
12881329 }
1289 return {
1330 const responseEvent = {
12901331 ...(yield* withEventBase({
12911332 aggregateKind: "thread",
12921333 aggregateId: command.threadId,
12931334 occurredAt: command.createdAt,
12941335 commandId: command.commandId,
1295 metadata: {
1296 requestId: command.requestId,
1297 },
1336 metadata: { requestId: command.requestId },
12981337 })),
1299 type: "thread.user-input-response-requested",
1338 type: "thread.user-input-response-requested" as const,
13001339 payload: {
13011340 threadId: command.threadId,
13021341 requestId: command.requestId,
13031342 answers: command.answers,
1343 ...(command.attachmentsByQuestionId
1344 ? { attachmentsByQuestionId: command.attachmentsByQuestionId }
1345 : {}),
13041346 createdAt: command.createdAt,
13051347 },
13061348 };
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];
13071375 }
13081376
13091377 case "thread.user-input.dismiss": {
apps/server/src/provider/Layers/ProviderService.ts+6-1
......@@ -49,6 +49,7 @@ import * as Schema from "effect/Schema";
4949import * as SchemaIssue from "effect/SchemaIssue";
5050import * as Stream from "effect/Stream";
5151
52import { appendUserInputAttachmentPaths } from "../userInputAttachments.ts";
5253import { resolveAttachmentPath } from "../../attachmentStore.ts";
5354import * as ServerConfig from "../../config.ts";
5455import {
......@@ -1919,7 +1920,11 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
19191920 "provider.thread_id": input.threadId,
19201921 "provider.request_id": input.requestId,
19211922 });
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);
19231928 }).pipe(
19241929 withMetrics({
19251930 counter: providerTurnsTotal,
apps/server/src/provider/userInputAttachments.test.ts created+61
......@@ -0,0 +1,61 @@
1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeFS from "node:fs";
3import * as NodePath from "node:path";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import { describe, expect, it } from "@effect/vitest";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8import * as ServerConfig from "../config.ts";
9import { appendUserInputAttachmentPaths } from "./userInputAttachments.ts";
10
11const layer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-answer-paths-" }).pipe(
12 Layer.provideMerge(NodeServices.layer),
13);
14const 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};
21describe("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 @@
1import type { ProviderUserInputAnswers, UserInputAttachments } from "@t3tools/contracts";
2import * as Schema from "effect/Schema";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import { resolveAttachmentPath } from "../attachmentStore.ts";
6import { ProviderValidationError } from "./Errors.ts";
7
8const quoteReference = Schema.encodeSync(Schema.fromJsonString(Schema.String));
9
10/** Keep provider answer protocols unchanged; paths refer to files on the provider's server. */
11export 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 {
88import { feedbackBannerItem } from "./chat/ComposerFeedback";
99import { usageLimitsBannerItem } from "./chat/ComposerUsageLimits";
1010import { derivePendingRequests } from "@t3tools/client-runtime/pending-requests";
11import {
12 questionAttachmentDraftId,
13 questionAttachmentDraftPrefix,
14 clearQuestionAttachmentDraft,
15 useQuestionAttachmentPreparation,
16} from "../questionAttachments";
17import { useAttachmentUploadStore } from "../lib/attachmentUploadQueue";
1118import {
1219 type AssistantCitation,
1320 type ApprovalRequestId,
......@@ -264,7 +271,7 @@ import {
264271 finalizePromotedDraftThreadByRef,
265272 markPromotedDraftThreadByRef,
266273 useComposerDraftStore,
267 type DraftId,
274 DraftId,
268275} from "../composerDraftStore";
269276import {
270277 appendTerminalContextsToPrompt,
......@@ -1610,6 +1617,7 @@ export default function ChatView(props: ChatViewProps) {
16101617 null,
16111618 );
16121619 const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]);
1620 const userInputResponsesInFlight = useRef(new Set<string>());
16131621 const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState<
16141622 ApprovalRequestId[]
16151623 >([]);
......@@ -2310,6 +2318,8 @@ export default function ChatView(props: ChatViewProps) {
23102318 const supportsPullRequests = serverConfig?.environment.capabilities.pullRequests === true;
23112319 const attachmentEnvironmentConfig = environmentById.get(environmentId)?.serverConfig ?? null;
23122320 const attachmentUploadsCapabilityKnown = attachmentEnvironmentConfig !== null;
2321 const supportsQuestionAttachments =
2322 attachmentEnvironmentConfig?.environment.capabilities.questionAttachments === true;
23132323 const supportsAttachmentUploads =
23142324 attachmentEnvironmentConfig?.environment.capabilities.attachmentUploads === true;
23152325 const advertisedFileAttachmentBytes =
......@@ -2627,16 +2637,120 @@ export default function ChatView(props: ChatViewProps) {
26272637 [threadActivities],
26282638 );
26292639 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(
26312646 () =>
2632 activePendingUserInput
2633 ? (pendingUserInputAnswersByRequestId[activePendingUserInput.requestId] ??
2634 EMPTY_PENDING_USER_INPUT_ANSWERS)
2635 : EMPTY_PENDING_USER_INPUT_ANSWERS,
2636 [activePendingUserInput, pendingUserInputAnswersByRequestId],
2647 activeThreadId
2648 ? pendingUserInputs.flatMap((request) =>
2649 request.questions.map((question) =>
2650 questionAttachmentDraftId(
2651 environmentId,
2652 activeThreadId,
2653 request.requestId,
2654 question.id,
2655 ),
2656 ),
2657 )
2658 : [],
2659 [activeThreadId, environmentId, pendingUserInputs],
26372660 );
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 ]);
26382752 const activePendingQuestionIndex = activePendingUserInput
2639 ? (pendingUserInputQuestionIndexByRequestId[activePendingUserInput.requestId] ?? 0)
2753 ? (pendingUserInputQuestionIndexByRequestId[activePendingRequestKey] ?? 0)
26402754 : 0;
26412755 const activePendingProgress = useMemo(
26422756 () =>
......@@ -7061,8 +7175,38 @@ export default function ChatView(props: ChatViewProps) {
70617175
70627176 const onRespondToUserInput = useCallback(
70637177 async (requestId: ApprovalRequestId, answers: Record<string, unknown>) => {
7064 if (!activeThreadId) return;
7065
7178 if (!activeThreadId || !activePendingUserInput || activePendingIsResponding) return;
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);
70667210 setRespondingUserInputRequestIds((existing) =>
70677211 existing.includes(requestId) ? existing : [...existing, requestId],
70687212 );
......@@ -7072,6 +7216,9 @@ export default function ChatView(props: ChatViewProps) {
70727216 threadId: activeThreadId,
70737217 requestId,
70747218 answers,
7219 ...(attachmentsByQuestionId.size > 0
7220 ? { attachmentsByQuestionId: Object.fromEntries(attachmentsByQuestionId) }
7221 : {}),
70757222 },
70767223 });
70777224 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
......@@ -7081,10 +7228,18 @@ export default function ChatView(props: ChatViewProps) {
70817228 error instanceof Error ? error.message : "Failed to submit user input.",
70827229 );
70837230 }
7231 userInputResponsesInFlight.current.delete(responseKey);
70847232 setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId));
70857233 return result;
70867234 },
7087 [activeThreadId, environmentId, respondToThreadUserInput, setThreadError],
7235 [
7236 activeThreadId,
7237 activePendingUserInput,
7238 activePendingIsResponding,
7239 environmentId,
7240 respondToThreadUserInput,
7241 setThreadError,
7242 ],
70887243 );
70897244
70907245 // Closes an async question without messaging the agent. The server records
......@@ -7120,10 +7275,10 @@ export default function ChatView(props: ChatViewProps) {
71207275 }
71217276 setPendingUserInputQuestionIndexByRequestId((existing) => ({
71227277 ...existing,
7123 [activePendingUserInput.requestId]: nextQuestionIndex,
7278 [activePendingRequestKey]: nextQuestionIndex,
71247279 }));
71257280 },
7126 [activePendingUserInput],
7281 [activePendingUserInput, activePendingRequestKey],
71277282 );
71287283
71297284 const onSelectActivePendingUserInputOption = useCallback(
......@@ -7143,11 +7298,11 @@ export default function ChatView(props: ChatViewProps) {
71437298
71447299 return {
71457300 ...existing,
7146 [activePendingUserInput.requestId]: {
7147 ...existing[activePendingUserInput.requestId],
7301 [activePendingRequestKey]: {
7302 ...existing[activePendingRequestKey],
71487303 [questionId]: togglePendingUserInputOptionSelection(
71497304 question,
7150 existing[activePendingUserInput.requestId]?.[questionId],
7305 existing[activePendingRequestKey]?.[questionId],
71517306 optionValue,
71527307 ),
71537308 },
......@@ -7156,7 +7311,12 @@ export default function ChatView(props: ChatViewProps) {
71567311 promptRef.current = "";
71577312 composerRef.current?.resetCursorState({ cursor: 0 });
71587313 },
7159 [activePendingProgress?.activeQuestion, activePendingUserInput, composerRef],
7314 [
7315 activePendingProgress?.activeQuestion,
7316 activePendingUserInput,
7317 activePendingRequestKey,
7318 composerRef,
7319 ],
71607320 );
71617321
71627322 const onChangeActivePendingUserInputCustomAnswer = useCallback(
......@@ -7177,10 +7337,10 @@ export default function ChatView(props: ChatViewProps) {
71777337 promptRef.current = value;
71787338 setPendingUserInputAnswersByRequestId((existing) => ({
71797339 ...existing,
7180 [activePendingUserInput.requestId]: {
7181 ...existing[activePendingUserInput.requestId],
7340 [activePendingRequestKey]: {
7341 ...existing[activePendingRequestKey],
71827342 [questionId]: setPendingUserInputCustomAnswer(
7183 existing[activePendingUserInput.requestId]?.[questionId],
7343 existing[activePendingRequestKey]?.[questionId],
71847344 value,
71857345 ),
71867346 },
......@@ -7194,11 +7354,16 @@ export default function ChatView(props: ChatViewProps) {
71947354 composerRef.current?.focusAt(nextCursor);
71957355 }
71967356 },
7197 [activePendingUserInput, composerRef],
7357 [activePendingUserInput, activePendingRequestKey, composerRef],
71987358 );
71997359
72007360 const onAdvanceActivePendingUserInput = useCallback(() => {
7201 if (!activePendingUserInput || !activePendingProgress) {
7361 if (
7362 !activePendingUserInput ||
7363 !activePendingProgress ||
7364 !activePendingProgress.canAdvance ||
7365 activePendingIsResponding
7366 ) {
72027367 return;
72037368 }
72047369 if (activePendingProgress.isLastQuestion) {
......@@ -7212,6 +7377,7 @@ export default function ChatView(props: ChatViewProps) {
72127377 activePendingProgress,
72137378 activePendingResolvedAnswers,
72147379 activePendingUserInput,
7380 activePendingIsResponding,
72157381 onRespondToUserInput,
72167382 setActivePendingUserInputQuestionIndex,
72177383 ]);
......@@ -8119,6 +8285,7 @@ export default function ChatView(props: ChatViewProps) {
81198285 environmentId={environmentId}
81208286 attachmentUploadsCapabilityKnown={attachmentUploadsCapabilityKnown}
81218287 supportsAttachmentUploads={supportsAttachmentUploads}
8288 supportsQuestionAttachments={supportsQuestionAttachments}
81228289 maxFileAttachmentBytes={maxFileAttachmentBytes}
81238290 routeKind={routeKind}
81248291 routeThreadRef={routeThreadRef}
apps/web/src/components/chat/ChatComposer.tsx+156-47
......@@ -1,4 +1,9 @@
11import { RefreshIcon } from "~/components/ui/refresh-icon";
2import {
3 questionAttachmentDraftId,
4 useQuestionAttachmentPreparation,
5 changeQuestionAttachmentPreparation,
6} from "../../questionAttachments";
27import type {
38 ApprovalRequestId,
49 AssistantCitation,
......@@ -1227,6 +1232,7 @@ export interface ChatComposerProps {
12271232 environmentId: EnvironmentId;
12281233 attachmentUploadsCapabilityKnown: boolean;
12291234 supportsAttachmentUploads: boolean;
1235 supportsQuestionAttachments: boolean;
12301236 maxFileAttachmentBytes: number | null;
12311237 routeKind: "server" | "draft";
12321238 routeThreadRef: ScopedThreadRef;
......@@ -1376,6 +1382,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
13761382 environmentId,
13771383 attachmentUploadsCapabilityKnown,
13781384 supportsAttachmentUploads,
1385 supportsQuestionAttachments,
13791386 maxFileAttachmentBytes,
13801387 routeKind,
13811388 routeThreadRef,
......@@ -1468,9 +1475,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
14681475 // happened while they awaited.
14691476 const composerDraftTargetKeyRef = useRef("");
14701477 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);
14711491 const prompt = composerDraft.prompt;
1472 const composerImages = composerDraft.images;
1473 const composerFiles = composerDraft.files;
1492 const composerImages = attachmentDraft.images;
1493 const composerFiles = attachmentDraft.files;
14741494 const composerVideos = composerFiles.filter((file) =>
14751495 isPreviewableComposerVideo(file, environmentId),
14761496 );
......@@ -1500,7 +1520,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
15001520 );
15011521 return composerImages.filter((image) => !previewAnnotationIds.has(image.id));
15021522 }, [composerImages, composerPreviewAnnotations]);
1503 const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds;
1523 const nonPersistedComposerImageIds = attachmentDraft.nonPersistedImageIds;
15041524 const uploadsByImageId = useAttachmentUploadStore((state) => state.uploadsByImageId);
15051525 const needsReattachFileCount = composerFiles.filter(composerFileNeedsReattach).length;
15061526 const fileStagingLimit = fileAttachmentStagingLimit({
......@@ -1515,6 +1535,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
15151535 maxFileAttachmentBytes,
15161536 });
15171537 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) ??
15181543 fileCapabilityBlockReason ??
15191544 (supportsAttachmentUploads
15201545 ? needsReattachFileCount > 0
......@@ -1528,7 +1553,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
15281553 })
15291554 : null);
15301555 const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt);
1531 const addComposerDraftImage = useComposerDraftStore((store) => store.addImage);
15321556 const addComposerDraftImages = useComposerDraftStore((store) => store.addImages);
15331557 const removeComposerDraftImage = useComposerDraftStore((store) => store.removeImage);
15341558 const addComposerDraftFiles = useComposerDraftStore((store) => store.addFiles);
......@@ -1596,11 +1620,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
15961620 if (attachment.type === "file" && composerFileNeedsReattach(attachment)) {
15971621 continue;
15981622 }
1599 startAttachmentUpload({ environmentId, image: attachment, draftTarget: composerDraftTarget });
1623 startAttachmentUpload({
1624 environmentId,
1625 image: attachment,
1626 draftTarget: attachmentDraftTarget,
1627 });
16001628 }
16011629 }, [
16021630 attachmentUploadsCapabilityKnown,
1603 composerDraftTarget,
1631 attachmentDraftTarget,
16041632 composerFiles,
16051633 composerImages,
16061634 environmentId,
......@@ -1621,7 +1649,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
16211649 const upload = uploadsByImageId[file.id];
16221650 if (upload?.status === "ready" && upload.environmentId === environmentId) {
16231651 setComposerDraftFileUpload(
1624 composerDraftTarget,
1652 attachmentDraftTarget,
16251653 file.id,
16261654 environmentId,
16271655 upload.attachmentId,
......@@ -1630,7 +1658,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
16301658 }
16311659 }, [
16321660 attachmentUploadsCapabilityKnown,
1633 composerDraftTarget,
1661 attachmentDraftTarget,
16341662 composerFiles,
16351663 environmentId,
16361664 maxFileAttachmentBytes,
......@@ -1713,7 +1741,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
17131741 );
17141742 const sendDisabledReason =
17151743 externalSendDisabledReason ??
1716 (activePendingProgress ? null : (attachmentBlockReason ?? providerSendBlockReason));
1744 (activePendingProgress
1745 ? attachmentBlockReason
1746 : (attachmentBlockReason ?? providerSendBlockReason));
17171747 const isSendDisabled = sendDisabledReason !== null;
17181748 const selectedProviderStatus = useMemo(
17191749 () => selectedProviderEntry?.snapshot ?? null,
......@@ -1938,7 +1968,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
19381968 * and checked before sending or compacting so an image cannot move into
19391969 * the next draft.
19401970 */
1941 const pendingImageCompressionsRef = useRef<Map<ThreadId, number>>(new Map());
1971 const pendingImageCompressionsRef = useRef<Map<string, number>>(new Map());
19421972
19431973 // ------------------------------------------------------------------
19441974 // Derived: composer send state
......@@ -2127,7 +2157,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
21272157 (!isComposerCollapsedMobile && showPlanFollowUpPrompt && activeProposedPlan !== null);
21282158 const showCollapsedMobilePromptRow =
21292159 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));
21312165 const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null;
21322166 const composerFooterActionLayoutKey = useMemo(() => {
21332167 if (activePendingProgress) {
......@@ -2219,12 +2253,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
22192253 ? {
22202254 questionIndex: activePendingProgress.questionIndex,
22212255 isLastQuestion: activePendingProgress.isLastQuestion,
2222 canAdvance: activePendingProgress.canAdvance,
2256 canAdvance:
2257 activePendingProgress.canAdvance &&
2258 !attachmentBlockReason &&
2259 !(questionPreparations[attachmentTargetKey] ?? 0),
22232260 isResponding: activePendingIsResponding,
22242261 isComplete: Boolean(activePendingResolvedAnswers),
22252262 }
22262263 : null,
2227 [activePendingIsResponding, activePendingProgress, activePendingResolvedAnswers],
2264 [
2265 activePendingIsResponding,
2266 activePendingProgress,
2267 activePendingResolvedAnswers,
2268 attachmentBlockReason,
2269 questionPreparations,
2270 attachmentTargetKey,
2271 ],
22282272 );
22292273 const collapsedComposerPrimaryActionDisabled =
22302274 phase === "running" ||
......@@ -2251,35 +2295,42 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
22512295
22522296 const addComposerImage = useCallback(
22532297 (image: ComposerImageAttachment) => {
2254 addComposerDraftImage(composerDraftTarget, image);
2298 addComposerDraftImages(attachmentDraftTarget, [image]);
22552299 },
2256 [composerDraftTarget, addComposerDraftImage],
2300 [attachmentDraftTarget, addComposerDraftImages],
22572301 );
22582302
22592303 const addComposerImagesToDraft = useCallback(
22602304 (images: ComposerImageAttachment[]) => {
2261 addComposerDraftImages(composerDraftTarget, images);
2305 addComposerDraftImages(attachmentDraftTarget, images);
22622306 },
2263 [composerDraftTarget, addComposerDraftImages],
2307 [attachmentDraftTarget, addComposerDraftImages],
22642308 );
22652309
22662310 const addComposerFilesToDraft = useCallback(
22672311 (files: ComposerFileAttachment[]) => {
2268 addComposerDraftFiles(composerDraftTarget, files);
2312 addComposerDraftFiles(attachmentDraftTarget, files);
22692313 },
2270 [addComposerDraftFiles, composerDraftTarget],
2314 [addComposerDraftFiles, attachmentDraftTarget],
22712315 );
22722316
22732317 const removeComposerImageFromDraft = useCallback(
22742318 (imageId: string) => {
2319 if (questionAttachmentTarget && activePendingIsResponding) return;
22752320 releaseAttachmentUpload(imageId);
2276 removeComposerDraftImage(composerDraftTarget, imageId);
2321 removeComposerDraftImage(attachmentDraftTarget, imageId);
22772322 },
2278 [composerDraftTarget, removeComposerDraftImage],
2323 [
2324 attachmentDraftTarget,
2325 questionAttachmentTarget,
2326 activePendingIsResponding,
2327 removeComposerDraftImage,
2328 ],
22792329 );
22802330
22812331 const removeComposerFileFromDraft = useCallback(
22822332 (fileId: string) => {
2333 if (questionAttachmentTarget && activePendingIsResponding) return;
22832334 // Release by the draft attachment, not the bare queue key: a hydrated
22842335 // file's upload lives server-side under its persisted attachment id.
22852336 const file = composerFilesRef.current.find((candidate) => candidate.id === fileId);
......@@ -2288,9 +2339,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
22882339 } else {
22892340 releaseAttachmentUpload(fileId);
22902341 }
2291 removeComposerDraftFile(composerDraftTarget, fileId);
2342 removeComposerDraftFile(attachmentDraftTarget, fileId);
22922343 },
2293 [composerDraftTarget, composerFilesRef, removeComposerDraftFile],
2344 [
2345 attachmentDraftTarget,
2346 questionAttachmentTarget,
2347 activePendingIsResponding,
2348 composerFilesRef,
2349 removeComposerDraftFile,
2350 ],
22942351 );
22952352
22962353 const removeComposerTerminalContextFromDraft = useCallback(
......@@ -2525,11 +2582,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
25252582 let cancelled = false;
25262583 void (async () => {
25272584 if (composerImages.length === 0) {
2528 clearComposerDraftPersistedAttachments(composerDraftTarget);
2585 clearComposerDraftPersistedAttachments(attachmentDraftTarget);
25292586 return;
25302587 }
25312588 const getPersistedAttachmentsForThread = () =>
2532 getComposerDraft(composerDraftTarget)?.persistedAttachments ?? [];
2589 getComposerDraft(attachmentDraftTarget)?.persistedAttachments ?? [];
25332590 try {
25342591 const currentPersistedAttachments = getPersistedAttachmentsForThread();
25352592 const existingPersistedById = new Map(
......@@ -2558,7 +2615,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
25582615 );
25592616 const serialized = Array.from(stagedAttachmentById.values());
25602617 if (cancelled) return;
2561 syncComposerDraftPersistedAttachments(composerDraftTarget, serialized);
2618 syncComposerDraftPersistedAttachments(attachmentDraftTarget, serialized);
25622619 } catch {
25632620 const currentImageIds = new Set(composerImages.map((image) => image.id));
25642621 const fallbackPersistedAttachments = getPersistedAttachmentsForThread();
......@@ -2573,14 +2630,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
25732630 fallbackPersistedIdSet.has(attachment.id),
25742631 );
25752632 if (cancelled) return;
2576 syncComposerDraftPersistedAttachments(composerDraftTarget, fallbackAttachments);
2633 syncComposerDraftPersistedAttachments(attachmentDraftTarget, fallbackAttachments);
25772634 }
25782635 })();
25792636 return () => {
25802637 cancelled = true;
25812638 };
25822639 }, [
2583 composerDraftTarget,
2640 attachmentDraftTarget,
25842641 clearComposerDraftPersistedAttachments,
25852642 composerImages,
25862643 getComposerDraft,
......@@ -2946,7 +3003,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
29463003 // image: the turn snapshot wouldn't include it, and it would surface
29473004 // in the *next* draft instead. Only oversized images hit this — small
29483005 // 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 ) {
29503010 event?.preventDefault();
29513011 toastManager.add({
29523012 type: "info",
......@@ -2976,6 +3036,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
29763036 [
29773037 activeThreadId,
29783038 activePendingProgress,
3039 attachmentTargetKey,
29793040 blurMobileComposerAfterSend,
29803041 isSendDisabled,
29813042 noProviderAvailable,
......@@ -3009,7 +3070,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
30093070 // The compact buttons cannot see the compression counter (it lives in
30103071 // a ref), so they render enabled during a paste; toast instead of
30113072 // silently ignoring the click.
3012 if ((pendingImageCompressionsRef.current.get(activeThreadId) ?? 0) > 0) {
3073 if ((pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) > 0) {
30133074 toastManager.add({
30143075 type: "info",
30153076 title: "Still compressing a pasted image.",
......@@ -4255,10 +4316,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
42554316 // ------------------------------------------------------------------
42564317 const addComposerAttachments = async (files: File[]) => {
42574318 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 ) {
42594325 toastManager.add({
42604326 type: "error",
4261 title: "Attach files after answering pending questions.",
4327 title: "This question cannot accept attachments.",
42624328 });
42634329 return;
42644330 }
......@@ -4270,9 +4336,31 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
42704336 // Validation happens synchronously so concurrent pastes see each other:
42714337 // accepted files reserve their attachment slots (via the pending counter)
42724338 // 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;
42744359 let reservedCount =
4275 composerImagesRef.current.length + composerFilesRef.current.length + pendingCount;
4360 composerImagesRef.current.length +
4361 composerFilesRef.current.length +
4362 pendingCount +
4363 otherQuestionAttachments;
42764364 // A pick that matches a needs-reattach marker replaces it in the draft, so
42774365 // it must not consume a slot; a draft full of markers would otherwise hit
42784366 // the capacity error before the replacement path could run.
......@@ -4351,7 +4439,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
43514439 }
43524440 if (acceptedImages.length === 0) return;
43534441
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);
43554448 try {
43564449 const nextImages: ComposerImageAttachment[] = [];
43574450 let compressionError: string | null = null;
......@@ -4381,6 +4474,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
43814474 file: attachmentFile,
43824475 });
43834476 }
4477 if (
4478 questionAttachmentTarget &&
4479 !useQuestionAttachmentPreparation.getState().counts[questionAttachmentTarget]
4480 ) {
4481 for (const image of nextImages) URL.revokeObjectURL(image.previewUrl);
4482 return;
4483 }
43844484 if (nextImages.length === 1 && nextImages[0]) {
43854485 addComposerImage(nextImages[0]);
43864486 } else if (nextImages.length > 1) {
......@@ -4394,12 +4494,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
43944494 setThreadError(threadId, compressionError);
43954495 }
43964496 } finally {
4497 if (questionAttachmentTarget)
4498 changeQuestionAttachmentPreparation(questionAttachmentTarget, -acceptedImages.length);
43974499 const remaining =
4398 (pendingImageCompressionsRef.current.get(threadId) ?? 0) - acceptedImages.length;
4500 (pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) - acceptedImages.length;
43994501 if (remaining > 0) {
4400 pendingImageCompressionsRef.current.set(threadId, remaining);
4502 pendingImageCompressionsRef.current.set(attachmentTargetKey, remaining);
44014503 } else {
4402 pendingImageCompressionsRef.current.delete(threadId);
4504 pendingImageCompressionsRef.current.delete(attachmentTargetKey);
44034505 }
44044506 }
44054507 };
......@@ -4909,7 +5011,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
49095011 ) : !isComposerCollapsedMobile && pendingUserInputs.length > 0 ? (
49105012 <ComposerPendingUserInputPanel
49115013 pendingUserInputs={pendingUserInputs}
4912 respondingRequestIds={respondingRequestIds}
5014 respondingRequestIds={
5015 activePendingIsResponding && activePendingUserInput
5016 ? [...respondingRequestIds, activePendingUserInput.requestId]
5017 : respondingRequestIds
5018 }
49135019 answers={activePendingDraftAnswers}
49145020 questionIndex={activePendingQuestionIndex}
49155021 onToggleOption={onSelectActivePendingUserInputOption}
......@@ -4925,7 +5031,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
49255031 <div data-chat-composer-collapsed-controls="true">
49265032 <ComposerPendingUserInputPanel
49275033 pendingUserInputs={pendingUserInputs}
4928 respondingRequestIds={respondingRequestIds}
5034 respondingRequestIds={
5035 activePendingIsResponding && activePendingUserInput
5036 ? [...respondingRequestIds, activePendingUserInput.requestId]
5037 : respondingRequestIds
5038 }
49295039 answers={activePendingDraftAnswers}
49305040 questionIndex={activePendingQuestionIndex}
49315041 onToggleOption={onSelectActivePendingUserInputOption}
......@@ -5148,7 +5258,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
51485258 retryAttachmentUpload({
51495259 environmentId,
51505260 image,
5151 draftTarget: composerDraftTarget,
5261 draftTarget: attachmentDraftTarget,
51525262 }),
51535263 }
51545264 : {})}
......@@ -5192,7 +5302,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
51925302
51935303 {!isComposerCollapsedMobile &&
51945304 !isComposerApprovalState &&
5195 pendingUserInputs.length === 0 &&
51965305 (uncommittedSnapShotIds.length > 0 ||
51975306 composerVideos.length > 0 ||
51985307 expandedComposerImages.length > 0) && (
......@@ -5310,7 +5419,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
53105419 retryAttachmentUpload({
53115420 environmentId,
53125421 image,
5313 draftTarget: composerDraftTarget,
5422 draftTarget: attachmentDraftTarget,
53145423 })
53155424 }
53165425 aria-label={`Retry upload for ${image.name}`}
......@@ -5410,7 +5519,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
54105519 retryAttachmentUpload({
54115520 environmentId,
54125521 image: file,
5413 draftTarget: composerDraftTarget,
5522 draftTarget: attachmentDraftTarget,
54145523 })
54155524 }
54165525 aria-label={`Retry upload for ${file.name}`}
......@@ -5444,7 +5553,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
54445553
54455554 {!isComposerCollapsedMobile &&
54465555 !isComposerApprovalState &&
5447 pendingUserInputs.length === 0 &&
54485556 composerOtherFiles.length > 0 && (
54495557 <div className="mb-3 flex flex-col gap-1">
54505558 {composerOtherFiles.map((file) => {
......@@ -5487,7 +5595,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
54875595 retryAttachmentUpload({
54885596 environmentId,
54895597 image: file,
5490 draftTarget: composerDraftTarget,
5598 draftTarget: attachmentDraftTarget,
54915599 })
54925600 }
54935601 aria-label={`Retry upload for ${file.name}`}
......@@ -5587,7 +5695,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
55875695 isConnecting ||
55885696 isComposerApprovalState ||
55895697 projectSelectionRequired ||
5590 isChoiceOnlyPendingQuestion
5698 isChoiceOnlyPendingQuestion ||
5699 activePendingIsResponding
55915700 }
55925701 />
55935702 {isComposerResting ? collapsedComposerImagePreviews : null}
apps/web/src/components/chat/MessagesTimeline.test.tsx+76-1
......@@ -1,4 +1,10 @@
1import { CheckpointRef, EnvironmentId, MessageId, TurnId } from "@t3tools/contracts";
1import {
2 ApprovalRequestId,
3 CheckpointRef,
4 EnvironmentId,
5 MessageId,
6 TurnId,
7} from "@t3tools/contracts";
28import { act, createRef, useLayoutEffect, type ReactNode, type Ref } from "react";
39import { renderToStaticMarkup } from "react-dom/server";
410import { create, type ReactTestRenderer } from "react-test-renderer";
......@@ -266,6 +272,75 @@ function buildSnapShotTimelineEntry(previewUrl?: string) {
266272}
267273
268274describe("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
269344 it.each([
270345 { toolLifecycleStatus: "inProgress", isAtEnd: true },
271346 { toolLifecycleStatus: "inProgress", isAtEnd: false },
apps/web/src/components/chat/MessagesTimeline.tsx+70-1
......@@ -3216,7 +3216,9 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
32163216 showWarningIndicator || showDestructiveRowStyle
32173217 ? undefined
32183218 : (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));
32203222 const viewedImagePath = workEntryViewedImagePath(workEntry);
32213223 const viewedImage =
32223224 viewedImagePath && threadRef
......@@ -3362,6 +3364,9 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
33623364 />
33633365 </div>
33643366 ) : null}
3367 {workEntry.questionAnswer ? (
3368 <QuestionAnswerHistory answer={workEntry.questionAnswer} />
3369 ) : null}
33653370 {expanded && canExpand && expandedBody ? (
33663371 <div
33673372 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: {
33743379 </div>
33753380 );
33763381});
3382
3383function 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", () => {
339339 }
340340 });
341341
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
342368 it("verifies an uploaded file reference before restoring it", async () => {
343369 const file: ComposerFileAttachment = {
344370 ...makeFile("restored"),
apps/web/src/pendingUserInput.test.ts+19
......@@ -302,3 +302,22 @@ describe("pending user input question progress", () => {
302302 });
303303 });
304304});
305
306it("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";
33export interface PendingUserInputDraftAnswer {
44 selectedOptionValues?: string[];
55 customAnswer?: string;
6 attachmentCount?: number;
7 attachmentsBlocked?: boolean;
68}
79
810export interface PendingUserInputProgress {
......@@ -41,6 +43,7 @@ export function resolvePendingUserInputAnswer(
4143 question: UserInputQuestion,
4244 draft: PendingUserInputDraftAnswer | undefined,
4345): string | string[] | null {
46 if (draft?.attachmentsBlocked) return null;
4447 const customAnswer =
4548 question.allowCustomAnswer === false ? null : normalizeDraftAnswer(draft?.customAnswer);
4649 if (customAnswer) {
......@@ -51,10 +54,17 @@ export function resolvePendingUserInputAnswer(
5154 (value) => question.options.some((option) => (option.value ?? option.label) === value),
5255 );
5356 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;
5562 }
5663
57 return selectedOptionValues[0] ?? null;
64 return (
65 selectedOptionValues[0] ??
66 (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null)
67 );
5868}
5969
6070export function setPendingUserInputCustomAnswer(
apps/web/src/questionAttachments.test.ts created+100
......@@ -0,0 +1,100 @@
1import { ApprovalRequestId, EnvironmentId, ThreadId } from "@t3tools/contracts";
2import { beforeEach, expect, it, vi } from "vite-plus/test";
3import { useComposerDraftStore } from "./composerDraftStore";
4import {
5 questionAttachmentDraftId,
6 questionAttachmentDraftPrefix,
7 changeQuestionAttachmentPreparation,
8 clearQuestionAttachmentDraft,
9 useQuestionAttachmentPreparation,
10} from "./questionAttachments";
11
12const release = vi.hoisted(() => vi.fn());
13vi.mock("./lib/attachmentUploadQueue", () => ({ releaseDraftAttachments: release }));
14const environmentId = EnvironmentId.make("environment-1");
15const threadId = ThreadId.make("thread-1");
16const requestId = ApprovalRequestId.make("request-1");
17beforeEach(() => {
18 useComposerDraftStore.setState({ draftsByThreadKey: {}, draftThreadsByThreadKey: {} });
19 useQuestionAttachmentPreparation.setState({ counts: {} });
20 release.mockClear();
21});
22it("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});
60it("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});
68it("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
85it("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 @@
1import type { ApprovalRequestId, EnvironmentId, ThreadId } from "@t3tools/contracts";
2import { create } from "zustand";
3import { DraftId, useComposerDraftStore } from "./composerDraftStore";
4import { releaseDraftAttachments } from "./lib/attachmentUploadQueue";
5
6export function questionAttachmentDraftPrefix(
7 environmentId: EnvironmentId,
8 threadId: ThreadId,
9): string {
10 return `${encodeURIComponent(JSON.stringify(environmentId))}:question-${encodeURIComponent(JSON.stringify(threadId))}-`;
11}
12
13export 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
24export const useQuestionAttachmentPreparation = create<{ counts: Record<string, number> }>(() => ({
25 counts: {},
26}));
27
28export 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
38export 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 {
22 requestKindFromRequestType,
33 type PendingApproval,
44} from "@t3tools/client-runtime/pending-requests";
5import { UserInputAttachmentAnswerPayload } from "@t3tools/contracts";
56import * as Option from "effect/Option";
7import * as Schema from "effect/Schema";
68import * as Arr from "effect/Array";
79import { shallow } from "zustand/vanilla/shallow";
810import { isBackgroundTaskActivity } from "@t3tools/client-runtime/state/subagentRuntime";
......@@ -51,6 +53,7 @@ export {
5153} from "@t3tools/client-runtime/work-log/presentation";
5254
5355export interface WorkLogEntry {
56 questionAnswer?: UserInputAttachmentAnswerPayload;
5457 id: string;
5558 createdAt: string;
5659 turnId?: TurnId | null;
......@@ -492,6 +495,8 @@ function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): bool
492495 return typeof payload?.detail === "string" && payload.detail.startsWith("ExitPlanMode:");
493496}
494497
498const decodeQuestionAttachmentAnswer = Schema.decodeUnknownOption(UserInputAttachmentAnswerPayload);
499
495500function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWorkLogEntry {
496501 const cachedEntry = derivedWorkLogEntryByActivity.get(activity);
497502 if (cachedEntry) {
......@@ -543,6 +548,10 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
543548 : activity.tone,
544549 sourceActivityKind: activity.kind,
545550 };
551 if (activity.kind === "user-input.answer-submitted") {
552 const answer = decodeQuestionAttachmentAnswer(payload);
553 if (Option.isSome(answer)) entry.questionAnswer = answer.value;
554 }
546555 const itemType = extractWorkLogItemType(payload);
547556 const requestKind = extractWorkLogRequestKind(payload);
548557 const viewedImagePath = asTrimmedString(asRecord(payload?.data)?.imagePath);
docs/user/question-attachments.md created+9
......@@ -0,0 +1,9 @@
1# Files in question answers
2
3When 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
5Each 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
7Files 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
9Submitted 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({
8080 connectionProbe: Schema.optionalKey(Schema.Boolean),
8181 /** Missing on older servers, which still accept inline image attachments. */
8282 attachmentUploads: Schema.optionalKey(Schema.Boolean),
83 /** Uploaded files may accompany question answers. */
84 questionAttachments: Schema.optionalKey(Schema.Boolean),
8385 /** Missing on servers that only accept image attachments. */
8486 fileAttachments: Schema.optionalKey(
8587 Schema.Struct({
packages/contracts/src/orchestration.ts+18
......@@ -354,6 +354,22 @@ export const ChatAttachment = Schema.Union([
354354 ChatUnknownAttachment,
355355]);
356356export type ChatAttachment = typeof ChatAttachment.Type;
357
358export 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);
364export type UserInputAttachments = typeof UserInputAttachments.Type;
365
366export const UserInputAttachmentAnswerPayload = Schema.Struct({
367 requestId: ApprovalRequestId,
368 questionTextById: Schema.optional(Schema.Record(Schema.String, Schema.String)),
369 answers: ProviderUserInputAnswers,
370 attachmentsByQuestionId: UserInputAttachments,
371});
372export type UserInputAttachmentAnswerPayload = typeof UserInputAttachmentAnswerPayload.Type;
357373const UploadChatAttachment = Schema.Union([UploadChatImageAttachment]);
358374export type UploadChatAttachment = typeof UploadChatAttachment.Type;
359375
......@@ -1139,6 +1155,7 @@ const ThreadUserInputRespondCommand = Schema.Struct({
11391155 threadId: ThreadId,
11401156 requestId: ApprovalRequestId,
11411157 answers: ProviderUserInputAnswers,
1158 attachmentsByQuestionId: Schema.optional(UserInputAttachments),
11421159 createdAt: IsoDateTime,
11431160});
11441161
......@@ -1585,6 +1602,7 @@ const ThreadUserInputResponseRequestedPayload = Schema.Struct({
15851602 threadId: ThreadId,
15861603 requestId: ApprovalRequestId,
15871604 answers: ProviderUserInputAnswers,
1605 attachmentsByQuestionId: Schema.optional(UserInputAttachments),
15881606 createdAt: IsoDateTime,
15891607});
15901608
packages/contracts/src/provider.ts+2
......@@ -19,6 +19,7 @@ import {
1919 ProviderRequestKind,
2020 ProviderSandboxMode,
2121 ProviderUserInputAnswers,
22 UserInputAttachments,
2223 RuntimeMode,
2324} from "./orchestration.ts";
2425import { ProviderInstanceId, ProviderDriverKind } from "./providerInstance.ts";
......@@ -110,6 +111,7 @@ export const ProviderRespondToUserInputInput = Schema.Struct({
110111 threadId: ThreadId,
111112 requestId: ApprovalRequestId,
112113 answers: ProviderUserInputAnswers,
114 attachmentsByQuestionId: Schema.optional(UserInputAttachments),
113115});
114116export type ProviderRespondToUserInputInput = typeof ProviderRespondToUserInputInput.Type;
115117
pnpm-lock.yaml+3
......@@ -473,6 +473,9 @@ importers:
473473 '@types/react':
474474 specifier: ~19.2.0
475475 version: 19.2.16
476 '@types/react-dom':
477 specifier: ~19.2.3
478 version: 19.2.3(@types/react@19.2.16)
476479 babel-preset-expo:
477480 specifier: ~57.0.9
478481 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)