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: {
239239 : { ios: "minus", android: "remove" }
240240 }
241241 size={11}
242 tintColor={row.status === "failure" ? "#e11d48" : props.iconSubtleColor}
242 tintColor={props.iconSubtleColor}
243243 type="monochrome"
244244 />
245245 ) : null}
apps/mobile/src/lib/threadActivity.test.ts+32
......@@ -324,6 +324,38 @@ describe("buildThreadFeed", () => {
324324 ]);
325325 });
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
327359 it("collapses matching tool lifecycle rows like desktop", () => {
328360 const thread = makeThread({
329361 id: ThreadId.make("thread-2"),
apps/mobile/src/lib/threadActivity.ts+12
......@@ -338,6 +338,7 @@ function deriveWorkLogEntries(
338338 if (activity.kind === "tool.progress") continue;
339339 if (activity.kind === "context-window.updated") continue;
340340 if (activity.summary === "Checkpoint captured") continue;
341 if (isNoContentRuntimeWarning(activity)) continue;
341342 if (isPlanBoundaryToolActivity(activity)) continue;
342343 if (isAgentInternalActivity(activity)) continue;
343344 entries.push(toDerivedWorkLogEntry(activity));
......@@ -345,6 +346,17 @@ function deriveWorkLogEntries(
345346 return collapseDerivedWorkLogEntries(entries);
346347}
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
348360function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean {
349361 if (activity.kind !== "tool.updated" && activity.kind !== "tool.completed") {
350362 return false;
apps/web/src/components/chat/MessagesTimeline.test.tsx+93-1
......@@ -973,6 +973,49 @@ describe("MessagesTimeline", () => {
973973 expect(markup).not.toContain('aria-label="Tool call failed"');
974974 });
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
9761019 it("keeps mixed work logs neutral after a later tool call succeeds", () => {
9771020 const markup = renderToStaticMarkup(
9781021 <MessagesTimeline
......@@ -1249,11 +1292,22 @@ describe("MessagesTimeline", () => {
12491292 expect(markup).not.toContain('data-testid="file-diff"');
12501293 });
12511294
1252 it("renders a failure marker for failed tool lifecycle entries", () => {
1295 it("renders a muted failure marker for failed tool lifecycle entries", () => {
12531296 const markup = renderToStaticMarkup(
12541297 <MessagesTimeline
12551298 {...buildProps()}
12561299 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 },
12571311 {
12581312 id: "entry-1",
12591313 kind: "work",
......@@ -1273,5 +1327,43 @@ describe("MessagesTimeline", () => {
12731327
12741328 expect(markup).toContain("lucide-x");
12751329 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");
12761368 });
12771369});
apps/web/src/components/chat/MessagesTimeline.tsx+21-33
......@@ -35,6 +35,7 @@ import { FileDiff } from "@pierre/diffs/react";
3535import {
3636 deriveTimelineEntries,
3737 workEntryDisplayIndicatesToolFailure,
38 workEntrySignalsSevereFailure,
3839 workLogEntryIsToolLike,
3940} from "../../session-logic";
4041import { type TurnDiffSummary } from "../../types";
......@@ -1479,7 +1480,7 @@ function LiveActivityContent({
14791480 <span
14801481 className={cn(
14811482 "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",
14831484 )}
14841485 role={announceFailure ? "img" : undefined}
14851486 aria-label={announceFailure ? "Tool call failed" : undefined}
......@@ -1556,16 +1557,9 @@ function WorkGroupToggleTimelineRow({
15561557 aria-expanded={row.expanded}
15571558 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
15581559 >
1559 <span
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 >
1560 <span className="flex size-6 shrink-0 items-center justify-center text-icon-muted">
15671561 <WorkEntryIconSvg
1568 name={row.hasFailure ? "x" : toolGroupSummaryIconName(row.summaryKind)}
1562 name={toolGroupSummaryIconName(row.summaryKind)}
15691563 className="size-4 shrink-0 stroke-[1.8] opacity-70"
15701564 />
15711565 </span>
......@@ -1580,33 +1574,25 @@ function WorkGroupToggleTimelineRow({
15801574 : row.hiddenCount === 1
15811575 ? "log entry"
15821576 : "log entries";
1583 const showHiddenFailure = row.hasFailure && !row.expanded;
1584
15851577 return (
15861578 <button
15871579 type="button"
15881580 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 }
15891586 aria-expanded={row.expanded}
15901587 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
15911588 >
1592 <span
1593 className={cn(
1594 "flex size-6 shrink-0 items-center justify-center",
1595 showHiddenFailure ? "text-destructive" : "text-icon-muted",
1596 )}
1597 role={showHiddenFailure ? "img" : undefined}
1598 aria-label={showHiddenFailure ? "Hidden work includes a failure" : undefined}
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 )}
1589 <span className="flex size-6 shrink-0 items-center justify-center text-icon-muted">
1590 <ChevronDownIcon
1591 className={cn(
1592 "size-4 shrink-0 opacity-70 transition-transform duration-200",
1593 row.expanded && "rotate-180",
1594 )}
1595 />
16101596 </span>
16111597 {row.expanded ? (
16121598 <span className="font-medium text-foreground">
......@@ -2631,11 +2617,13 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
26312617 const canExpand = expandedBody !== null;
26322618 const showDestructiveRowStyle =
26332619 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.
26352623 const iconWrapperClass = cn(
26362624 "flex size-6 shrink-0 items-center justify-center",
2637 showWarningIndicator || showFailedIndicator
2638 ? "text-destructive"
2625 showWarningIndicator
2626 ? "text-warning"
26392627 : showDestructiveRowStyle
26402628 ? "text-destructive"
26412629 : workEntry.tone === "tool" || showFailedIndicator
apps/web/src/session-logic.test.ts+22
......@@ -935,6 +935,28 @@ describe("deriveWorkLogEntries", () => {
935935 expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]);
936936 });
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
938960 it("omits task.started but shows task.progress and task.completed", () => {
939961 const activities: OrchestrationThreadActivity[] = [
940962 makeActivity({
apps/web/src/session-logic.ts+23
......@@ -279,6 +279,17 @@ export function workEntryDisplayIndicatesToolFailure(entry: WorkLogEntry): boole
279279 return workEntryIndicatesToolFailureFromOutput(entry, false);
280280}
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
282293/** Tool/command row completed without failure (blue check affordance). */
283294export function workEntryIndicatesToolSuccess(entry: WorkLogEntry): boolean {
284295 if (!workLogEntryIsToolLike(entry)) {
......@@ -870,6 +881,7 @@ export function deriveWorkLogEntries(
870881 if (activity.kind === "tool.progress") continue;
871882 if (activity.kind === "context-window.updated") continue;
872883 if (activity.summary === "Checkpoint captured") continue;
884 if (isNoContentRuntimeWarning(activity)) continue;
873885 if (isPlanBoundaryToolActivity(activity)) continue;
874886 if (isAgentInternalActivity(activity)) continue;
875887 entries.push(toDerivedWorkLogEntry(activity));
......@@ -877,6 +889,17 @@ export function deriveWorkLogEntries(
877889 return collapseDerivedWorkLogEntries(entries);
878890}
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
880903function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean {
881904 if (activity.kind !== "tool.updated" && activity.kind !== "tool.completed") {
882905 return false;