authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-10 23:38:42-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-10 23:38:42-03:00
log27eb79dc719d70a99db495b0cb20bd6e3ac10b8d
treec7132905631c7e42cec359675d6d118991f83b83
parent6a2d2466673c010a15cb530fa2a8f77b363e046d
signature Signed by PGP key B5690EEEBB952194

fix(chat): fold question answers into tool activity (#11014)


10 files changed, 368 insertions(+), 45 deletions(-)

apps/mobile/src/features/threads/QuestionAnswerHistory.tsx+7-6
...@@ -4,6 +4,7 @@ import type {...@@ -4,6 +4,7 @@ import type {
4 UserInputAttachments,4 UserInputAttachments,
5} from "@t3tools/contracts";5} from "@t3tools/contracts";
6import { Image, Linking, Pressable, View } from "react-native";6import { Image, Linking, Pressable, View } from "react-native";
7import { getQuestionAnswerText } from "@t3tools/client-runtime/work-log/user-input";
7import { AppText as Text } from "../../components/AppText";8import { AppText as Text } from "../../components/AppText";
8import { useAssetUrl } from "../../state/assets";9import { useAssetUrl } from "../../state/assets";
910
...@@ -41,6 +42,7 @@ export function QuestionAnswerHistory(props: {...@@ -41,6 +42,7 @@ export function QuestionAnswerHistory(props: {
41 <View className="gap-2">42 <View className="gap-2">
42 {[43 {[
43 ...new Set([44 ...new Set([
45 ...Object.keys(props.answer.questionTextById ?? {}),
44 ...Object.keys(props.answer.answers),46 ...Object.keys(props.answer.answers),
45 ...Object.keys(props.answer.attachmentsByQuestionId),47 ...Object.keys(props.answer.attachmentsByQuestionId),
46 ]),48 ]),
...@@ -51,12 +53,11 @@ export function QuestionAnswerHistory(props: {...@@ -51,12 +53,11 @@ export function QuestionAnswerHistory(props: {
51 {props.answer.questionTextById[questionId]}53 {props.answer.questionTextById[questionId]}
52 </Text>54 </Text>
53 ) : null}55 ) : null}
54 <Text className="text-sm text-foreground">56 {getQuestionAnswerText(props.answer.answers[questionId]) ? (
55 {[props.answer.answers[questionId]]57 <Text className="ml-3 text-sm text-foreground-muted">
56 .flat()58 {getQuestionAnswerText(props.answer.answers[questionId])}
57 .filter((value): value is string => typeof value === "string")59 </Text>
58 .join(", ")}60 ) : null}
59 </Text>
60 {(props.answer.attachmentsByQuestionId[questionId] ?? []).map((attachment) => (61 {(props.answer.attachmentsByQuestionId[questionId] ?? []).map((attachment) => (
61 <AnswerFile62 <AnswerFile
62 key={attachment.id}63 key={attachment.id}
apps/mobile/src/features/threads/thread-work-log.tsx+20-1
...@@ -1,4 +1,8 @@...@@ -1,4 +1,8 @@
1import { QuestionAnswerHistory } from "./QuestionAnswerHistory";1import { QuestionAnswerHistory } from "./QuestionAnswerHistory";
2import {
3 getQuestionAnswerPreview,
4 hasQuestionAnswer,
5} from "@t3tools/client-runtime/work-log/user-input";
2import * as Haptics from "expo-haptics";6import * as Haptics from "expo-haptics";
3import { Image } from "expo-image";7import { Image } from "expo-image";
4import { type AppSymbolName, SymbolView } from "../../components/AppSymbol";8import { type AppSymbolName, SymbolView } from "../../components/AppSymbol";
...@@ -745,6 +749,10 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(...@@ -745,6 +749,10 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
745 const viewedImagePath = workEntryViewedImagePath(row.workEntry);749 const viewedImagePath = workEntryViewedImagePath(row.workEntry);
746 const toolPresentation = resolveWorkEntryToolPresentation(row.workEntry);750 const toolPresentation = resolveWorkEntryToolPresentation(row.workEntry);
747 const previewText = workEntryRowLabel(row.workEntry);751 const previewText = workEntryRowLabel(row.workEntry);
752 const answerPreview = row.workEntry.questionAnswer
753 ? getQuestionAnswerPreview(row.workEntry.questionAnswer)
754 : null;
755 const accessiblePreview = [previewText, answerPreview].filter(Boolean).join(": ");
748 const displayText = workEntryRowLabel(row.workEntry, expanded);756 const displayText = workEntryRowLabel(row.workEntry, expanded);
749 const iconIsDestructive = row.icon === "alert" || row.icon === "warning";757 const iconIsDestructive = row.icon === "alert" || row.icon === "warning";
750 const failed = row.status === "failure";758 const failed = row.status === "failure";
...@@ -759,7 +767,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(...@@ -759,7 +767,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
759 >767 >
760 <Pressable768 <Pressable
761 accessibilityRole={canExpand ? "button" : undefined}769 accessibilityRole={canExpand ? "button" : undefined}
762 accessibilityLabel={failed ? `${previewText}, tool call failed` : previewText}770 accessibilityLabel={failed ? `${accessiblePreview}, tool call failed` : accessiblePreview}
763 accessibilityHint={771 accessibilityHint={
764 canExpand772 canExpand
765 ? `Double tap to ${expanded ? "hide" : "show"} full details. Long press to copy.`773 ? `Double tap to ${expanded ? "hide" : "show"} full details. Long press to copy.`
...@@ -820,6 +828,17 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(...@@ -820,6 +828,17 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
820 numberOfLines={expanded ? undefined : 1}828 numberOfLines={expanded ? undefined : 1}
821 >829 >
822 {displayText}830 {displayText}
831 {answerPreview ? (
832 <Text
833 className={
834 !expanded &&
835 row.workEntry.questionAnswer &&
836 hasQuestionAnswer(row.workEntry.questionAnswer)
837 ? "text-foreground"
838 : "text-foreground-subtle"
839 }
840 >{` ${answerPreview}`}</Text>
841 ) : null}
823 </Text>842 </Text>
824 </>843 </>
825 )}844 )}
apps/mobile/src/lib/threadActivity.ts+25-14
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import * as Option from "effect/Option";1import * as Option from "effect/Option";
2import { foldUserInputActivities } from "@t3tools/client-runtime/work-log/user-input";
2import * as Schema from "effect/Schema";3import * as Schema from "effect/Schema";
3import {4import {
4 requestKindFromRequestType,5 requestKindFromRequestType,
...@@ -405,7 +406,7 @@ function deriveWorkLogEntries(...@@ -405,7 +406,7 @@ function deriveWorkLogEntries(
405): DerivedWorkLogEntry[] {406): DerivedWorkLogEntry[] {
406 const ordered = Arr.sort(activities, activityOrder);407 const ordered = Arr.sort(activities, activityOrder);
407 const entries: DerivedWorkLogEntry[] = [];408 const entries: DerivedWorkLogEntry[] = [];
408 for (const activity of ordered) {409 for (const activity of foldUserInputActivities(ordered)) {
409 if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue;410 if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue;
410 if (activity.kind === "tool.started") continue;411 if (activity.kind === "tool.started") continue;
411 // Like web: an agent's task.started row anchors its batch. It has a fixed412 // Like web: an agent's task.started row anchors its batch. It has a fixed
...@@ -936,6 +937,7 @@ function workEntryStatus(entry: WorkLogEntry): ThreadFeedActivity["status"] {...@@ -936,6 +937,7 @@ function workEntryStatus(entry: WorkLogEntry): ThreadFeedActivity["status"] {
936function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] {937function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] {
937 if (entry.agentSpawn) return "agent";938 if (entry.agentSpawn) return "agent";
938 if (939 if (
940 entry.questionAnswer ||
939 entry.sourceActivityKind === "user-input.requested" ||941 entry.sourceActivityKind === "user-input.requested" ||
940 entry.sourceActivityKind === "user-input.resolved"942 entry.sourceActivityKind === "user-input.resolved"
941 ) {943 ) {
...@@ -2184,21 +2186,30 @@ export function buildThreadFeed(...@@ -2184,21 +2186,30 @@ export function buildThreadFeed(
2184 : loadedMessages;2186 : loadedMessages;
2185 const oldestLoadedMessageCreatedAt =2187 const oldestLoadedMessageCreatedAt =
2186 options?.loadedMessages !== undefined ? (loadedMessages[0]?.createdAt ?? null) : null;2188 options?.loadedMessages !== undefined ? (loadedMessages[0]?.createdAt ?? null) : null;
2187 const activityEntries = getThreadFeedActivityEntries(thread.activities);2189 const activityEntries = getThreadFeedActivityEntries(thread.activities).filter(
2190 (entry) =>
2191 oldestLoadedMessageCreatedAt === null || entry.createdAt >= oldestLoadedMessageCreatedAt,
2192 );
2193 const foldedAnswerMessageIds = new Set(
2194 activityEntries.flatMap((entry) =>
2195 entry.activity.workEntry.questionAnswer
2196 ? [`async-answer:${entry.activity.workEntry.questionAnswer.requestId}`]
2197 : [],
2198 ),
2199 );
2188 const entries = Arr.sortWith(2200 const entries = Arr.sortWith(
2189 [2201 [
2190 ...messages.map((message) => {2202 ...messages
2191 let entry = messageEntriesCache.get(message);2203 .filter((message) => message.role !== "user" || !foldedAnswerMessageIds.has(message.id))
2192 if (!entry) {2204 .map((message) => {
2193 entry = { type: "message", id: message.id, createdAt: message.createdAt, message };2205 let entry = messageEntriesCache.get(message);
2194 messageEntriesCache.set(message, entry);2206 if (!entry) {
2195 }2207 entry = { type: "message", id: message.id, createdAt: message.createdAt, message };
2196 return entry;2208 messageEntriesCache.set(message, entry);
2197 }),2209 }
2198 ...activityEntries.filter(2210 return entry;
2199 (entry) =>2211 }),
2200 oldestLoadedMessageCreatedAt === null || entry.createdAt >= oldestLoadedMessageCreatedAt,2212 ...activityEntries,
2201 ),
2202 ],2213 ],
2203 (s) => new Date(s.createdAt),2214 (s) => new Date(s.createdAt),
2204 Order.Date,2215 Order.Date,
apps/server/src/orchestration/ActivityPayloadProjection.ts+4-2
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { projectQuestionToolInput } from "@t3tools/shared/toolActivity";
1import type {2import type {
2 OrchestrationEvent,3 OrchestrationEvent,
3 OrchestrationThreadActivity,4 OrchestrationThreadActivity,
...@@ -370,18 +371,19 @@ export function projectActivityPayload(...@@ -370,18 +371,19 @@ export function projectActivityPayload(
370 payload.status === "completed" && (itemStatus === "failed" || itemStatus === "declined")371 payload.status === "completed" && (itemStatus === "failed" || itemStatus === "declined")
371 ? { ...payload, status: itemStatus }372 ? { ...payload, status: itemStatus }
372 : payload;373 : payload;
374 const questionInput = projectQuestionToolInput(data, payload.title);
373375
374 if (payload.itemType === "mcp_tool_call") {376 if (payload.itemType === "mcp_tool_call") {
375 return {377 return {
376 ...activity,378 ...activity,
377 payload: {379 payload: {
378 ...projectedPayload,380 ...projectedPayload,
379 data: projectMcpToolCallData(data),381 data: { ...projectMcpToolCallData(data), ...questionInput },
380 },382 },
381 };383 };
382 }384 }
383385
384 const projectedData: Record<string, unknown> = {};386 const projectedData: Record<string, unknown> = { ...questionInput };
385 const item = projectCommandData(data);387 const item = projectCommandData(data);
386 if (item) {388 if (item) {
387 projectedData.item = item;389 projectedData.item = item;
apps/web/src/components/chat/MessagesTimeline.test.tsx+13-1
...@@ -348,12 +348,24 @@ describe("MessagesTimeline", () => {...@@ -348,12 +348,24 @@ describe("MessagesTimeline", () => {
348 });348 });
349 const toggle = renderer!.root.findByProps({ "aria-expanded": false });349 const toggle = renderer!.root.findByProps({ "aria-expanded": false });
350 await act(() => toggle.props.onClick());350 await act(() => toggle.props.onClick());
351 const questionToggle = renderer!.root.find(
352 (node) =>
353 node.props["aria-label"]?.startsWith("Question answer submitted:") &&
354 node.props["aria-expanded"] === false,
355 );
356 expect(questionToggle.props["aria-label"]).toContain(
357 Object.values(answers)[0] ?? "spec.txt",
358 );
359 expect(JSON.stringify(renderer!.toJSON())).not.toContain("Provide a spec");
360 await act(() => questionToggle.props.onClick());
351 const markup = JSON.stringify(renderer!.toJSON());361 const markup = JSON.stringify(renderer!.toJSON());
352 expect(markup.match(/Provide a spec/g)).toHaveLength(1);362 expect(markup.match(/Provide a spec/g)).toHaveLength(1);
353 expect(markup.match(/spec\.txt/g)).toHaveLength(1);363 expect(markup).toContain("spec.txt");
354 expect(markup).toContain("Provide a screenshot");364 expect(markup).toContain("Provide a screenshot");
355 expect(markup).toContain("shot.png");365 expect(markup).toContain("shot.png");
356 for (const answer of Object.values(answers)) expect(markup).toContain(answer);366 for (const answer of Object.values(answers)) expect(markup).toContain(answer);
367 await act(() => questionToggle.props.onClick());
368 expect(JSON.stringify(renderer!.toJSON())).not.toContain("Provide a spec");
357 } finally {369 } finally {
358 await act(() => renderer?.unmount());370 await act(() => renderer?.unmount());
359 }371 }
apps/web/src/components/chat/MessagesTimeline.tsx+61-18
...@@ -1,4 +1,9 @@...@@ -1,4 +1,9 @@
1import { GitPullRequestIcon } from "lucide-react";1import { GitPullRequestIcon } from "lucide-react";
2import {
3 getQuestionAnswerPreview,
4 getQuestionAnswerText,
5 hasQuestionAnswer,
6} from "@t3tools/client-runtime/work-log/user-input";
2import {7import {
3 type AssistantCitation,8 type AssistantCitation,
4 type EnvironmentId,9 type EnvironmentId,
...@@ -2115,7 +2120,7 @@ function LiveActivityRow({...@@ -2115,7 +2120,7 @@ function LiveActivityRow({
2115 active = false,2120 active = false,
2116 shimmer = false,2121 shimmer = false,
2117}: {2122}: {
2118 label: string;2123 label: ReactNode;
2119 iconName?: WorkEntryIconName;2124 iconName?: WorkEntryIconName;
2120 toolIcon?: ToolActivityIcon | undefined;2125 toolIcon?: ToolActivityIcon | undefined;
2121 failed?: boolean;2126 failed?: boolean;
...@@ -2155,7 +2160,7 @@ function LiveActivityContent({...@@ -2155,7 +2160,7 @@ function LiveActivityContent({
2155 active = false,2160 active = false,
2156 highlighted = false,2161 highlighted = false,
2157}: {2162}: {
2158 label: string;2163 label: ReactNode;
2159 iconName: WorkEntryIconName | undefined;2164 iconName: WorkEntryIconName | undefined;
2160 toolIcon?: ToolActivityIcon | undefined;2165 toolIcon?: ToolActivityIcon | undefined;
2161 failed?: boolean;2166 failed?: boolean;
...@@ -2213,7 +2218,25 @@ function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "...@@ -2213,7 +2218,25 @@ function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "
2213 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}2218 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
2214 >2219 >
2215 <LiveActivityRow2220 <LiveActivityRow
2216 label={label}2221 label={
2222 row.entry.questionAnswer ? (
2223 <span className="flex min-w-0 gap-1.5">
2224 <span className="shrink-0">{label}</span>
2225 <span
2226 className={cn(
2227 "truncate",
2228 !row.expanded && hasQuestionAnswer(row.entry.questionAnswer)
2229 ? "text-foreground"
2230 : "text-muted-foreground",
2231 )}
2232 >
2233 {getQuestionAnswerPreview(row.entry.questionAnswer)}
2234 </span>
2235 </span>
2236 ) : (
2237 label
2238 )
2239 }
2217 iconName={workEntryIconName(row.entry)}2240 iconName={workEntryIconName(row.entry)}
2218 toolIcon={row.entry.toolIcon ?? row.entry.toolSource?.icon}2241 toolIcon={row.entry.toolIcon ?? row.entry.toolSource?.icon}
2219 failed={failed}2242 failed={failed}
...@@ -3148,6 +3171,7 @@ const toolCallExpandedBodyClassName =...@@ -3148,6 +3171,7 @@ const toolCallExpandedBodyClassName =
31483171
3149function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName {3172function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName {
3150 if (3173 if (
3174 workEntry.questionAnswer ||
3151 workEntry.sourceActivityKind === "user-input.requested" ||3175 workEntry.sourceActivityKind === "user-input.requested" ||
3152 workEntry.sourceActivityKind === "user-input.resolved"3176 workEntry.sourceActivityKind === "user-input.resolved"
3153 ) {3177 ) {
...@@ -3325,9 +3349,10 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {...@@ -3325,9 +3349,10 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
3325 showWarningIndicator || showDestructiveRowStyle3349 showWarningIndicator || showDestructiveRowStyle
3326 ? undefined3350 ? undefined
3327 : (workEntry.toolIcon ?? workEntry.toolSource?.icon);3351 : (workEntry.toolIcon ?? workEntry.toolSource?.icon);
3328 const previewText = workEntry.questionAnswer3352 const previewText = displayLabel ?? workEntryDisplayLabel(workEntry, workspaceRoot);
3329 ? "Question answer submitted"3353 const answerPreview = workEntry.questionAnswer
3330 : (displayLabel ?? workEntryDisplayLabel(workEntry, workspaceRoot));3354 ? getQuestionAnswerPreview(workEntry.questionAnswer)
3355 : null;
3331 const viewedImagePath = workEntryViewedImagePath(workEntry);3356 const viewedImagePath = workEntryViewedImagePath(workEntry);
3332 const viewedImage =3357 const viewedImage =
3333 viewedImagePath && threadRef3358 viewedImagePath && threadRef
...@@ -3337,6 +3362,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {...@@ -3337,6 +3362,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
3337 })3362 })
3338 : null;3363 : null;
3339 const canExpand =3364 const canExpand =
3365 Boolean(workEntry.questionAnswer) ||
3340 (showFailedIndicator && previewText.trim().length > 0) ||3366 (showFailedIndicator && previewText.trim().length > 0) ||
3341 (workEntry.itemType === "mcp_tool_call" && workEntry.toolData !== undefined) ||3367 (workEntry.itemType === "mcp_tool_call" && workEntry.toolData !== undefined) ||
3342 Boolean(3368 Boolean(
...@@ -3374,9 +3400,10 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {...@@ -3374,9 +3400,10 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
3374 : workLogEntryIsToolLike(workEntry)3400 : workLogEntryIsToolLike(workEntry)
3375 ? "text-secondary-label"3401 ? "text-secondary-label"
3376 : "text-foreground/80";3402 : "text-foreground/80";
3403 const accessiblePreview = [previewText, answerPreview].filter(Boolean).join(": ");
3377 const accessibleDisplayText = showFailedIndicator3404 const accessibleDisplayText = showFailedIndicator
3378 ? `${previewText}, tool call failed`3405 ? `${accessiblePreview}, tool call failed`
3379 : previewText;3406 : accessiblePreview;
3380 const rowToggleProps = canExpand3407 const rowToggleProps = canExpand
3381 ? {3408 ? {
3382 role: "button" as const,3409 role: "button" as const,
...@@ -3421,7 +3448,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {...@@ -3421,7 +3448,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
3421 <p className="flex min-w-0 w-full items-baseline gap-1.5 text-sm leading-relaxed">3448 <p className="flex min-w-0 w-full items-baseline gap-1.5 text-sm leading-relaxed">
3422 <span3449 <span
3423 className={cn(3450 className={cn(
3424 "min-w-0 flex-1",3451 answerPreview ? "shrink-0" : "min-w-0 flex-1",
3425 expanded ? "whitespace-pre-wrap break-words select-text" : "truncate",3452 expanded ? "whitespace-pre-wrap break-words select-text" : "truncate",
3426 headingClass,3453 headingClass,
3427 )}3454 )}
...@@ -3430,6 +3457,20 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {...@@ -3430,6 +3457,20 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
3430 >3457 >
3431 {previewText}3458 {previewText}
3432 </span>3459 </span>
3460 {answerPreview ? (
3461 <span
3462 className={cn(
3463 "min-w-0 truncate",
3464 !expanded &&
3465 workEntry.questionAnswer &&
3466 hasQuestionAnswer(workEntry.questionAnswer)
3467 ? "text-foreground"
3468 : "text-muted-foreground",
3469 )}
3470 >
3471 {answerPreview}
3472 </span>
3473 ) : null}
3433 </p>3474 </p>
3434 </div>3475 </div>
3435 {showFailedIndicator &&3476 {showFailedIndicator &&
...@@ -3470,10 +3511,10 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {...@@ -3470,10 +3511,10 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
3470 />3511 />
3471 </div>3512 </div>
3472 ) : null}3513 ) : null}
3473 {workEntry.questionAnswer ? (3514 {expanded && workEntry.questionAnswer ? (
3474 <QuestionAnswerHistory answer={workEntry.questionAnswer} />3515 <QuestionAnswerHistory answer={workEntry.questionAnswer} />
3475 ) : null}3516 ) : null}
3476 {expanded && canExpand && expandedBody ? (3517 {expanded && canExpand && expandedBody && !workEntry.questionAnswer ? (
3477 <div3518 <div
3478 className="mt-1 ms-7 cursor-default rounded-md bg-muted/40 px-3 py-2"3519 className="mt-1 ms-7 cursor-default rounded-md bg-muted/40 px-3 py-2"
3479 onClick={stopRowToggle}3520 onClick={stopRowToggle}
...@@ -3506,20 +3547,22 @@ function QuestionAnswerHistory({...@@ -3506,20 +3547,22 @@ function QuestionAnswerHistory({
3506 <div className="ms-7 mt-2 space-y-2" onClick={stopRowToggle}>3547 <div className="ms-7 mt-2 space-y-2" onClick={stopRowToggle}>
3507 {[3548 {[
3508 ...new Set([3549 ...new Set([
3550 ...Object.keys(answer.questionTextById ?? {}),
3509 ...Object.keys(answer.answers),3551 ...Object.keys(answer.answers),
3510 ...Object.keys(answer.attachmentsByQuestionId),3552 ...Object.keys(answer.attachmentsByQuestionId),
3511 ]),3553 ]),
3512 ].map((questionId) => (3554 ].map((questionId) => (
3513 <div key={questionId} className="space-y-1">3555 <div key={questionId} className="space-y-1">
3514 {answer.questionTextById?.[questionId] ? (3556 {answer.questionTextById?.[questionId] ? (
3515 <p className="text-sm text-muted-foreground">{answer.questionTextById[questionId]}</p>3557 <p className="whitespace-pre-wrap text-sm text-muted-foreground">
3558 {answer.questionTextById[questionId]}
3559 </p>
3560 ) : null}
3561 {getQuestionAnswerText(answer.answers[questionId]) ? (
3562 <p className="ms-3 whitespace-pre-wrap text-sm text-muted-foreground">
3563 {getQuestionAnswerText(answer.answers[questionId])}
3564 </p>
3516 ) : null}3565 ) : null}
3517 <p className="whitespace-pre-wrap text-sm">
3518 {[answer.answers[questionId]]
3519 .flat()
3520 .filter((value): value is string => typeof value === "string")
3521 .join(", ")}
3522 </p>
3523 <div className="flex flex-wrap gap-2">3566 <div className="flex flex-wrap gap-2">
3524 {(answer.attachmentsByQuestionId[questionId] ?? []).map((attachment) => {3567 {(answer.attachmentsByQuestionId[questionId] ?? []).map((attachment) => {
3525 const url = urls[attachments.indexOf(attachment)];3568 const url = urls[attachments.indexOf(attachment)];
apps/web/src/session-logic.ts+15-3
...@@ -3,6 +3,7 @@ import {...@@ -3,6 +3,7 @@ import {
3 type PendingApproval,3 type PendingApproval,
4} from "@t3tools/client-runtime/pending-requests";4} from "@t3tools/client-runtime/pending-requests";
5import { UserInputAttachmentAnswerPayload } from "@t3tools/contracts";5import { UserInputAttachmentAnswerPayload } from "@t3tools/contracts";
6import { foldUserInputActivities } from "@t3tools/client-runtime/work-log/user-input";
6import * as Option from "effect/Option";7import * as Option from "effect/Option";
7import * as Schema from "effect/Schema";8import * as Schema from "effect/Schema";
8import * as Arr from "effect/Array";9import * as Arr from "effect/Array";
...@@ -451,7 +452,7 @@ export function deriveWorkLogEntries(...@@ -451,7 +452,7 @@ export function deriveWorkLogEntries(
451): WorkLogEntry[] {452): WorkLogEntry[] {
452 const ordered = [...activities].toSorted(compareActivitiesByOrder);453 const ordered = [...activities].toSorted(compareActivitiesByOrder);
453 const entries: DerivedWorkLogEntry[] = [];454 const entries: DerivedWorkLogEntry[] = [];
454 for (const activity of ordered) {455 for (const activity of foldUserInputActivities(ordered)) {
455 if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue;456 if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue;
456 if (activity.kind === "tool.started") continue;457 if (activity.kind === "tool.started") continue;
457 // Agent task.started rows are CTA seeds: they carry the true spawn turn,458 // Agent task.started rows are CTA seeds: they carry the true spawn turn,
...@@ -1622,14 +1623,25 @@ export function deriveTimelineEntriesWithState(...@@ -1622,14 +1623,25 @@ export function deriveTimelineEntriesWithState(
1622 const entries = replaceStreamingTimelineMessages(messages, previous);1623 const entries = replaceStreamingTimelineMessages(messages, previous);
1623 if (entries !== null) return { messages, proposedPlans, workEntries, entries };1624 if (entries !== null) return { messages, proposedPlans, workEntries, entries };
1624 }1625 }
1626 const foldedAnswerMessageIds = new Set(
1627 workEntries.flatMap((entry) =>
1628 entry.questionAnswer ? [`async-answer:${entry.questionAnswer.requestId}`] : [],
1629 ),
1630 );
1631 const showMessage = (message: ChatMessage) =>
1632 message.role !== "user" || !foldedAnswerMessageIds.has(message.id);
1625 const canAppend =1633 const canAppend =
1626 previous !== null &&1634 previous !== null &&
1635 !previous.entries.some((entry) => entry.kind === "message" && !showMessage(entry.message)) &&
1627 hasExactArrayPrefix(previous.messages, messages) &&1636 hasExactArrayPrefix(previous.messages, messages) &&
1628 hasExactArrayPrefix(previous.proposedPlans, proposedPlans) &&1637 hasExactArrayPrefix(previous.proposedPlans, proposedPlans) &&
1629 hasExactArrayPrefix(previous.workEntries, workEntries);1638 hasExactArrayPrefix(previous.workEntries, workEntries);
16301639
1631 if (canAppend) {1640 if (canAppend) {
1632 const messageRows = messages.slice(previous.messages.length).map(timelineEntryFromMessage);1641 const messageRows = messages
1642 .slice(previous.messages.length)
1643 .filter(showMessage)
1644 .map(timelineEntryFromMessage);
1633 const proposedPlanRows = proposedPlans1645 const proposedPlanRows = proposedPlans
1634 .slice(previous.proposedPlans.length)1646 .slice(previous.proposedPlans.length)
1635 .map(timelineEntryFromProposedPlan);1647 .map(timelineEntryFromProposedPlan);
...@@ -1645,7 +1657,7 @@ export function deriveTimelineEntriesWithState(...@@ -1645,7 +1657,7 @@ export function deriveTimelineEntriesWithState(
1645 };1657 };
1646 }1658 }
16471659
1648 const messageRows = messages.map(timelineEntryFromMessage);1660 const messageRows = messages.filter(showMessage).map(timelineEntryFromMessage);
1649 const proposedPlanRows = proposedPlans.map(timelineEntryFromProposedPlan);1661 const proposedPlanRows = proposedPlans.map(timelineEntryFromProposedPlan);
1650 const workRows = workEntries.map(timelineEntryFromWork);1662 const workRows = workEntries.map(timelineEntryFromWork);
1651 return {1663 return {
packages/client-runtime/package.json+4
...@@ -223,6 +223,10 @@...@@ -223,6 +223,10 @@
223 "types": "./src/state/vcs.ts",223 "types": "./src/state/vcs.ts",
224 "default": "./src/state/vcs.ts"224 "default": "./src/state/vcs.ts"
225 },225 },
226 "./work-log/user-input": {
227 "types": "./src/work-log/userInput.ts",
228 "default": "./src/work-log/userInput.ts"
229 },
226 "./work-log/presentation": {230 "./work-log/presentation": {
227 "types": "./src/work-log/presentation.ts",231 "types": "./src/work-log/presentation.ts",
228 "default": "./src/work-log/presentation.ts"232 "default": "./src/work-log/presentation.ts"
packages/client-runtime/src/work-log/userInput.ts created+186
...@@ -0,0 +1,186 @@
1import { projectQuestionToolInput } from "@t3tools/shared/toolActivity";
2import {
3 type OrchestrationThreadActivity,
4 UserInputAttachmentAnswerPayload,
5} from "@t3tools/contracts";
6import * as Schema from "effect/Schema";
7
8function record(value: unknown): Record<string, unknown> | undefined {
9 return value !== null && typeof value === "object" && !Array.isArray(value)
10 ? (value as Record<string, unknown>)
11 : undefined;
12}
13
14const isQuestionAnswer = Schema.is(UserInputAttachmentAnswerPayload);
15
16function displayOptionAnswer(value: unknown, labels: ReadonlyMap<string, string>): unknown {
17 if (typeof value === "string") return labels.get(value) ?? value;
18 if (Array.isArray(value)) return value.map((answer) => displayOptionAnswer(answer, labels));
19 const nested = record(value);
20 return nested && "answers" in nested
21 ? { ...nested, answers: displayOptionAnswer(nested.answers, labels) }
22 : value;
23}
24
25function questionFingerprint(
26 turnId: string,
27 questions: ReadonlyArray<unknown>,
28): string | undefined {
29 const texts = questions.map((question) => (typeof question === "string" ? question.trim() : ""));
30 return texts.length > 0 && texts.every(Boolean)
31 ? JSON.stringify([turnId, texts.toSorted()])
32 : undefined;
33}
34
35function withoutDuplicateQuestionTools(
36 activities: ReadonlyArray<OrchestrationThreadActivity>,
37): ReadonlyArray<OrchestrationThreadActivity> {
38 const questions = new Set<string>();
39 for (const activity of activities) {
40 if (activity.kind !== "user-input.answer-submitted" || !activity.turnId) continue;
41 const payload = record(activity.payload);
42 const texts = Object.values(record(payload?.questionTextById) ?? {});
43 const fingerprint = questionFingerprint(activity.turnId, texts);
44 if (fingerprint) questions.add(fingerprint);
45 }
46 if (questions.size === 0) return activities;
47 const duplicateToolIds = new Set<string>();
48 for (const activity of activities) {
49 if (!activity.kind.startsWith("tool.") || !activity.turnId) continue;
50 const payload = record(activity.payload);
51 if (typeof payload?.toolCallId !== "string") continue;
52 const input = projectQuestionToolInput(record(payload.data) ?? {}, payload.title).input;
53 if (!input) continue;
54 const fingerprint = questionFingerprint(
55 activity.turnId,
56 input.questions.map((question) => record(question)?.question),
57 );
58 if (fingerprint && questions.has(fingerprint)) {
59 duplicateToolIds.add(JSON.stringify([activity.turnId, payload.toolCallId]));
60 }
61 }
62 return activities.filter((activity) => {
63 const payload = record(activity.payload);
64 const toolCallId = payload?.toolCallId;
65 return (
66 activity.tone === "error" ||
67 /^(failed|declined|stopped|cancelled)$/.test(String(payload?.status)) ||
68 !activity.kind.startsWith("tool.") ||
69 typeof toolCallId !== "string" ||
70 !duplicateToolIds.has(JSON.stringify([activity.turnId, toolCallId]))
71 );
72 });
73}
74
75/** Keep a question and its answer at the original tool position in the work log. */
76export function foldUserInputActivities(
77 activities: ReadonlyArray<OrchestrationThreadActivity>,
78): ReadonlyArray<OrchestrationThreadActivity> {
79 const requests = new Map<string, OrchestrationThreadActivity[]>();
80 for (const activity of activities) {
81 if (
82 activity.kind !== "user-input.requested" &&
83 activity.kind !== "user-input.resolved" &&
84 activity.kind !== "user-input.answer-submitted"
85 )
86 continue;
87 const requestId = record(activity.payload)?.requestId;
88 if (typeof requestId !== "string" || !requestId) continue;
89 const group = requests.get(requestId) ?? [];
90 group.push(activity);
91 requests.set(requestId, group);
92 }
93 const replacements = new Map<OrchestrationThreadActivity, OrchestrationThreadActivity | null>();
94 for (const [requestId, group] of requests) {
95 const payloads = group.map((activity) => record(activity.payload)!);
96 const questions = new Map<string, Record<string, unknown>>();
97 const texts = new Map<string, unknown>();
98 for (const payload of payloads) {
99 for (const [id, text] of Object.entries(record(payload.questionTextById) ?? {}))
100 texts.set(id, text);
101 for (const value of Array.isArray(payload.questions) ? payload.questions : []) {
102 const question = record(value);
103 if (typeof question?.id !== "string") continue;
104 questions.set(question.id, question);
105 if (typeof question.question === "string") texts.set(question.id, question.question);
106 }
107 }
108 const questionTextById = Object.fromEntries(texts);
109 const submitted = group.findLast(
110 (activity) =>
111 activity.kind === "user-input.answer-submitted" &&
112 record(record(activity.payload)?.answers),
113 );
114 const rawAnswers =
115 record(record(submitted?.payload)?.answers) ??
116 payloads.map((payload) => record(payload.answers)).findLast(Boolean) ??
117 {};
118 const answers = Object.fromEntries(
119 Object.entries(rawAnswers).map(([id, value]) => {
120 const options = questions.get(id)?.options;
121 const labels = new Map<string, string>();
122 for (const candidate of Array.isArray(options) ? options : []) {
123 const option = record(candidate);
124 if (typeof option?.value === "string" && typeof option.label === "string")
125 labels.set(option.value, option.label);
126 }
127 return [id, displayOptionAnswer(value, labels)];
128 }),
129 );
130 const attachmentsByQuestionId = Object.fromEntries(
131 payloads.flatMap((payload) => Object.entries(record(payload.attachmentsByQuestionId) ?? {})),
132 );
133 const answer = { requestId, questionTextById, answers, attachmentsByQuestionId };
134 if (!isQuestionAnswer(answer)) continue;
135 const submittedAnswer =
136 Object.keys(answers).length > 0 || Object.keys(attachmentsByQuestionId).length > 0;
137 for (const activity of group) replacements.set(activity, null);
138 replacements.set(group[0]!, {
139 ...group[0]!,
140 kind: "user-input.answer-submitted",
141 tone: "tool",
142 summary: submittedAnswer
143 ? "User input submitted"
144 : group.some((activity) => activity.kind === "user-input.resolved")
145 ? "User input dismissed"
146 : "User input requested",
147 payload: answer,
148 });
149 }
150 return withoutDuplicateQuestionTools(
151 activities.flatMap((activity) => {
152 const replacement = replacements.get(activity);
153 return replacement === null ? [] : [replacement ?? activity];
154 }),
155 );
156}
157
158export function getQuestionAnswerText(value: unknown): string {
159 if (typeof value === "string") return value;
160 if (Array.isArray(value)) return value.map(getQuestionAnswerText).filter(Boolean).join(", ");
161 const nested = record(value);
162 return nested ? getQuestionAnswerText(nested.answers) : "";
163}
164
165export function getQuestionAnswerPreview(answer: UserInputAttachmentAnswerPayload): string {
166 const answers = Object.values(answer.answers).map(getQuestionAnswerText).filter(Boolean);
167 const attachments = Object.values(answer.attachmentsByQuestionId)
168 .flat()
169 .map((attachment) => attachment.name);
170 return (
171 answers.length > 0
172 ? answers.join(" · ")
173 : attachments.length > 0
174 ? attachments.join(", ")
175 : Object.values(answer.questionTextById ?? {}).join(" · ")
176 )
177 .replace(/\s+/g, " ")
178 .trim();
179}
180
181export function hasQuestionAnswer(answer: UserInputAttachmentAnswerPayload): boolean {
182 return (
183 Object.values(answer.answers).some(getQuestionAnswerText) ||
184 Object.values(answer.attachmentsByQuestionId).some((attachments) => attachments.length > 0)
185 );
186}
packages/shared/src/toolActivity.ts+33
...@@ -259,3 +259,36 @@ export function deriveToolActivityPresentation(...@@ -259,3 +259,36 @@ export function deriveToolActivityPresentation(
259 summary: title ?? fallbackSummary,259 summary: title ?? fallbackSummary,
260 };260 };
261}261}
262
263export function projectQuestionToolInput(data: Record<string, unknown>, title: unknown) {
264 const item = asRecord(data.item);
265 const toolName = data.toolName ?? data.tool ?? item?.tool ?? title;
266 if (typeof toolName !== "string") return {};
267 const name = toolName
268 .split(/__|[./]/)
269 .at(-1)
270 ?.replace(/[_\s]/g, "")
271 .toLowerCase();
272 if (!name || !/^(askuserquestion|requestuserinput(?:async)?|askquestion|question)$/.test(name))
273 return {};
274 const input = asRecord(
275 data.input ?? data.rawInput ?? asRecord(data.state)?.input ?? item?.arguments,
276 );
277 const questions = input?.questions ?? asRecord(input?.params)?.questions;
278 if (!Array.isArray(questions)) return {};
279 // Clients match native tools to the canonical question; choices and answers
280 // already live on the user-input activities and need not cross the wire twice.
281 return {
282 toolName,
283 input: {
284 questions: questions.map((value) => {
285 const question = asRecord(value);
286 return {
287 question: asTrimmedString(
288 question?.question ?? question?.question_text ?? question?.prompt ?? question?.title,
289 ),
290 };
291 }),
292 },
293 };
294}