authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-27 20:49:07-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-27 20:49:07-07:00
logf1e6f0c9bbe527a76eaae9e3b498a5977ce3c7d6
treeef84098d464044de3705cbe2b12a8cc237ea830c
parentb982847ab201795711884d1dfadfddb3864adb23
signature Signed by PGP key B5690EEEBB952194

fix(web): stop showing red x summaries for ordinary tool failures (#8395)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

7 files changed, 204 insertions(+), 35 deletions(-)

apps/mobile/src/features/threads/thread-work-log.tsx+1-1
...@@ -239,7 +239,7 @@ export function ThreadWorkLog(props: {...@@ -239,7 +239,7 @@ export function ThreadWorkLog(props: {
239 : { ios: "minus", android: "remove" }239 : { ios: "minus", android: "remove" }
240 }240 }
241 size={11}241 size={11}
242 tintColor={row.status === "failure" ? "#e11d48" : props.iconSubtleColor}242 tintColor={props.iconSubtleColor}
243 type="monochrome"243 type="monochrome"
244 />244 />
245 ) : null}245 ) : null}
apps/mobile/src/lib/threadActivity.test.ts+32
...@@ -324,6 +324,38 @@ describe("buildThreadFeed", () => {...@@ -324,6 +324,38 @@ describe("buildThreadFeed", () => {
324 ]);324 ]);
325 });325 });
326326
327 it("drops runtime warnings with no displayable content", () => {
328 const thread = makeThread({
329 id: ThreadId.make("thread-noise"),
330 projectId: ProjectId.make("project-1"),
331 title: "Warning noise thread",
332 activities: [
333 makeActivity({
334 id: EventId.make("activity-noise"),
335 kind: "runtime.warning",
336 summary: "Claude system message 'background_tasks_changed' (no displayable text content)",
337 createdAt: "2026-04-01T00:00:02.000Z",
338 turnId: TurnId.make("turn-1"),
339 }),
340 makeActivity({
341 id: EventId.make("activity-signal"),
342 kind: "runtime.warning",
343 summary: "Reconnecting... 2/5",
344 createdAt: "2026-04-01T00:00:03.000Z",
345 turnId: TurnId.make("turn-1"),
346 }),
347 ],
348 });
349
350 const feed = buildThreadFeed(thread);
351 expect(feed).toMatchObject([
352 {
353 type: "activity-group",
354 activities: [{ id: "activity-signal" }],
355 },
356 ]);
357 });
358
327 it("collapses matching tool lifecycle rows like desktop", () => {359 it("collapses matching tool lifecycle rows like desktop", () => {
328 const thread = makeThread({360 const thread = makeThread({
329 id: ThreadId.make("thread-2"),361 id: ThreadId.make("thread-2"),
apps/mobile/src/lib/threadActivity.ts+12
...@@ -338,6 +338,7 @@ function deriveWorkLogEntries(...@@ -338,6 +338,7 @@ function deriveWorkLogEntries(
338 if (activity.kind === "tool.progress") continue;338 if (activity.kind === "tool.progress") continue;
339 if (activity.kind === "context-window.updated") continue;339 if (activity.kind === "context-window.updated") continue;
340 if (activity.summary === "Checkpoint captured") continue;340 if (activity.summary === "Checkpoint captured") continue;
341 if (isNoContentRuntimeWarning(activity)) continue;
341 if (isPlanBoundaryToolActivity(activity)) continue;342 if (isPlanBoundaryToolActivity(activity)) continue;
342 if (isAgentInternalActivity(activity)) continue;343 if (isAgentInternalActivity(activity)) continue;
343 entries.push(toDerivedWorkLogEntry(activity));344 entries.push(toDerivedWorkLogEntry(activity));
...@@ -345,6 +346,17 @@ function deriveWorkLogEntries(...@@ -345,6 +346,17 @@ function deriveWorkLogEntries(
345 return collapseDerivedWorkLogEntries(entries);346 return collapseDerivedWorkLogEntries(entries);
346}347}
347348
349/** Adapters forward unknown wire-only SDK messages (background_tasks_changed,
350 * commands_changed, ...) as runtime warnings. The suffix comes from
351 * describeUnknownSdkMessage in the Claude adapter; a row with no displayable
352 * text carries nothing a user can act on, so it does not render. */
353function isNoContentRuntimeWarning(activity: OrchestrationThreadActivity): boolean {
354 return (
355 activity.kind === "runtime.warning" &&
356 activity.summary.endsWith("(no displayable text content)")
357 );
358}
359
348function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean {360function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean {
349 if (activity.kind !== "tool.updated" && activity.kind !== "tool.completed") {361 if (activity.kind !== "tool.updated" && activity.kind !== "tool.completed") {
350 return false;362 return false;
apps/web/src/components/chat/MessagesTimeline.test.tsx+93-1
...@@ -973,6 +973,49 @@ describe("MessagesTimeline", () => {...@@ -973,6 +973,49 @@ describe("MessagesTimeline", () => {
973 expect(markup).not.toContain('aria-label="Tool call failed"');973 expect(markup).not.toContain('aria-label="Tool call failed"');
974 });974 });
975975
976 it("keeps the collapsed summary icon neutral when the group ends in a failure", () => {
977 const markup = renderToStaticMarkup(
978 <MessagesTimeline
979 {...buildProps()}
980 timelineEntries={[
981 {
982 id: "entry-completed",
983 kind: "work",
984 createdAt: "2026-03-17T19:12:28.000Z",
985 entry: {
986 id: "work-completed",
987 createdAt: "2026-03-17T19:12:28.000Z",
988 label: "Run tests",
989 tone: "tool",
990 itemType: "command_execution",
991 toolLifecycleStatus: "completed",
992 },
993 },
994 {
995 id: "entry-failed",
996 kind: "work",
997 createdAt: "2026-03-17T19:12:29.000Z",
998 entry: {
999 id: "work-failed",
1000 createdAt: "2026-03-17T19:12:29.000Z",
1001 label: "Run lint",
1002 tone: "tool",
1003 itemType: "command_execution",
1004 toolLifecycleStatus: "failed",
1005 },
1006 },
1007 ]}
1008 />,
1009 );
1010
1011 expect(markup).toContain("Ran 2 commands");
1012 expect(markup).toContain("lucide-terminal");
1013 expect(markup).not.toContain("lucide-x");
1014 expect(markup).not.toContain("text-destructive");
1015 // The failure stays discoverable for screen readers.
1016 expect(markup).toContain("tool call failed");
1017 });
1018
976 it("keeps mixed work logs neutral after a later tool call succeeds", () => {1019 it("keeps mixed work logs neutral after a later tool call succeeds", () => {
977 const markup = renderToStaticMarkup(1020 const markup = renderToStaticMarkup(
978 <MessagesTimeline1021 <MessagesTimeline
...@@ -1249,11 +1292,22 @@ describe("MessagesTimeline", () => {...@@ -1249,11 +1292,22 @@ describe("MessagesTimeline", () => {
1249 expect(markup).not.toContain('data-testid="file-diff"');1292 expect(markup).not.toContain('data-testid="file-diff"');
1250 });1293 });
12511294
1252 it("renders a failure marker for failed tool lifecycle entries", () => {1295 it("renders a muted failure marker for failed tool lifecycle entries", () => {
1253 const markup = renderToStaticMarkup(1296 const markup = renderToStaticMarkup(
1254 <MessagesTimeline1297 <MessagesTimeline
1255 {...buildProps()}1298 {...buildProps()}
1256 timelineEntries={[1299 timelineEntries={[
1300 {
1301 id: "entry-info",
1302 kind: "work",
1303 createdAt: "2026-03-17T19:12:27.000Z",
1304 entry: {
1305 id: "work-info",
1306 createdAt: "2026-03-17T19:12:27.000Z",
1307 label: "Status updated",
1308 tone: "info",
1309 },
1310 },
1257 {1311 {
1258 id: "entry-1",1312 id: "entry-1",
1259 kind: "work",1313 kind: "work",
...@@ -1273,5 +1327,43 @@ describe("MessagesTimeline", () => {...@@ -1273,5 +1327,43 @@ describe("MessagesTimeline", () => {
12731327
1274 expect(markup).toContain("lucide-x");1328 expect(markup).toContain("lucide-x");
1275 expect(markup).toContain('aria-label="Tool call failed"');1329 expect(markup).toContain('aria-label="Tool call failed"');
1330 // Ordinary tool failures render muted, not red.
1331 expect(markup).not.toContain("text-destructive");
1332 });
1333
1334 it("keeps the red treatment for severe orchestration failures", () => {
1335 const markup = renderToStaticMarkup(
1336 <MessagesTimeline
1337 {...buildProps()}
1338 timelineEntries={[
1339 {
1340 id: "entry-info",
1341 kind: "work",
1342 createdAt: "2026-03-17T19:12:27.000Z",
1343 entry: {
1344 id: "work-info",
1345 createdAt: "2026-03-17T19:12:27.000Z",
1346 label: "Status updated",
1347 tone: "info",
1348 },
1349 },
1350 {
1351 id: "entry-turn-failed",
1352 kind: "work",
1353 createdAt: "2026-03-17T19:12:28.000Z",
1354 entry: {
1355 id: "work-turn-failed",
1356 createdAt: "2026-03-17T19:12:28.000Z",
1357 label: "Provider turn start failed",
1358 tone: "error",
1359 sourceActivityKind: "provider.turn.start.failed",
1360 },
1361 },
1362 ]}
1363 />,
1364 );
1365
1366 expect(markup).toContain("lucide-x");
1367 expect(markup).toContain("text-destructive");
1276 });1368 });
1277});1369});
apps/web/src/components/chat/MessagesTimeline.tsx+21-33
...@@ -35,6 +35,7 @@ import { FileDiff } from "@pierre/diffs/react";...@@ -35,6 +35,7 @@ import { FileDiff } from "@pierre/diffs/react";
35import {35import {
36 deriveTimelineEntries,36 deriveTimelineEntries,
37 workEntryDisplayIndicatesToolFailure,37 workEntryDisplayIndicatesToolFailure,
38 workEntrySignalsSevereFailure,
38 workLogEntryIsToolLike,39 workLogEntryIsToolLike,
39} from "../../session-logic";40} from "../../session-logic";
40import { type TurnDiffSummary } from "../../types";41import { type TurnDiffSummary } from "../../types";
...@@ -1479,7 +1480,7 @@ function LiveActivityContent({...@@ -1479,7 +1480,7 @@ function LiveActivityContent({
1479 <span1480 <span
1480 className={cn(1481 className={cn(
1481 "flex size-6 shrink-0 items-center justify-center",1482 "flex size-6 shrink-0 items-center justify-center",
1482 failed ? "text-destructive" : highlighted ? "text-foreground" : "text-icon-muted",1483 highlighted ? "text-foreground" : "text-icon-muted",
1483 )}1484 )}
1484 role={announceFailure ? "img" : undefined}1485 role={announceFailure ? "img" : undefined}
1485 aria-label={announceFailure ? "Tool call failed" : undefined}1486 aria-label={announceFailure ? "Tool call failed" : undefined}
...@@ -1556,16 +1557,9 @@ function WorkGroupToggleTimelineRow({...@@ -1556,16 +1557,9 @@ function WorkGroupToggleTimelineRow({
1556 aria-expanded={row.expanded}1557 aria-expanded={row.expanded}
1557 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}1558 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
1558 >1559 >
1559 <span1560 <span className="flex size-6 shrink-0 items-center justify-center text-icon-muted">
1560 className={cn(
1561 "flex size-6 shrink-0 items-center justify-center",
1562 row.hasFailure ? "text-destructive" : "text-icon-muted",
1563 )}
1564 role={row.hasFailure ? "img" : undefined}
1565 aria-label={row.hasFailure ? "Tool call failed" : undefined}
1566 >
1567 <WorkEntryIconSvg1561 <WorkEntryIconSvg
1568 name={row.hasFailure ? "x" : toolGroupSummaryIconName(row.summaryKind)}1562 name={toolGroupSummaryIconName(row.summaryKind)}
1569 className="size-4 shrink-0 stroke-[1.8] opacity-70"1563 className="size-4 shrink-0 stroke-[1.8] opacity-70"
1570 />1564 />
1571 </span>1565 </span>
...@@ -1580,33 +1574,25 @@ function WorkGroupToggleTimelineRow({...@@ -1580,33 +1574,25 @@ function WorkGroupToggleTimelineRow({
1580 : row.hiddenCount === 11574 : row.hiddenCount === 1
1581 ? "log entry"1575 ? "log entry"
1582 : "log entries";1576 : "log entries";
1583 const showHiddenFailure = row.hasFailure && !row.expanded;
1584
1585 return (1577 return (
1586 <button1578 <button
1587 type="button"1579 type="button"
1588 className="flex min-h-6 w-full cursor-pointer items-center gap-1.5 rounded-md px-0.5 py-0.5 text-left text-sm leading-relaxed transition-colors duration-150 hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"1580 className="flex min-h-6 w-full cursor-pointer items-center gap-1.5 rounded-md px-0.5 py-0.5 text-left text-sm leading-relaxed transition-colors duration-150 hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
1581 aria-label={
1582 row.hasFailure && !row.expanded
1583 ? `+${row.hiddenCount} previous ${labelNoun}, includes a failure`
1584 : undefined
1585 }
1589 aria-expanded={row.expanded}1586 aria-expanded={row.expanded}
1590 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}1587 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
1591 >1588 >
1592 <span1589 <span className="flex size-6 shrink-0 items-center justify-center text-icon-muted">
1593 className={cn(1590 <ChevronDownIcon
1594 "flex size-6 shrink-0 items-center justify-center",1591 className={cn(
1595 showHiddenFailure ? "text-destructive" : "text-icon-muted",1592 "size-4 shrink-0 opacity-70 transition-transform duration-200",
1596 )}1593 row.expanded && "rotate-180",
1597 role={showHiddenFailure ? "img" : undefined}1594 )}
1598 aria-label={showHiddenFailure ? "Hidden work includes a failure" : undefined}1595 />
1599 >
1600 {showHiddenFailure ? (
1601 <WorkEntryIconSvg name="x" className="size-4 shrink-0 stroke-[1.8] opacity-70" />
1602 ) : (
1603 <ChevronDownIcon
1604 className={cn(
1605 "size-4 shrink-0 opacity-70 transition-transform duration-200",
1606 row.expanded && "rotate-180",
1607 )}
1608 />
1609 )}
1610 </span>1596 </span>
1611 {row.expanded ? (1597 {row.expanded ? (
1612 <span className="font-medium text-foreground">1598 <span className="font-medium text-foreground">
...@@ -2631,11 +2617,13 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {...@@ -2631,11 +2617,13 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
2631 const canExpand = expandedBody !== null;2617 const canExpand = expandedBody !== null;
2632 const showDestructiveRowStyle =2618 const showDestructiveRowStyle =
2633 showFailedIndicator &&2619 showFailedIndicator &&
2634 (workEntry.sourceActivityKind === "runtime.error" || !workLogEntryIsToolLike(workEntry));2620 (workEntrySignalsSevereFailure(workEntry) || !workLogEntryIsToolLike(workEntry));
2621 // Ordinary tool failures stay muted; only runtime errors and warnings get
2622 // color. The red treatment is reserved for severe failures.
2635 const iconWrapperClass = cn(2623 const iconWrapperClass = cn(
2636 "flex size-6 shrink-0 items-center justify-center",2624 "flex size-6 shrink-0 items-center justify-center",
2637 showWarningIndicator || showFailedIndicator2625 showWarningIndicator
2638 ? "text-destructive"2626 ? "text-warning"
2639 : showDestructiveRowStyle2627 : showDestructiveRowStyle
2640 ? "text-destructive"2628 ? "text-destructive"
2641 : workEntry.tone === "tool" || showFailedIndicator2629 : workEntry.tone === "tool" || showFailedIndicator
apps/web/src/session-logic.test.ts+22
...@@ -935,6 +935,28 @@ describe("deriveWorkLogEntries", () => {...@@ -935,6 +935,28 @@ describe("deriveWorkLogEntries", () => {
935 expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]);935 expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]);
936 });936 });
937937
938 it("drops runtime warnings with no displayable content, keeps ones with a preview", () => {
939 const activities: OrchestrationThreadActivity[] = [
940 makeActivity({
941 id: "warning-noise",
942 createdAt: "2026-02-23T00:00:01.000Z",
943 kind: "runtime.warning",
944 summary: "Claude system message 'background_tasks_changed' (no displayable text content)",
945 tone: "info",
946 }),
947 makeActivity({
948 id: "warning-signal",
949 createdAt: "2026-02-23T00:00:02.000Z",
950 kind: "runtime.warning",
951 summary: "Reconnecting... 2/5",
952 tone: "info",
953 }),
954 ];
955
956 const entries = deriveWorkLogEntries(activities);
957 expect(entries.map((entry) => entry.id)).toEqual(["warning-signal"]);
958 });
959
938 it("omits task.started but shows task.progress and task.completed", () => {960 it("omits task.started but shows task.progress and task.completed", () => {
939 const activities: OrchestrationThreadActivity[] = [961 const activities: OrchestrationThreadActivity[] = [
940 makeActivity({962 makeActivity({
apps/web/src/session-logic.ts+23
...@@ -279,6 +279,17 @@ export function workEntryDisplayIndicatesToolFailure(entry: WorkLogEntry): boole...@@ -279,6 +279,17 @@ export function workEntryDisplayIndicatesToolFailure(entry: WorkLogEntry): boole
279 return workEntryIndicatesToolFailureFromOutput(entry, false);279 return workEntryIndicatesToolFailureFromOutput(entry, false);
280}280}
281281
282/** Severe failures keep the red treatment ordinary tool failures lost: runtime
283 * errors and orchestration `*.failed` activities (provider.turn.start.failed,
284 * checkpoint.capture.failed, ...) mean the turn or a core side effect broke,
285 * not that a command exited nonzero. */
286export function workEntrySignalsSevereFailure(entry: WorkLogEntry): boolean {
287 return (
288 entry.sourceActivityKind === "runtime.error" ||
289 entry.sourceActivityKind?.endsWith(".failed") === true
290 );
291}
292
282/** Tool/command row completed without failure (blue check affordance). */293/** Tool/command row completed without failure (blue check affordance). */
283export function workEntryIndicatesToolSuccess(entry: WorkLogEntry): boolean {294export function workEntryIndicatesToolSuccess(entry: WorkLogEntry): boolean {
284 if (!workLogEntryIsToolLike(entry)) {295 if (!workLogEntryIsToolLike(entry)) {
...@@ -870,6 +881,7 @@ export function deriveWorkLogEntries(...@@ -870,6 +881,7 @@ export function deriveWorkLogEntries(
870 if (activity.kind === "tool.progress") continue;881 if (activity.kind === "tool.progress") continue;
871 if (activity.kind === "context-window.updated") continue;882 if (activity.kind === "context-window.updated") continue;
872 if (activity.summary === "Checkpoint captured") continue;883 if (activity.summary === "Checkpoint captured") continue;
884 if (isNoContentRuntimeWarning(activity)) continue;
873 if (isPlanBoundaryToolActivity(activity)) continue;885 if (isPlanBoundaryToolActivity(activity)) continue;
874 if (isAgentInternalActivity(activity)) continue;886 if (isAgentInternalActivity(activity)) continue;
875 entries.push(toDerivedWorkLogEntry(activity));887 entries.push(toDerivedWorkLogEntry(activity));
...@@ -877,6 +889,17 @@ export function deriveWorkLogEntries(...@@ -877,6 +889,17 @@ export function deriveWorkLogEntries(
877 return collapseDerivedWorkLogEntries(entries);889 return collapseDerivedWorkLogEntries(entries);
878}890}
879891
892/** Adapters forward unknown wire-only SDK messages (background_tasks_changed,
893 * commands_changed, ...) as runtime warnings. The suffix comes from
894 * describeUnknownSdkMessage in the Claude adapter; a row with no displayable
895 * text carries nothing a user can act on, so it does not render. */
896function isNoContentRuntimeWarning(activity: OrchestrationThreadActivity): boolean {
897 return (
898 activity.kind === "runtime.warning" &&
899 activity.summary.endsWith("(no displayable text content)")
900 );
901}
902
880function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean {903function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean {
881 if (activity.kind !== "tool.updated" && activity.kind !== "tool.completed") {904 if (activity.kind !== "tool.updated" && activity.kind !== "tool.completed") {
882 return false;905 return false;