| author | |
| committer | |
| log | f1e6f0c9bbe527a76eaae9e3b498a5977ce3c7d6 |
| tree | ef84098d464044de3705cbe2b12a8cc237ea830c |
| parent | b982847ab201795711884d1dfadfddb3864adb23 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 239 | : { ios: "minus", android: "remove" } |
| 240 | 240 | } |
| 241 | 241 | size={11} |
| 242 | tintColor={row.status === "failure" ? "#e11d48" : props.iconSubtleColor} | |
| 242 | tintColor={props.iconSubtleColor} | |
| 243 | 243 | type="monochrome" |
| 244 | 244 | /> |
| 245 | 245 | ) : null} |
apps/mobile/src/lib/threadActivity.test.ts+32| ... | ... | @@ -324,6 +324,38 @@ describe("buildThreadFeed", () => { |
| 324 | 324 | ]); |
| 325 | 325 | }); |
| 326 | 326 | |
| 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 | 359 | it("collapses matching tool lifecycle rows like desktop", () => { |
| 328 | 360 | const thread = makeThread({ |
| 329 | 361 | id: ThreadId.make("thread-2"), |
apps/mobile/src/lib/threadActivity.ts+12| ... | ... | @@ -338,6 +338,7 @@ function deriveWorkLogEntries( |
| 338 | 338 | if (activity.kind === "tool.progress") continue; |
| 339 | 339 | if (activity.kind === "context-window.updated") continue; |
| 340 | 340 | if (activity.summary === "Checkpoint captured") continue; |
| 341 | if (isNoContentRuntimeWarning(activity)) continue; | |
| 341 | 342 | if (isPlanBoundaryToolActivity(activity)) continue; |
| 342 | 343 | if (isAgentInternalActivity(activity)) continue; |
| 343 | 344 | entries.push(toDerivedWorkLogEntry(activity)); |
| ... | ... | @@ -345,6 +346,17 @@ function deriveWorkLogEntries( |
| 345 | 346 | return collapseDerivedWorkLogEntries(entries); |
| 346 | 347 | } |
| 347 | 348 | |
| 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. */ | |
| 353 | function isNoContentRuntimeWarning(activity: OrchestrationThreadActivity): boolean { | |
| 354 | return ( | |
| 355 | activity.kind === "runtime.warning" && | |
| 356 | activity.summary.endsWith("(no displayable text content)") | |
| 357 | ); | |
| 358 | } | |
| 359 | ||
| 348 | 360 | function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean { |
| 349 | 361 | if (activity.kind !== "tool.updated" && activity.kind !== "tool.completed") { |
| 350 | 362 | return false; |
apps/web/src/components/chat/MessagesTimeline.test.tsx+93-1| ... | ... | @@ -973,6 +973,49 @@ describe("MessagesTimeline", () => { |
| 973 | 973 | expect(markup).not.toContain('aria-label="Tool call failed"'); |
| 974 | 974 | }); |
| 975 | 975 | |
| 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 | 1019 | it("keeps mixed work logs neutral after a later tool call succeeds", () => { |
| 977 | 1020 | const markup = renderToStaticMarkup( |
| 978 | 1021 | <MessagesTimeline |
| ... | ... | @@ -1249,11 +1292,22 @@ describe("MessagesTimeline", () => { |
| 1249 | 1292 | expect(markup).not.toContain('data-testid="file-diff"'); |
| 1250 | 1293 | }); |
| 1251 | 1294 | |
| 1252 | it("renders a failure marker for failed tool lifecycle entries", () => { | |
| 1295 | it("renders a muted failure marker for failed tool lifecycle entries", () => { | |
| 1253 | 1296 | const markup = renderToStaticMarkup( |
| 1254 | 1297 | <MessagesTimeline |
| 1255 | 1298 | {...buildProps()} |
| 1256 | 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 | 1312 | id: "entry-1", |
| 1259 | 1313 | kind: "work", |
| ... | ... | @@ -1273,5 +1327,43 @@ describe("MessagesTimeline", () => { |
| 1273 | 1327 | |
| 1274 | 1328 | expect(markup).toContain("lucide-x"); |
| 1275 | 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 | 35 | import { |
| 36 | 36 | deriveTimelineEntries, |
| 37 | 37 | workEntryDisplayIndicatesToolFailure, |
| 38 | workEntrySignalsSevereFailure, | |
| 38 | 39 | workLogEntryIsToolLike, |
| 39 | 40 | } from "../../session-logic"; |
| 40 | 41 | import { type TurnDiffSummary } from "../../types"; |
| ... | ... | @@ -1479,7 +1480,7 @@ function LiveActivityContent({ |
| 1479 | 1480 | <span |
| 1480 | 1481 | className={cn( |
| 1481 | 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 | 1485 | role={announceFailure ? "img" : undefined} |
| 1485 | 1486 | aria-label={announceFailure ? "Tool call failed" : undefined} |
| ... | ... | @@ -1556,16 +1557,9 @@ function WorkGroupToggleTimelineRow({ |
| 1556 | 1557 | aria-expanded={row.expanded} |
| 1557 | 1558 | onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)} |
| 1558 | 1559 | > |
| 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"> | |
| 1567 | 1561 | <WorkEntryIconSvg |
| 1568 | name={row.hasFailure ? "x" : toolGroupSummaryIconName(row.summaryKind)} | |
| 1562 | name={toolGroupSummaryIconName(row.summaryKind)} | |
| 1569 | 1563 | className="size-4 shrink-0 stroke-[1.8] opacity-70" |
| 1570 | 1564 | /> |
| 1571 | 1565 | </span> |
| ... | ... | @@ -1580,33 +1574,25 @@ function WorkGroupToggleTimelineRow({ |
| 1580 | 1574 | : row.hiddenCount === 1 |
| 1581 | 1575 | ? "log entry" |
| 1582 | 1576 | : "log entries"; |
| 1583 | const showHiddenFailure = row.hasFailure && !row.expanded; | |
| 1584 | ||
| 1585 | 1577 | return ( |
| 1586 | 1578 | <button |
| 1587 | 1579 | type="button" |
| 1588 | 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 | 1586 | aria-expanded={row.expanded} |
| 1590 | 1587 | onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)} |
| 1591 | 1588 | > |
| 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 | /> | |
| 1610 | 1596 | </span> |
| 1611 | 1597 | {row.expanded ? ( |
| 1612 | 1598 | <span className="font-medium text-foreground"> |
| ... | ... | @@ -2631,11 +2617,13 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { |
| 2631 | 2617 | const canExpand = expandedBody !== null; |
| 2632 | 2618 | const showDestructiveRowStyle = |
| 2633 | 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 | 2623 | const iconWrapperClass = cn( |
| 2636 | 2624 | "flex size-6 shrink-0 items-center justify-center", |
| 2637 | showWarningIndicator || showFailedIndicator | |
| 2638 | ? "text-destructive" | |
| 2625 | showWarningIndicator | |
| 2626 | ? "text-warning" | |
| 2639 | 2627 | : showDestructiveRowStyle |
| 2640 | 2628 | ? "text-destructive" |
| 2641 | 2629 | : workEntry.tone === "tool" || showFailedIndicator |
apps/web/src/session-logic.test.ts+22| ... | ... | @@ -935,6 +935,28 @@ describe("deriveWorkLogEntries", () => { |
| 935 | 935 | expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]); |
| 936 | 936 | }); |
| 937 | 937 | |
| 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 | 960 | it("omits task.started but shows task.progress and task.completed", () => { |
| 939 | 961 | const activities: OrchestrationThreadActivity[] = [ |
| 940 | 962 | makeActivity({ |
apps/web/src/session-logic.ts+23| ... | ... | @@ -279,6 +279,17 @@ export function workEntryDisplayIndicatesToolFailure(entry: WorkLogEntry): boole |
| 279 | 279 | return workEntryIndicatesToolFailureFromOutput(entry, false); |
| 280 | 280 | } |
| 281 | 281 | |
| 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. */ | |
| 286 | export function workEntrySignalsSevereFailure(entry: WorkLogEntry): boolean { | |
| 287 | return ( | |
| 288 | entry.sourceActivityKind === "runtime.error" || | |
| 289 | entry.sourceActivityKind?.endsWith(".failed") === true | |
| 290 | ); | |
| 291 | } | |
| 292 | ||
| 282 | 293 | /** Tool/command row completed without failure (blue check affordance). */ |
| 283 | 294 | export function workEntryIndicatesToolSuccess(entry: WorkLogEntry): boolean { |
| 284 | 295 | if (!workLogEntryIsToolLike(entry)) { |
| ... | ... | @@ -870,6 +881,7 @@ export function deriveWorkLogEntries( |
| 870 | 881 | if (activity.kind === "tool.progress") continue; |
| 871 | 882 | if (activity.kind === "context-window.updated") continue; |
| 872 | 883 | if (activity.summary === "Checkpoint captured") continue; |
| 884 | if (isNoContentRuntimeWarning(activity)) continue; | |
| 873 | 885 | if (isPlanBoundaryToolActivity(activity)) continue; |
| 874 | 886 | if (isAgentInternalActivity(activity)) continue; |
| 875 | 887 | entries.push(toDerivedWorkLogEntry(activity)); |
| ... | ... | @@ -877,6 +889,17 @@ export function deriveWorkLogEntries( |
| 877 | 889 | return collapseDerivedWorkLogEntries(entries); |
| 878 | 890 | } |
| 879 | 891 | |
| 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. */ | |
| 896 | function isNoContentRuntimeWarning(activity: OrchestrationThreadActivity): boolean { | |
| 897 | return ( | |
| 898 | activity.kind === "runtime.warning" && | |
| 899 | activity.summary.endsWith("(no displayable text content)") | |
| 900 | ); | |
| 901 | } | |
| 902 | ||
| 880 | 903 | function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean { |
| 881 | 904 | if (activity.kind !== "tool.updated" && activity.kind !== "tool.completed") { |
| 882 | 905 | return false; |