Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
ba93d74
test(web): reproduce #17382 expanded turn collapsing when the next ru…
juliusmarminge Oct 22, 2026
87db401
fix(web): a turn you expanded stays open when the next run starts
juliusmarminge Oct 22, 2026
8a9c128
fix(web,mobile): a turn you expanded stays open when the next run starts
juliusmarminge Oct 22, 2026
b1a6be5
test(web): seed the remembered fold instead of relying on a scroll event
juliusmarminge Oct 22, 2026
96662d5
fix(web,mobile): an interrupted turn you already opened stays open on…
juliusmarminge Oct 22, 2026
d4b6168
feat(web): fold every run since a prompt under one Worked-for row
t3dotgg Oct 25, 2026
cfb80eb
feat(mobile): fold every run since a prompt under one Worked-for row
t3dotgg Oct 25, 2026
eb4bdb3
fix(web,mobile): address response-group review findings
t3dotgg Oct 25, 2026
cb899f0
fix(web,mobile): a collapsed group always shows its answer
t3dotgg Oct 25, 2026
ac8483d
fix(web,mobile): a group diff spanning one turn opens as that turn
t3dotgg Oct 25, 2026
f690ab4
fix(web): a collapsed group shows its answer when late output lands i…
t3dotgg Oct 25, 2026
09c756a
style: keep response-group comments within the comment length rule
t3dotgg Oct 25, 2026
a589a1b
fix(web): a fold anchored on an interrupt request keeps its row
t3dotgg Oct 25, 2026
2b69c9b
perf(mobile): a tools-only wake's group keeps its identity across fee…
t3dotgg Oct 25, 2026
385da16
fix(mobile): a re-captured checkpoint refreshes its cached group
t3dotgg Oct 25, 2026
5325630
chore(web): drop the formatDuration re-export nothing imports
t3dotgg Oct 27, 2026
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
12 changes: 12 additions & 0 deletions apps/mobile/src/features/review/reviewModel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { describe, expect, it } from "vite-plus/test";
import {
applyReviewDiffMetadata,
buildReviewParsedDiff,
getReviewSectionIdForCheckpoint,
parseReviewRangeSectionId,
resolveReviewSelectedSectionId,
type ReviewSectionItem,
} from "./reviewModel";
Expand Down Expand Up @@ -304,3 +306,13 @@ describe("applyReviewDiffMetadata", () => {
expect(applyReviewDiffMetadata(parsed, null)).toEqual(parsed);
});
});

describe("review range sections", () => {
it("round-trips a response group's turn range through its section id", () => {
const id = getReviewSectionIdForCheckpoint({ checkpointTurnCount: 9, rangeFromTurnCount: 4 });
expect(id).toBe("turns:4:9");
expect(parseReviewRangeSectionId(id)).toEqual({ fromTurnCount: 4, toTurnCount: 9 });
expect(getReviewSectionIdForCheckpoint({ checkpointTurnCount: 9 })).toBe("turn:9");
expect(parseReviewRangeSectionId("turn:9")).toBeNull();
});
});
27 changes: 22 additions & 5 deletions apps/mobile/src/features/review/reviewModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,9 @@ export type ReviewParsedDiff =
};

function checkpointTitle(checkpoint: ThreadCheckpointSummary): string {
return `Turn ${checkpoint.checkpointTurnCount}`;
return checkpoint.rangeFromTurnCount === undefined
? `Turn ${checkpoint.checkpointTurnCount}`
: `Turns ${checkpoint.rangeFromTurnCount + 1}–${checkpoint.checkpointTurnCount}`;
}

