Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions apps/mobile/src/features/threads/QuestionAnswerHistory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
UserInputAttachments,
} from "@t3tools/contracts";
import { Image, Linking, Pressable, View } from "react-native";
import { getQuestionAnswerText } from "@t3tools/client-runtime/work-log/user-input";
import { AppText as Text } from "../../components/AppText";
import { useAssetUrl } from "../../state/assets";

Expand Down Expand Up @@ -41,6 +42,7 @@ export function QuestionAnswerHistory(props: {
<View className="gap-2">
{[
...new Set([
...Object.keys(props.answer.questionTextById ?? {}),
...Object.keys(props.answer.answers),
...Object.keys(props.answer.attachmentsByQuestionId),
]),
Expand All @@ -51,12 +53,11 @@ export function QuestionAnswerHistory(props: {
{props.answer.questionTextById[questionId]}
</Text>
) : null}
<Text className="text-sm text-foreground">
{[props.answer.answers[questionId]]
.flat()
.filter((value): value is string => typeof value === "string")
.join(", ")}
</Text>
{getQuestionAnswerText(props.answer.answers[questionId]) ? (
<Text className="text-sm text-foreground">
{getQuestionAnswerText(props.answer.answers[questionId])}
</Text>
) : null}
{(props.answer.attachmentsByQuestionId[questionId] ?? []).map((attachment) => (
<AnswerFile
key={attachment.id}
Expand Down
20 changes: 19 additions & 1 deletion apps/mobile/src/features/threads/thread-work-log.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { QuestionAnswerHistory } from "./QuestionAnswerHistory";
import {
getQuestionAnswerPreview,
hasQuestionAnswer,
} from "@t3tools/client-runtime/work-log/user-input";
import * as Haptics from "expo-haptics";
import { Image } from "expo-image";
import { type AppSymbolName, SymbolView } from "../../components/AppSymbol";
Expand Down Expand Up @@ -743,6 +747,10 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
const viewedImagePath = workEntryViewedImagePath(row.workEntry);
const toolPresentation = resolveWorkEntryToolPresentation(row.workEntry);
const previewText = workEntryRowLabel(row.workEntry);
const answerPreview = row.workEntry.questionAnswer
? getQuestionAnswerPreview(row.workEntry.questionAnswer)
: null;
const accessiblePreview = [previewText, answerPreview].filter(Boolean).join(": ");
const displayText = workEntryRowLabel(row.workEntry, expanded);
const iconIsDestructive = row.icon === "alert" || row.icon === "warning";
const failed = row.status === "failure";
Expand All @@ -757,7 +765,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
>
<Pressable
accessibilityRole={canExpand ? "button" : undefined}
accessibilityLabel={failed ? `${previewText}, tool call failed` : previewText}
accessibilityLabel={failed ? `${accessiblePreview}, tool call failed` : accessiblePreview}
accessibilityHint={
canExpand
? `Double tap to ${expanded ? "hide" : "show"} full details. Long press to copy.`
Expand Down Expand Up @@ -818,6 +826,16 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
numberOfLines={expanded ? undefined : 1}
>
{displayText}
{answerPreview ? (
<Text
className={
row.workEntry.questionAnswer &&
hasQuestionAnswer(row.workEntry.questionAnswer)
? "text-foreground"
: "text-foreground-subtle"
}
>{` ${answerPreview}`}</Text>
) : null}
</Text>
</>
)}
Expand Down
136 changes: 136 additions & 0 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,142 @@ function makeThread(
}

describe("buildThreadFeed", () => {
it("keeps a submitted answer visible until the same-turn assistant resumes", () => {
const turnId = TurnId.make("answer-turn");
const startedAt = "2026-04-01T00:00:00.000Z";
const submittedAt = "2026-04-01T00:00:03.000Z";
const latestTurn = { turnId, state: "running" as const, startedAt, completedAt: null };
const thread = makeThread({
id: ThreadId.make("answer-thread"),
projectId: ProjectId.make("project-1"),
title: "Answer visibility",
messages: [
{
id: MessageId.make("assistant-question"),
role: "assistant",
text: "Choose a runtime.",
turnId,
streaming: true,
createdAt: startedAt,
updatedAt: startedAt,
},
],
activities: [
makeActivity({
id: EventId.make("previous-tool"),
kind: "tool.completed",
tone: "tool",
summary: "Read files",
createdAt: "2026-04-01T00:00:01.000Z",
turnId,
}),
makeActivity({
id: EventId.make("answer"),
kind: "user-input.answer-submitted",
summary: "User input submitted",
createdAt: submittedAt,
turnId,
payload: {
requestId: "runtime-question",
questionTextById: { runtime: "Which runtime?" },
answers: { runtime: "Node.js" },
attachmentsByQuestionId: {},
},
}),
],
});
const feed = buildThreadFeed(thread);
const present = (source: ReadonlyArray<ThreadFeedEntry>) =>
deriveThreadFeedPresentation(source, latestTurn, new Set(), new Set(), startedAt);
const answerIsVisible = (rows: ReadonlyArray<ThreadFeedEntry>) =>
rows.some(
(row) =>
row.type === "activity-group" &&
row.activities.some((activity) => activity.workEntry.questionAnswer),
);
expect(answerIsVisible(present(feed))).toBe(true);
const updatedFeed = (messageTurnId: TurnId, text = "Using Node.js.") =>
feed.map((entry) =>
entry.type === "message"
? {
...entry,
message: {
...entry.message,
text,
turnId: messageTurnId,
updatedAt: "2026-04-01T00:00:04.000Z",
},
}
: entry,
);
expect(answerIsVisible(present(updatedFeed(TurnId.make("other-turn"))))).toBe(true);
expect(answerIsVisible(present(updatedFeed(turnId, "")))).toBe(true);
// Reuse the activity group to exercise presentation-cache invalidation.
expect(answerIsVisible(present(updatedFeed(turnId)))).toBe(false);
expect(answerIsVisible(deriveThreadFeedPresentation(feed, latestTurn, new Set()))).toBe(false);
expect(
answerIsVisible(
deriveThreadFeedPresentation(
feed,
{ ...latestTurn, state: "completed", completedAt: "2026-04-01T00:00:05.000Z" },
new Set(),
new Set(),
startedAt,
),
),
).toBe(false);
});

it("folds question answers into tool history and only hides the matching async reply", () => {
const createdAt = "2026-04-01T00:00:01.000Z";
const activities = [
makeActivity({
id: EventId.make("question-request"),
kind: "user-input.requested",
summary: "User input requested",
createdAt,
turnId: TurnId.make("question-turn"),
payload: {
requestId: "question-1",
questions: [{ id: "scope", question: "Which scope?" }],
},
}),
makeActivity({
id: EventId.make("question-response"),
kind: "user-input.resolved",
summary: "User input submitted",
createdAt: "2026-04-01T00:00:02.000Z",
turnId: TurnId.make("question-turn"),
payload: { requestId: "question-1", answers: { scope: "Web" } },
}),
];
const messages = ["async-answer:question-1", "real-user-message", "async-answer:unloaded"].map(
(id) => ({
id: MessageId.make(id),
role: "user" as const,
text: "Web",
turnId: TurnId.make("answer-turn"),
createdAt: "2026-04-01T00:00:03.000Z",
updatedAt: "2026-04-01T00:00:03.000Z",
streaming: false,
}),
);
const feed = buildThreadFeed({ messages, activities });
const rows = feed.flatMap((entry) => (entry.type === "activity-group" ? entry.activities : []));
expect(rows).toHaveLength(1);
expect(rows[0]?.icon).toBe("message");
expect(rows[0]?.workEntry).toMatchObject({
tone: "tool",
label: "User input submitted",
questionAnswer: { questionTextById: { scope: "Which scope?" }, answers: { scope: "Web" } },
});
expect(feed.flatMap((entry) => (entry.type === "message" ? [entry.message.id] : []))).toEqual([
"real-user-message",
"async-answer:unloaded",
]);
expect(buildThreadFeed({ messages, activities: [] })).toHaveLength(3);
});

it("reuses unchanged feed and presentation rows during an assistant text update", () => {
const completedTurnId = TurnId.make("completed-turn");
const activeTurnId = TurnId.make("active-turn");
Expand Down
Loading
Loading