Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
15 changes: 14 additions & 1 deletion apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ import {
resolveMarkdownLinkPresentation,
} from "@t3tools/mobile-markdown-text/links";
import {
failedFeedRunIds,
deriveThreadFeedPresentation,
threadFeedRunIsUnsettled,
isContextCompactionActivityGroup,
Expand Down Expand Up @@ -1466,6 +1467,7 @@ function renderFeedEntry(
readonly workGroupScrollPositions: Map<string, ThreadWorkGroupScrollPosition>;
readonly terminalAssistantMessageIds: ReadonlySet<string>;
readonly unsettledTurnId: RunId | null;
readonly failedRunIds: ReadonlySet<RunId>;
readonly onCopyWorkRow: (rowId: string, value: string) => void;
readonly onToggleWorkGroup: (groupId: string, anchorKey?: string) => void;
readonly onToggleWorkRow: (rowId: string, anchorKey?: string) => void;
Expand Down Expand Up @@ -1786,7 +1788,12 @@ function renderFeedEntry(
const enterAnimated = isFreshTimestamp(message.createdAt);
return (
<Animated.View
className={cn(showAssistantMeta ? "mb-5 px-1" : "mb-1 px-1", hasWideBlock && "w-full")}
className={cn(
showAssistantMeta && !(message.runId && props.failedRunIds.has(message.runId))
? "mb-5 px-1"
: "mb-1 px-1",
hasWideBlock && "w-full",
)}
{...(enterAnimated ? { entering: FadeIn.duration(220) } : {})}
>
{renderedText.trim().length > 0 ? (
Expand Down Expand Up @@ -2602,6 +2609,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
),
[presentedFeed, props.anchorMessageId, anchorTopInset],
);
const failedRunIds = useMemo(
() => failedFeedRunIds(props.feed, props.latestRun),
[props.feed, props.latestRun],
);
const terminalAssistantMessageIds = useMemo(() => {
const terminalIdsByTurn = new Map<RunId, string>();
for (const entry of props.feed) {
Expand Down Expand Up @@ -2851,6 +2862,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
workGroupScrollPositions,
terminalAssistantMessageIds,
unsettledTurnId,
failedRunIds,
onCopyWorkRow,
onToggleWorkGroup,
onToggleWorkRow,
Expand Down Expand Up @@ -2896,6 +2908,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
workGroupScrollPositions,
terminalAssistantMessageIds,
unsettledTurnId,
failedRunIds,
iconSubtleColor,
screenColor,
userBubbleColor,
Expand Down
66 changes: 66 additions & 0 deletions apps/mobile/src/features/threads/thread-work-log.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -876,6 +876,72 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
},
) {
const { row, expanded } = props;
const failureItem = row.projectedItem.item;
if (failureItem.type === "error" && failureItem.status === "failed") {
const warning = failureItem.failure.class === "usage_limit";
const timestamp = new Date(row.createdAt);
const resetAt = failureItem.failure.resetAt;
const resetTime = resetAt
? new Date(resetAt).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
})
: null;
const label = warning
? `Usage limit reached.${resetTime ? ` Retry after ${resetTime}.` : ""}`
: row.summary;
return (
<WorkLogPressable
accessibilityLabel={warning ? label : `${row.summary}: ${failureItem.failure.message}`}
accessibilityHint="Long press to copy."
onLongPress={() => props.onCopyRow(row.id, row.getCopyText())}
>
<View className="flex-1 py-1">
<View className="flex-row items-center gap-1.5">
<WorkLogIconSlot>
<WorkLogIcon
icon="exclamationmark.circle"
color={props.iconSubtleColor}
colorClassName={warning ? "accent-warning-foreground" : "accent-danger-foreground"}
/>
</WorkLogIconSlot>
<Text
className={
warning
? "min-w-0 flex-1 font-t3-medium text-sm text-warning-foreground"
: "min-w-0 flex-1 font-t3-medium text-sm text-adaptive-rose-600-400"
}
>
{label}
</Text>
{props.copied ? (
<Text className="pr-1 font-t3-medium text-3xs text-adaptive-emerald-600-400">
Copied
</Text>
) : null}
<Text
accessibilityLabel={timestamp.toLocaleString()}
className="shrink-0 text-xs text-foreground-subtle"
>
{timestamp.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
})}
</Text>
</View>
{!warning ? (
<Text selectable className="ml-7 text-sm text-foreground">
{failureItem.failure.message}
</Text>
) : null}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</View>
</WorkLogPressable>
);
}
const canExpand = row.canExpand;
const reasoning = row.projectedItem.item.type === "reasoning" ? row.projectedItem.item : null;
const fullDetail = expanded && !reasoning ? row.getFullDetail() : null;
Expand Down
61 changes: 60 additions & 1 deletion apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -445,7 +445,7 @@ describe("buildThreadFeed", () => {
);
expect(failedPresentation.map((entry) => entry.type)).toEqual([
"activity-group",
"work-toggle",
"activity-group",
]);
expect(
failedPresentation[0]?.type === "activity-group"
Expand Down Expand Up @@ -1836,3 +1836,62 @@ it("previews a settled thought in its collapsed header and labels its expanded h
if (detail?.type !== "activity-group") throw new Error("Expected full thought");
expect(detail.activities[0]?.detail).toBe(thought.text);
});

it.each(["provider_error", "usage_limit"] as const)(
"keeps a historical %s failure and preceding work visible without disclosures",
(failureClass) => {
const at = "2026-06-20T00:00:03.000Z";
const error: OrchestrationV2TurnItem = {
...base("failure", at, 2),
type: "error",
status: "failed",
failure: {
class: failureClass,
message: "The provider stopped this turn.\nRetry later.",
code: null,
retryable: true,
},
};
const command: OrchestrationV2TurnItem = {
...base("command", "2026-06-20T00:00:02.000Z", 1),
type: "command_execution",
input: "pwd",
output: "",
exitCode: 0,
};
const sourceFeed = buildThreadFeed([
projected(userMessage(), 0),
projected(command, 1),
projected(error, 2),
]);
const feed = deriveThreadFeedPresentation(
sourceFeed,
{ runId: RunId.make("newer-run"), status: "completed", startedAt: at, completedAt: at },
new Set(),
);
const whileWorking = deriveThreadFeedPresentation(
sourceFeed,
{ runId: RunId.make("newer-run"), status: "running", startedAt: at, completedAt: null },
new Set(),
);
for (const entry of feed) {
expect(whileWorking.find((row) => row.id === entry.id)).toBe(entry);
}
expect(feed.some((entry) => entry.type === "run-fold" || entry.type === "work-toggle")).toBe(
false,
);
const activities = feed.flatMap((entry) =>
entry.type === "activity-group" ? entry.activities : [],
);
expect(activities.map((activity) => activity.projectedItem.item.id)).toEqual([
"command",
"failure",
]);
expect(activities.at(-1)).toMatchObject({
detail: error.failure.message,
createdAt: at,
canExpand: false,
prominent: true,
});
},
);
55 changes: 53 additions & 2 deletions apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,10 @@ const localMessageEntriesCache = new WeakMap<
Extract<RawThreadFeedEntry, { readonly type: "message" }>
>();
const activityGroupsCache = new WeakMap<ThreadFeedActivity, ThreadFeedActivityGroup>();
const failedActivityGroupsCache = new WeakMap<
ThreadFeedActivityGroup,
ReadonlyArray<ThreadFeedActivityGroup>
>();
const presentedActivityGroupsCache = new WeakMap<
ThreadFeedActivityGroup,
{
Expand Down Expand Up @@ -669,13 +673,13 @@ function toFeedActivity(
attemptId,
summary,
detail,
canExpand: true,
canExpand: !(item.type === "error" && item.status === "failed"),
getFullDetail,
getCopyText,
icon: workEntry.toolSurface ?? itemIcon(item),
logo: toolPresentation?.logo ?? null,
toolLike: itemIsToolLike(item),
prominent: itemIsProminent(item),
prominent: itemIsProminent(item) || (item.type === "error" && item.status === "failed"),
status:
item.type === "error" && item.failure.class === "usage_limit"
? itemStatus(item)
Expand Down Expand Up @@ -821,6 +825,28 @@ interface ThreadFeedRunFold {
readonly label: string;
}

export function failedFeedRunIds(
feed: ReadonlyArray<ThreadFeedEntry>,
latestRun: ThreadFeedLatestRun | null,
) {
const failed = new Set<RunId>();
if (latestRun?.status === "failed") failed.add(latestRun.runId);
for (const entry of feed) {
if (entry.type !== "activity-group") continue;
for (const activity of entry.activities) {
const item = activity.projectedItem.item;
if (
item.type === "error" &&
item.status === "failed" &&
item.parentItemId === null &&
item.runId !== null
)
failed.add(item.runId);
}
}
return failed;
}

function deriveThreadFeedRunFolds(
feed: ReadonlyArray<ThreadFeedEntry>,
latestRun: ThreadFeedLatestRun | null,
Expand Down Expand Up @@ -873,11 +899,13 @@ function deriveThreadFeedRunFolds(
}

const activeRunId = unsettledRunId(latestRun);
const failedRunIds = failedFeedRunIds(feed, latestRun);
const foldsByAnchorId = new Map<string, ThreadFeedRunFold>();
for (const [runId, group] of groupsByRunId) {
if (
runId === activeRunId ||
interruptedRunIds.has(runId) ||
failedRunIds.has(runId) ||
group.entries.some((entry) => entry.type === "message" && entry.message.streaming)
) {
continue;
Expand Down Expand Up @@ -958,6 +986,7 @@ export function deriveThreadFeedPresentation(
(entry) =>
entry.type !== "run-fold" && entry.type !== "work-toggle" && entry.type !== "thinking",
);
const failedRunIds = failedFeedRunIds(sourceFeed, latestRun);
const activeTailGroup = sourceFeed.at(-1);
const foldsByAnchorId = deriveThreadFeedRunFolds(sourceFeed, latestRun);
const activeRunId = unsettledRunId(latestRun);
Expand Down Expand Up @@ -1001,6 +1030,28 @@ export function deriveThreadFeedPresentation(
result.push(row);
}
if (!collapsedEntryIds.has(entry.id)) {
if (
entry.type === "activity-group" &&
entry.runId !== null &&
failedRunIds.has(entry.runId)
) {
let rows = failedActivityGroupsCache.get(entry);
if (!rows) {
rows =
entry.activities.length === 1
? [entry]
: entry.activities.map((activity) => ({
type: "activity-group" as const,
id: activity.id,
createdAt: activity.createdAt,
runId: activity.runId,
activities: [activity],
}));
failedActivityGroupsCache.set(entry, rows);
}
result.push(...rows);
continue;
}
appendPresentedFeedEntry(
result,
entry,
Expand Down
Loading
Loading