function checkpointSubtitle(checkpoint: ThreadCheckpointSummary): string {
Expand Down Expand Up @@ -484,9 +486,19 @@ function mapRenderableFile(
}

export function getReviewSectionIdForCheckpoint(
checkpoint: Pick<ThreadCheckpointSummary, "checkpointTurnCount">,
checkpoint: Pick<ThreadCheckpointSummary, "checkpointTurnCount" | "rangeFromTurnCount">,
): string {
return `turn:${checkpoint.checkpointTurnCount}`;
return checkpoint.rangeFromTurnCount === undefined
? `turn:${checkpoint.checkpointTurnCount}`
: `turns:${checkpoint.rangeFromTurnCount}:${checkpoint.checkpointTurnCount}`;
}

/** The turn range a response group's diff section covers, from its section id. */
export function parseReviewRangeSectionId(
sectionId: string | null,
): { readonly fromTurnCount: number; readonly toTurnCount: number } | null {
const match = sectionId === null ? null : /^turns:(\d+):(\d+)$/.exec(sectionId);
return match ? { fromTurnCount: Number(match[1]), toTurnCount: Number(match[2]) } : null;
}

export function getReadyReviewCheckpoints(
Expand All @@ -500,13 +512,18 @@ export function getReadyReviewCheckpoints(
}

export function reviewTurnDiffsBySectionId(
checkpoints: ReadonlyArray<Pick<ThreadCheckpointSummary, "checkpointTurnCount">>,
checkpoints: ReadonlyArray<
Pick<ThreadCheckpointSummary, "checkpointTurnCount" | "rangeFromTurnCount">
>,
diffByTurnCount: Readonly<Record<number, string | undefined>>,
): Readonly<Record<string, string | undefined>> {
// Only single turns are cached; a range section waits for its live query.
return Object.fromEntries(
checkpoints.map((checkpoint) => [
getReviewSectionIdForCheckpoint(checkpoint),
diffByTurnCount[checkpoint.checkpointTurnCount],
checkpoint.rangeFromTurnCount === undefined
? diffByTurnCount[checkpoint.checkpointTurnCount]
: undefined,
]),
);
}
Expand Down
25 changes: 23 additions & 2 deletions apps/mobile/src/features/review/useReviewSections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useAtomValue } from "@effect/atom-react";
import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem";
import { Atom } from "effect/reactivity";

import { mergeResponseGroupCheckpoints } from "@t3tools/client-runtime/state/response-groups";
import { deriveThreadCheckpointSummaries } from "@t3tools/client-runtime/state/thread-checkpoints";
import type { EnvironmentId, ReviewDiffPreviewSource, ThreadId } from "@t3tools/contracts";

Expand All @@ -17,6 +18,7 @@ import {
buildReviewSectionItems,
getReadyReviewCheckpoints,
getReviewSectionIdForCheckpoint,
parseReviewRangeSectionId,
resolveReviewSelectedSectionId,
reviewTurnDiffsBySectionId,
type ReviewSectionItem,
Expand Down Expand Up @@ -73,8 +75,24 @@ export function useReviewSections(input: {
: NO_TURN_DIFFS_ATOM,
);

const checkpoints =
selectedThread === null ? [] : deriveThreadCheckpointSummaries(selectedThread.projection);
// A response group's stat opens one section spanning its turns; it exists
// only while selected.
const selectedRange = parseReviewRangeSectionId(reviewCache.selectedSectionId);
const range = selectedRange
? mergeResponseGroupCheckpoints(
checkpoints.filter(
(checkpoint) =>
checkpoint.checkpointTurnCount > selectedRange.fromTurnCount &&
checkpoint.checkpointTurnCount <= selectedRange.toTurnCount,
),
)
: null;
const readyCheckpoints = getReadyReviewCheckpoints(
selectedThread === null ? [] : deriveThreadCheckpointSummaries(selectedThread.projection),
range?.rangeFromTurnCount === undefined
? checkpoints
: [...checkpoints, { ...range, rangeFromTurnCount: range.rangeFromTurnCount }],
);
const checkpointBySectionId = new Map(
readyCheckpoints.map((checkpoint) => [getReviewSectionIdForCheckpoint(checkpoint), checkpoint]),
Expand Down Expand Up @@ -103,7 +121,10 @@ export function useReviewSections(input: {
environmentId: enabled ? (environmentId ?? null) : null,
threadId: enabled ? (threadId ?? null) : null,
fromTurnCount:
enabled && activeCheckpoint ? Math.max(0, activeCheckpoint.checkpointTurnCount - 1) : null,
enabled && activeCheckpoint
? (activeCheckpoint.rangeFromTurnCount ??
Math.max(0, activeCheckpoint.checkpointTurnCount - 1))
: null,
toTurnCount: enabled ? (activeCheckpoint?.checkpointTurnCount ?? null) : null,
ignoreWhitespace: false,
});
Expand Down
47 changes: 30 additions & 17 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ import {
isSecretRequestActivityGroup,
type ThreadFeedEntry,
type ThreadFeedLatestRun,
messageFoldRunId,
messageFoldRunIds,
} from "../../lib/threadActivity";
import type { ThreadContentPresentation } from "./threadContentPresentation";
import {
Expand Down Expand Up @@ -1257,7 +1257,7 @@ function renderFeedEntry(
readonly onCopyWorkRow: (rowId: string, value: string) => void;
readonly onToggleWorkGroup: (groupId: string, anchorKey?: string) => void;
readonly onToggleWorkRow: (rowId: string, anchorKey?: string, groupId?: string) => void;
readonly onToggleTurnFold: (runId: RunId) => void;
readonly onToggleTurnFold: (row: Extract<ThreadFeedEntry, { type: "run-fold" }>) => void;
readonly onOpenTurnDiff: (checkpoint: ThreadCheckpointSummary) => void;
readonly onPressPreview: (source: FilePreviewSource) => void;
readonly onPlaybackStart: () => void;
Expand Down Expand Up @@ -1308,7 +1308,7 @@ function renderFeedEntry(
<Pressable
accessibilityRole="button"
accessibilityState={{ expanded: entry.expanded }}
onPress={() => props.onToggleTurnFold(entry.runId)}
onPress={() => props.onToggleTurnFold(entry)}
hitSlop={4}
className="min-h-11 min-w-0 flex-1 flex-row items-center gap-2"
style={{
Expand Down Expand Up @@ -2138,6 +2138,7 @@ export const ThreadFeed = memo(function ThreadFeed({
const disclosureSettleFrameRef = useRef<number | null>(null);
const disclosureSettleSecondFrameRef = useRef<number | null>(null);
const disclosureAnchorKeyRef = useRef<string | null>(null);
const [autoExpandedTurnId, setAutoExpandedTurnId] = useState<RunId | null>(null);
const userScrollSettleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const headerMaterialVisibleRef = useRef(false);
const { width: windowWidth, fontScale } = useWindowDimensions();
Expand Down Expand Up @@ -2743,15 +2744,15 @@ export const ThreadFeed = memo(function ThreadFeed({
useThreadFeedPresentation(props, expandedTurnIds, expandedWorkGroups),
guestShare,
);
const jumpRunId =
const jumpFoldKeys =
jumpReady &&
!presentedFeed.some((entry) => entry.type === "message" && entry.id === jumpToMessageId)
? messageFoldRunId(props.feed, jumpToMessageId)
: null;
if (jumpRunId && !expandedTurnIds.has(jumpRunId)) {
? messageFoldRunIds(props.feed, jumpToMessageId)
: [];
if (jumpFoldKeys.some((key) => !expandedTurnIds.has(key))) {
setInteractionState((current) => ({
...current,
expandedTurnIds: new Set(current.expandedTurnIds).add(jumpRunId),
expandedTurnIds: new Set([...current.expandedTurnIds, ...jumpFoldKeys]),
}));
}
const setupAnchorIndex = presentedFeed.findIndex(
Expand Down Expand Up @@ -2862,22 +2863,30 @@ export const ThreadFeed = memo(function ThreadFeed({
}
return new Set(terminalIdsByTurn.values());
})();
// An interrupted run unfolds; a run that is no longer latest folds back.
// An in-session interrupt expands its turn so the user keeps their place; the next
// run folds only that expansion, never one the user opened.
const [previousLatestRun, setPreviousLatestRun] = useState(props.latestRun);
if (previousLatestRun !== props.latestRun) {
setPreviousLatestRun(props.latestRun);
const latest = props.latestRun;
const previous = previousLatestRun;
if (latest && previous) {
if (latest.runId !== previous.runId) {
if (expandedTurnIds.has(previous.runId)) {
if (autoExpandedTurnId === previous.runId) {
setAutoExpandedTurnId(null);
setInteractionState((current) => {
if (!current.expandedTurnIds.has(previous.runId)) return current;
const next = new Set(current.expandedTurnIds);
next.delete(previous.runId);
return { ...current, expandedTurnIds: next };
});
}
} else if (previous.status === "running" && latest.status === "interrupted") {
} else if (
previous.status === "running" &&
latest.status === "interrupted" &&
!expandedTurnIds.has(latest.runId)
) {
setAutoExpandedTurnId(latest.runId);
setInteractionState((current) => ({
...current,
expandedTurnIds: new Set(current.expandedTurnIds).add(latest.runId),
Expand Down Expand Up @@ -2997,14 +3006,18 @@ export const ThreadFeed = memo(function ThreadFeed({
});
};

const onToggleTurnFold = (runId: RunId) => {
suspendEndScrollMaintenanceForDisclosure(`run-fold:${runId}`);
// A response's only run answers to both its own key and its group's, so a
// fold opened before the first background wake stays open after it.
const onToggleTurnFold = (row: Extract<ThreadFeedEntry, { type: "run-fold" }>) => {
suspendEndScrollMaintenanceForDisclosure(row.id);
if (autoExpandedTurnId !== null && row.foldKeys.includes(autoExpandedTurnId)) {
setAutoExpandedTurnId(null);
}
setInteractionState((current) => {
const next = new Set(current.expandedTurnIds);
if (next.has(runId)) {
next.delete(runId);
} else {
next.add(runId);
for (const key of row.foldKeys) {
if (row.expanded) next.delete(key);
else next.add(key);
}
return { ...current, expandedTurnIds: next };
});
Expand Down
Loading
Loading