Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
63 changes: 63 additions & 0 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import { scopeThreadRef } from "@t3tools/client-runtime/environment";
import type { HandoffEndpoints } from "@t3tools/client-runtime/handoff";
import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message";
import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows";
import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/thread-checkpoints";
import { formatDiffCount } from "@t3tools/client-runtime/diff-count";
import type { WorkflowOpening } from "@t3tools/client-runtime/state/subagentRuntime";
import {
type OrchestrationMessageContext,
Expand Down Expand Up @@ -147,6 +149,8 @@ import {
type MediaVideoPreviewSource,
} from "../../lib/videoPreviewSource";
import { CopyTextButton } from "./CopyTextButton";
import { getReviewSectionIdForCheckpoint } from "../review/reviewModel";
import { setReviewSelectedSectionId } from "../review/reviewState";
import { Menu } from "../../components/ui/Menu";
import { copyPromptLink } from "../prompt-links/prompt-link-actions";
import { parseReviewCommentMessageSegments } from "../review/reviewCommentSelection";
Expand Down Expand Up @@ -989,6 +993,7 @@ function renderFeedEntry(
readonly onToggleWorkGroup: (groupId: string, anchorKey?: string) => void;
readonly onToggleWorkRow: (rowId: string, anchorKey?: string, groupId?: string) => void;
readonly onToggleTurnFold: (runId: RunId) => void;
readonly onOpenTurnDiff: (checkpoint: ThreadCheckpointSummary) => void;
readonly onPressPreview: (source: FilePreviewSource) => void;
readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void;
readonly markdownLinkHandlers: MarkdownLinkHandlers;
Expand Down Expand Up @@ -1412,6 +1417,12 @@ function renderFeedEntry(
<MessageAttachmentUnknown key={attachment.id} name={attachment.name} />
);
})}
{showAssistantMeta && message.checkpointSummary ? (
<AssistantChangedFilesSummary
checkpoint={message.checkpointSummary}
onOpenTurnDiff={props.onOpenTurnDiff}
/>
) : null}
{showAssistantMeta ? (
<View className="mt-1 flex-row items-center gap-1">
<CopyTextButton
Expand Down Expand Up @@ -1461,6 +1472,44 @@ function renderFeedEntry(
);
}

function AssistantChangedFilesSummary(props: {
readonly checkpoint: ThreadCheckpointSummary;
readonly onOpenTurnDiff: (checkpoint: ThreadCheckpointSummary) => void;
}) {
const { files } = props.checkpoint;
const additions = files.reduce((total, file) => total + file.additions, 0);
const deletions = files.reduce((total, file) => total + file.deletions, 0);
const fileCountLabel = `${files.length} file${files.length === 1 ? "" : "s"} changed`;
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={`Review turn ${props.checkpoint.checkpointTurnCount}: ${fileCountLabel}, ${additions} additions, ${deletions} deletions`}
onPress={() => props.onOpenTurnDiff(props.checkpoint)}
className="mt-2 min-h-11 gap-1 rounded-xl border border-border-subtle px-3 py-2 active:bg-subtle"
>
<View className="flex-row items-center gap-2">
<Text className="flex-1 font-t3-medium text-xs text-foreground-secondary">
{fileCountLabel}
</Text>
<Text className="font-t3-medium text-xs tabular-nums text-adaptive-emerald-700-300">
+{formatDiffCount(additions)}
</Text>
<Text className="font-t3-medium text-xs tabular-nums text-adaptive-rose-700-300">
-{formatDiffCount(deletions)}
</Text>
<SymbolView name="chevron.right" size={12} tintColorClassName="accent-foreground-muted" />
</View>
<Text className="text-xs text-foreground-muted" numberOfLines={2}>
{files
.slice(0, 3)
.map((file) => file.path)
.join(" · ")}
{files.length > 3 ? ` · +${files.length - 3} more` : ""}
</Text>
</Pressable>
);
}

type UserMessageContentProps = {
readonly text: string;
readonly environmentId: EnvironmentId;
Expand Down Expand Up @@ -1673,6 +1722,19 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
const duoColumns = useNativeWorkspaceColumnsSupported() && isDuoDevice(deviceType);
const openPullRequest = useOpenPullRequest();
const navigation = useNavigation();
const onOpenTurnDiff = useCallback(
(checkpoint: ThreadCheckpointSummary) => {
setReviewSelectedSectionId(
scopedThreadKey(props.environmentId, props.threadId),
getReviewSectionIdForCheckpoint(checkpoint),
);
navigation.navigate("ThreadReview", {
environmentId: props.environmentId,
threadId: props.threadId,
});
},
[navigation, props.environmentId, props.threadId],
);
const { themeAppearance } = useAppearancePreferences();
const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const disclosureSettleFrameRef = useRef<number | null>(null);
Expand Down Expand Up @@ -2433,6 +2495,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
onToggleWorkGroup,
onToggleWorkRow,
onToggleTurnFold,
onOpenTurnDiff,
onPressPreview,
onPressVideo,
markdownLinkHandlers,
Expand Down
69 changes: 69 additions & 0 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
ContextHandoffId,
MessageId,
CheckpointId,
CheckpointRef,
CheckpointScopeId,
RuntimeRequestId,
NodeId,
Expand All @@ -20,6 +21,8 @@ import {
type OrchestrationV2TurnItem,
} from "@t3tools/contracts";
import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message";
import { deriveThreadCheckpointSummaries } from "@t3tools/client-runtime/state/thread-checkpoints";
import { getReviewSectionIdForCheckpoint } from "../features/review/reviewModel";
import { summarizeToolGroup } from "@t3tools/client-runtime/work-log/presentation";
import * as DateTime from "effect/DateTime";
import { describe, expect, it } from "vite-plus/test";
Expand Down Expand Up @@ -257,6 +260,72 @@ describe("buildThreadFeed", () => {
expect(feed.map((entry) => entry.type)).toEqual(["message"]);
});

it.each([false, true])(
"carries a late checkpoint to only the final answer without changing cached messages (cache: %s)",
(cached) => {
const cache = cached ? createThreadFeedCache() : undefined;
const earlier = {
...assistantMessage(),
id: TurnItemId.make("earlier-item"),
messageId: MessageId.make("earlier-message"),
text: "Working on the files",
};
const final = assistantMessage("2026-06-20T00:00:04.000Z");
const rows = [projected(earlier, 0), projected(final, 1)];
const before = buildThreadFeed(rows, { cache });
const [summary] = deriveThreadCheckpointSummaries({
messages: [earlier, final].map((message) => ({
...message,
id: message.messageId,
role: "assistant",
createdBy: "agent",
creationSource: "provider",
attachments: [],
createdAt: message.startedAt,
})),
checkpoints: [
{
id: CheckpointId.make("checkpoint-final"),
threadId,
scopeId: CheckpointScopeId.make("scope"),
runId,
nodeId: NodeId.make("node"),
parentCheckpointId: null,
ordinalWithinScope: 1,
appRunOrdinal: 2,
ref: CheckpointRef.make("checkpoint-ref"),
status: "ready",
capturedAt: final.updatedAt,
files: [
{ path: "src/one.ts", kind: "modified", additions: 10, deletions: 2 },
{ path: "src/two.ts", kind: "added", additions: 5, deletions: 0 },
],
},
],
});
expect(summary).toBeDefined();
if (!summary) return;
const after = buildThreadFeed(rows, { checkpointSummaries: [summary], cache });
expect(after[0]).toBe(before[0]);
expect(after[1]).toMatchObject({
type: "message",
message: { id: final.messageId, checkpointSummary: summary },
});
expect(before[1]).not.toHaveProperty("message.checkpointSummary");
expect(getReviewSectionIdForCheckpoint(summary)).toBe("turn:2");

for (const checkpoint of [
{ ...summary, files: [] },
...(["missing", "error", "stale"] as const).map((status) => ({ ...summary, status })),
{ ...summary, runId: RunId.make("another-run") },
{ ...summary, assistantMessageId: MessageId.make("unloaded-message") },
]) {
expect(buildThreadFeed(rows, { checkpointSummaries: [checkpoint], cache })).toEqual(before);
}
expect(buildThreadFeed(rows, { cache })).toEqual(before);
},
);

it("omits cached tool output and patch bodies from expanded and copied activity", () => {
const rawOutput = "RAW_TOOL_OUTPUT";
const items: OrchestrationV2TurnItem[] = [
Expand Down
18 changes: 17 additions & 1 deletion apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
ThreadUserInputQuestion,
} from "@t3tools/client-runtime/state/thread-requests";
import { turnItemIsWorkspacePreparation } from "@t3tools/client-runtime/state/turn-item-presentation";
import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/thread-checkpoints";
import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display";
import { isLiveSubagentTurnItem } from "@t3tools/client-runtime/state/subagentRuntime";
import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation";
Expand Down Expand Up @@ -145,6 +146,7 @@ export interface ThreadFeedMessage {
readonly createdAt: string;
readonly updatedAt: string;
readonly projectedItem?: OrchestrationV2ProjectedTurnItem;
readonly checkpointSummary?: ThreadCheckpointSummary;
}

export interface LocalThreadMessage {
Expand Down Expand Up @@ -1898,6 +1900,7 @@ interface ThreadFeedBuild {
readonly rows: ReadonlyArray<OrchestrationV2ProjectedTurnItem>;
readonly attempts: ReadonlyArray<OrchestrationV2RunAttempt> | undefined;
readonly nodes: ReadonlyArray<OrchestrationV2ExecutionNode> | undefined;
readonly checkpointSummaries: ReadonlyArray<ThreadCheckpointSummary> | undefined;
readonly foldedAnswerMessageIds: ReadonlySet<string>;
readonly delegatedChildrenByRun: ReadonlyMap<RunId, ReadonlySet<string>>;
readonly feed: ReadonlyArray<ThreadFeedEntry>;
Expand Down Expand Up @@ -1953,6 +1956,7 @@ export function buildThreadFeed(
readonly anchoredMessages?: ReadonlyArray<LocalThreadMessage>;
readonly attempts?: ReadonlyArray<OrchestrationV2RunAttempt>;
readonly nodes?: ReadonlyArray<OrchestrationV2ExecutionNode>;
readonly checkpointSummaries?: ReadonlyArray<ThreadCheckpointSummary>;
readonly cache?: ThreadFeedCache;
},
): ThreadFeedEntry[] {
Expand Down Expand Up @@ -1999,6 +2003,7 @@ export function buildThreadFeed(
anchoredMessages.length === 0 &&
previous.attempts === options?.attempts &&
previous.nodes === options?.nodes &&
previous.checkpointSummaries === options?.checkpointSummaries &&
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
setsEqual(previous.foldedAnswerMessageIds, foldedAnswerMessageIds) &&
setMapsEqual(previous.delegatedChildrenByRun, delegatedChildrenByRun)
) {
Expand Down Expand Up @@ -2153,8 +2158,18 @@ export function buildThreadFeed(
if (insertionIndex === -1) entries.push(entry);
else entries.splice(insertionIndex, 0, entry);
}
const checkpointByMessageId = new Map(
(options?.checkpointSummaries ?? [])
.filter((checkpoint) => checkpoint.status === "ready" && checkpoint.files.length > 0)
.map((checkpoint) => [checkpoint.assistantMessageId, checkpoint]),
);
const grouped = groupAdjacentActivities(
entries,
entries.map((entry) => {
if (entry.type !== "message" || entry.message.role !== "assistant") return entry;
const checkpointSummary = checkpointByMessageId.get(entry.message.id);
if (!checkpointSummary || checkpointSummary.runId !== entry.message.runId) return entry;
return { ...entry, message: { ...entry.message, checkpointSummary } };
}),
delegatedChildrenByRun,
// Anchored messages shift entries away from their rows; such a build is not reused.
anchoredMessages.length > 0
Expand All @@ -2174,6 +2189,7 @@ export function buildThreadFeed(
rows: visibleTurnItems,
attempts: options?.attempts,
nodes: options?.nodes,
checkpointSummaries: options?.checkpointSummaries,
foldedAnswerMessageIds,
delegatedChildrenByRun,
feed,
Expand Down
5 changes: 4 additions & 1 deletion apps/mobile/src/state/thread-task-permissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,10 @@ vi.mock("./use-atom-command", () => ({ useAtomCommand: <A>(command: A) => comman
vi.mock("./threads", async () => {
const { Atom } = await import("effect/reactivity");
return {
environmentThreadDetails: { queueWorkflowAtom: () => Atom.make(null) },
environmentThreadDetails: {
queueWorkflowAtom: () => Atom.make(null),
checkpointSummariesAtom: () => Atom.make([]),
},
threadEnvironment: {
respondToApproval: state.approve,
respondToUserInput: state.answer,
Expand Down
8 changes: 8 additions & 0 deletions apps/mobile/src/state/use-thread-composer-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { readEnvironmentScope } from "./session";
import type { ComposerTextPaste } from "../native/T3ComposerEditor.types";
import { useAtomValue } from "@effect/atom-react";
import { threadRuntimeIsActive } from "@t3tools/client-runtime/state/shell";
import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/thread-checkpoints";
import { resolvePendingHandoff } from "@t3tools/client-runtime/handoff";
import {
deriveProviderSubagentStatus,
Expand Down Expand Up @@ -117,6 +118,7 @@ import { useAtomCommand } from "./use-atom-command";
const EMPTY_QUEUE_WORKFLOW_ATOM = Atom.make<null>(null).pipe(
Atom.withLabel("mobile-thread-queue-workflow:empty"),
);
const EMPTY_CHECKPOINT_SUMMARIES_ATOM = Atom.make<ReadonlyArray<ThreadCheckpointSummary>>([]);

export function appendReviewCommentToDraft(input: {
readonly environmentId: EnvironmentId;
Expand Down Expand Up @@ -240,6 +242,11 @@ export function useThreadComposerState() {
const selectedThreadMessages = selectedThreadProjection?.projection.messages;
const selectedThreadAttempts = selectedThreadProjection?.projection.attempts;
const selectedThreadNodes = selectedThreadProjection?.projection.nodes;
const checkpointSummaries = useAtomValue(
selectedThreadDetailRef === null
? EMPTY_CHECKPOINT_SUMMARIES_ATOM
: environmentThreadDetails.checkpointSummariesAtom(selectedThreadDetailRef),
);
// A thread whose creation has not delivered its turn yet: the prompt only
// exists in the outbox, so it is appended to whatever the server has. The
// detail is usually present but empty during a worktree checkout, so this
Expand All @@ -256,6 +263,7 @@ export function useThreadComposerState() {
anchoredMessages: pendingCreation,
attempts: selectedThreadAttempts,
nodes: selectedThreadNodes,
checkpointSummaries,
cache: feedCache,
});
const pendingAcknowledgments = acknowledgedMessages.filter(
Expand Down
Loading