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
17 changes: 14 additions & 3 deletions apps/mobile/src/features/threads/thread-work-log.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -154,7 +154,11 @@ export function ThreadWorkLog(props: {
const canExpand = row.canExpand;
const fullDetail = expanded ? row.getFullDetail() : null;
const displayText = row.detail ? `${row.summary} ${row.detail}` : row.summary;
const iconIsDestructive = row.icon === "alert" || row.icon === "warning";
// Warnings are not errors. Web reserves destructive red for
// runtime.error and orchestration *.failed rows and paints warnings
// amber; mobile matches that split rather than colouring both rose.
const iconIsDestructive = row.icon === "alert";
const iconIsWarning = row.icon === "warning";

return (
<Animated.View
Expand DownExpand Up@@ -189,7 +193,13 @@ export function ThreadWorkLog(props: {
name={workRowSymbolName(row.icon)}
size={13}
weight="medium"
tintColor={iconIsDestructive ? "#e11d48" : props.iconSubtleColor}
tintColor={
iconIsDestructive
? "#e11d48"
: iconIsWarning
? "#d97706"
: props.iconSubtleColor
}
type="monochrome"
/>
</View>
Expand All@@ -199,6 +209,7 @@ export function ThreadWorkLog(props: {
className={cn(
"font-t3-medium text-foreground",
iconIsDestructive && "text-rose-600 dark:text-rose-400",
iconIsWarning && "text-amber-600 dark:text-amber-400",
)}
>
{row.summary}
Expand DownExpand Up@@ -239,7 +250,7 @@ export function ThreadWorkLog(props: {
: { ios: "minus", android: "remove" }
}
size={11}
tintColor={row.status === "failure" ? "#e11d48" : props.iconSubtleColor}
tintColor={props.iconSubtleColor}
type="monochrome"
/>
) : null}
Expand Down
32 changes: 32 additions & 0 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -434,6 +434,38 @@ describe("buildThreadFeed", () => {
]);
});

it("drops runtime warnings with no displayable content", () => {
const thread = makeThread({
id: ThreadId.make("thread-noise"),
projectId: ProjectId.make("project-1"),
title: "Warning noise thread",
activities: [
makeActivity({
id: EventId.make("activity-noise"),
kind: "runtime.warning",
summary: "Claude system message 'background_tasks_changed' (no displayable text content)",
createdAt: "2026-04-01T00:00:02.000Z",
turnId: TurnId.make("turn-1"),
}),
makeActivity({
id: EventId.make("activity-signal"),
kind: "runtime.warning",
summary: "Reconnecting... 2/5",
createdAt: "2026-04-01T00:00:03.000Z",
turnId: TurnId.make("turn-1"),
}),
],
});

const feed = buildThreadFeed(thread);
expect(feed).toMatchObject([
{
type: "activity-group",
activities: [{ id: "activity-signal" }],
},
]);
});

it("collapses matching tool lifecycle rows like desktop", () => {
const thread = makeThread({
id: ThreadId.make("thread-2"),
Expand Down
12 changes: 12 additions & 0 deletions apps/mobile/src/lib/threadActivity.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -354,13 +354,25 @@ function deriveWorkLogEntries(
continue;
}
if (activity.summary === "Checkpoint captured") continue;
if (isNoContentRuntimeWarning(activity)) continue;
if (isPlanBoundaryToolActivity(activity)) continue;
if (isAgentInternalActivity(activity)) continue;
entries.push(toDerivedWorkLogEntry(activity));
}
return collapseDerivedWorkLogEntries(entries);
}

/** Adapters forward unknown wire-only SDK messages (background_tasks_changed,
* commands_changed, ...) as runtime warnings. The suffix comes from
* describeUnknownSdkMessage in the Claude adapter; a row with no displayable
* text carries nothing a user can act on, so it does not render. */
function isNoContentRuntimeWarning(activity: OrchestrationThreadActivity): boolean {
return (
activity.kind === "runtime.warning" &&
activity.summary.endsWith("(no displayable text content)")
);
}

function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean {
if (activity.kind !== "tool.updated" && activity.kind !== "tool.completed") {
return false;
Expand Down
103 changes: 102 additions & 1 deletion apps/web/src/components/chat/MessagesTimeline.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -989,6 +989,53 @@ describe("MessagesTimeline", () => {
expect(markup).not.toContain('aria-label="Tool call failed"');
});

it("keeps the collapsed summary icon neutral when the group ends in a failure", () => {
const markup = renderToStaticMarkup(
<MessagesTimeline
{...buildProps()}
timelineEntries={[
{
id: "entry-completed",
kind: "work",
createdAt: "2026-03-17T19:12:28.000Z",
entry: {
id: "work-completed",
createdAt: "2026-03-17T19:12:28.000Z",
label: "Run tests",
tone: "tool",
itemType: "command_execution",
toolLifecycleStatus: "completed",
},
},
{
id: "entry-failed",
kind: "work",
createdAt: "2026-03-17T19:12:29.000Z",
entry: {
id: "work-failed",
createdAt: "2026-03-17T19:12:29.000Z",
label: "Run lint",
tone: "tool",
itemType: "command_execution",
toolLifecycleStatus: "failed",
},
},
]}
/>,
);

expect(markup).toContain("Ran 2 commands");
// Neutral: the group keeps its command glyph instead of swapping to a
// failure glyph, and neither the heading nor the icon wrapper goes red once
// the group ends in an ordinary command failure. Pylon renders the command
// summary as a DotMatrix, so assert its state rather than a lucide class.
expect(markup).toContain('data-state="terminal"');
expect(markup).not.toMatch(/font-medium text-destructive/);
expect(markup).not.toMatch(/size-6 shrink-0 items-center justify-center text-destructive/);
// The failure stays discoverable for screen readers.
expect(markup).toContain("tool call failed");
});

it("keeps mixed work logs neutral after a later tool call succeeds", () => {
const markup = renderToStaticMarkup(
<MessagesTimeline
Expand DownExpand Up@@ -1402,11 +1449,22 @@ describe("MessagesTimeline", () => {
expect(markup).not.toContain('data-testid="file-diff"');
});

it("renders a failure marker for failed tool lifecycle entries", () => {
it("renders a muted failure marker for failed tool lifecycle entries", () => {
const markup = renderToStaticMarkup(
<MessagesTimeline
{...buildProps()}
timelineEntries={[
{
id: "entry-info",
kind: "work",
createdAt: "2026-03-17T19:12:27.000Z",
entry: {
id: "work-info",
createdAt: "2026-03-17T19:12:27.000Z",
label: "Status updated",
tone: "info",
},
},
{
id: "entry-1",
kind: "work",
Expand All@@ -1426,5 +1484,48 @@ describe("MessagesTimeline", () => {

expect(markup).toContain('data-state="error"');
expect(markup).toContain('aria-label="Tool call failed"');
// Ordinary tool failures render muted, not red. Pylon's marker is a small
// DotMatrix status dot that is itself destructive-coloured, so assert on the
// row treatment rather than on the absence of the class anywhere in the tree.
expect(markup).not.toMatch(/font-medium text-destructive/);
expect(markup).not.toMatch(/size-6 shrink-0 items-center justify-center text-destructive/);
});

it("keeps the red treatment for severe orchestration failures", () => {
const markup = renderToStaticMarkup(
<MessagesTimeline
{...buildProps()}
timelineEntries={[
{
id: "entry-info",
kind: "work",
createdAt: "2026-03-17T19:12:27.000Z",
entry: {
id: "work-info",
createdAt: "2026-03-17T19:12:27.000Z",
label: "Status updated",
tone: "info",
},
},
{
id: "entry-turn-failed",
kind: "work",
createdAt: "2026-03-17T19:12:28.000Z",
entry: {
id: "work-turn-failed",
createdAt: "2026-03-17T19:12:28.000Z",
label: "Provider turn start failed",
tone: "error",
sourceActivityKind: "provider.turn.start.failed",
},
},
]}
/>,
);

// Pylon renders severe failures with circle-alert where upstream uses an x.
expect(markup).toContain("lucide-circle-alert");
expect(markup).toMatch(/font-medium text-destructive/);
expect(markup).toMatch(/size-6 shrink-0 items-center justify-center text-destructive/);
});
});
52 changes: 23 additions & 29 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,6 +38,7 @@ import {
workEntryIndicatesToolSuccess,
workLogEntryIsMissingResponse,
workEntryDisplayIndicatesToolFailure,
workEntrySignalsSevereFailure,
workLogEntryIsToolLike,
} from "../../session-logic";
import { type TurnDiffSummary } from "../../types";
Expand DownExpand Up@@ -1647,33 +1648,25 @@ function WorkGroupToggleTimelineRow({
: row.hiddenCount === 1
? "log entry"
: "log entries";
const showHiddenFailure = row.hasFailure && !row.expanded;

return (
<button
type="button"
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"
aria-label={
row.hasFailure && !row.expanded
? `+${row.hiddenCount} previous ${labelNoun}, includes a failure`
: undefined
}
aria-expanded={row.expanded}
onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
>
<span
className={cn(
"flex size-6 shrink-0 items-center justify-center",
showHiddenFailure ? "text-destructive" : "text-icon-muted",
)}
role={showHiddenFailure ? "img" : undefined}
aria-label={showHiddenFailure ? "Hidden work includes a failure" : undefined}
>
{showHiddenFailure ? (
<WorkEntryIconSvg name="x" className="shrink-0 stroke-[1.8] opacity-70" />
) : (
<ChevronDownIcon
className={cn(
"size-4 shrink-0 opacity-70 transition-transform duration-200",
row.expanded && "rotate-180",
)}
/>
)}
<span className="flex size-6 shrink-0 items-center justify-center text-icon-muted">
<ChevronDownIcon
className={cn(
"size-4 shrink-0 opacity-70 transition-transform duration-200",
row.expanded && "rotate-180",
)}
/>
</span>
{row.expanded ? (
<span className="font-medium text-foreground">
Expand DownExpand Up@@ -2220,7 +2213,6 @@ type WorkEntryIconName =
| "square-pen"
| "terminal"
| "wrench"
| "x"
| "zap";

function WorkEntryIconSvg({ name, className }: { name: WorkEntryIconName; className: string }) {
Expand DownExpand Up@@ -2258,8 +2250,6 @@ function WorkEntryIconSvg({ name, className }: { name: WorkEntryIconName; classN
);
case "wrench":
return <WrenchIcon className={fixedIconClassName} aria-hidden />;
case "x":
return <DotMatrix sizeRole="inline" aria-hidden state="error" className={className} />;
case "zap":
return <ZapIcon className={fixedIconClassName} aria-hidden />;
}
Expand DownExpand Up@@ -2730,14 +2720,18 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
(turnSettled && workEntryIndicatesToolNeutralStatus(workEntry));
const showDestructiveRowStyle =
showFailedIndicator &&
(workEntry.sourceActivityKind === "runtime.error" || !workLogEntryIsToolLike(workEntry));
(workEntrySignalsSevereFailure(workEntry) || !workLogEntryIsToolLike(workEntry));
// Ordinary tool failures stay muted; only runtime errors and warnings get
// color. The red treatment is reserved for severe failures.
const iconWrapperClass = cn(
"flex size-6 shrink-0 items-center justify-center",
showWarningIndicator || showDestructiveRowStyle
? "text-destructive"
: workLogEntryIsToolLike(workEntry)
? "text-icon-muted"
: iconConfig.className,
showWarningIndicator
? "text-warning"
: showDestructiveRowStyle
? "text-destructive"
: workLogEntryIsToolLike(workEntry)
? "text-icon-muted"
: iconConfig.className,
);
const headingClass = showWarningIndicator
? "font-medium text-warning"
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
17 changes: 14 additions & 3 deletions apps/mobile/src/features/threads/thread-work-log.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -154,7 +154,11 @@ export function ThreadWorkLog(props: {
const canExpand = row.canExpand;
const fullDetail = expanded ? row.getFullDetail() : null;
const displayText = row.detail ? `${row.summary} ${row.detail}` : row.summary;
const iconIsDestructive = row.icon === "alert" || row.icon === "warning";
// Warnings are not errors. Web reserves destructive red for
// runtime.error and orchestration *.failed rows and paints warnings
// amber; mobile matches that split rather than colouring both rose.
const iconIsDestructive = row.icon === "alert";
const iconIsWarning = row.icon === "warning";

return (
<Animated.View
Expand DownExpand Up@@ -189,7 +193,13 @@ export function ThreadWorkLog(props: {
name={workRowSymbolName(row.icon)}
size={13}
weight="medium"
tintColor={iconIsDestructive ? "#e11d48" : props.iconSubtleColor}
tintColor={
iconIsDestructive
? "#e11d48"
: iconIsWarning
? "#d97706"
: props.iconSubtleColor
}
type="monochrome"
/>
</View>
Expand All@@ -199,6 +209,7 @@ export function ThreadWorkLog(props: {
className={cn(
"font-t3-medium text-foreground",
iconIsDestructive && "text-rose-600 dark:text-rose-400",
iconIsWarning && "text-amber-600 dark:text-amber-400",
)}
>
{row.summary}
Expand DownExpand Up@@ -239,7 +250,7 @@ export function ThreadWorkLog(props: {
: { ios: "minus", android: "remove" }
}
size={11}
tintColor={row.status === "failure" ? "#e11d48" : props.iconSubtleColor}
tintColor={props.iconSubtleColor}
type="monochrome"
/>
) : null}
Expand Down
32 changes: 32 additions & 0 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -434,6 +434,38 @@ describe("buildThreadFeed", () => {
]);
});

it("drops runtime warnings with no displayable content", () => {
const thread = makeThread({
id: ThreadId.make("thread-noise"),
projectId: ProjectId.make("project-1"),
title: "Warning noise thread",
activities: [
makeActivity({
id: EventId.make("activity-noise"),
kind: "runtime.warning",
summary: "Claude system message 'background_tasks_changed' (no displayable text content)",
createdAt: "2026-04-01T00:00:02.000Z",
turnId: TurnId.make("turn-1"),
}),
makeActivity({
id: EventId.make("activity-signal"),
kind: "runtime.warning",
summary: "Reconnecting... 2/5",
createdAt: "2026-04-01T00:00:03.000Z",
turnId: TurnId.make("turn-1"),
}),
],
});

const feed = buildThreadFeed(thread);
expect(feed).toMatchObject([
{
type: "activity-group",
activities: [{ id: "activity-signal" }],
},
]);
});

it("collapses matching tool lifecycle rows like desktop", () => {
const thread = makeThread({
id: ThreadId.make("thread-2"),
Expand Down
12 changes: 12 additions & 0 deletions apps/mobile/src/lib/threadActivity.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -354,13 +354,25 @@ function deriveWorkLogEntries(
continue;
}
if (activity.summary === "Checkpoint captured") continue;
if (isNoContentRuntimeWarning(activity)) continue;
if (isPlanBoundaryToolActivity(activity)) continue;
if (isAgentInternalActivity(activity)) continue;
entries.push(toDerivedWorkLogEntry(activity));
}
return collapseDerivedWorkLogEntries(entries);
}

/** Adapters forward unknown wire-only SDK messages (background_tasks_changed,
* commands_changed, ...) as runtime warnings. The suffix comes from
* describeUnknownSdkMessage in the Claude adapter; a row with no displayable
* text carries nothing a user can act on, so it does not render. */
function isNoContentRuntimeWarning(activity: OrchestrationThreadActivity): boolean {
return (
activity.kind === "runtime.warning" &&
activity.summary.endsWith("(no displayable text content)")
);
}

function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean {
if (activity.kind !== "tool.updated" && activity.kind !== "tool.completed") {
return false;
Expand Down
103 changes: 102 additions & 1 deletion apps/web/src/components/chat/MessagesTimeline.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -989,6 +989,53 @@ describe("MessagesTimeline", () => {
expect(markup).not.toContain('aria-label="Tool call failed"');
});

it("keeps the collapsed summary icon neutral when the group ends in a failure", () => {
const markup = renderToStaticMarkup(
<MessagesTimeline
{...buildProps()}
timelineEntries={[
{
id: "entry-completed",
kind: "work",
createdAt: "2026-03-17T19:12:28.000Z",
entry: {
id: "work-completed",
createdAt: "2026-03-17T19:12:28.000Z",
label: "Run tests",
tone: "tool",
itemType: "command_execution",
toolLifecycleStatus: "completed",
},
},
{
id: "entry-failed",
kind: "work",
createdAt: "2026-03-17T19:12:29.000Z",
entry: {
id: "work-failed",
createdAt: "2026-03-17T19:12:29.000Z",
label: "Run lint",
tone: "tool",
itemType: "command_execution",
toolLifecycleStatus: "failed",
},
},
]}
/>,
);

expect(markup).toContain("Ran 2 commands");
// Neutral: the group keeps its command glyph instead of swapping to a
// failure glyph, and neither the heading nor the icon wrapper goes red once
// the group ends in an ordinary command failure. Pylon renders the command
// summary as a DotMatrix, so assert its state rather than a lucide class.
expect(markup).toContain('data-state="terminal"');
expect(markup).not.toMatch(/font-medium text-destructive/);
expect(markup).not.toMatch(/size-6 shrink-0 items-center justify-center text-destructive/);
// The failure stays discoverable for screen readers.
expect(markup).toContain("tool call failed");
});

it("keeps mixed work logs neutral after a later tool call succeeds", () => {
const markup = renderToStaticMarkup(
<MessagesTimeline
Expand DownExpand Up@@ -1402,11 +1449,22 @@ describe("MessagesTimeline", () => {
expect(markup).not.toContain('data-testid="file-diff"');
});

it("renders a failure marker for failed tool lifecycle entries", () => {
it("renders a muted failure marker for failed tool lifecycle entries", () => {
const markup = renderToStaticMarkup(
<MessagesTimeline
{...buildProps()}
timelineEntries={[
{
id: "entry-info",
kind: "work",
createdAt: "2026-03-17T19:12:27.000Z",
entry: {
id: "work-info",
createdAt: "2026-03-17T19:12:27.000Z",
label: "Status updated",
tone: "info",
},
},
{
id: "entry-1",
kind: "work",
Expand All@@ -1426,5 +1484,48 @@ describe("MessagesTimeline", () => {

expect(markup).toContain('data-state="error"');
expect(markup).toContain('aria-label="Tool call failed"');
// Ordinary tool failures render muted, not red. Pylon's marker is a small
// DotMatrix status dot that is itself destructive-coloured, so assert on the
// row treatment rather than on the absence of the class anywhere in the tree.
expect(markup).not.toMatch(/font-medium text-destructive/);
expect(markup).not.toMatch(/size-6 shrink-0 items-center justify-center text-destructive/);
});

it("keeps the red treatment for severe orchestration failures", () => {
const markup = renderToStaticMarkup(
<MessagesTimeline
{...buildProps()}
timelineEntries={[
{
id: "entry-info",
kind: "work",
createdAt: "2026-03-17T19:12:27.000Z",
entry: {
id: "work-info",
createdAt: "2026-03-17T19:12:27.000Z",
label: "Status updated",
tone: "info",
},
},
{
id: "entry-turn-failed",
kind: "work",
createdAt: "2026-03-17T19:12:28.000Z",
entry: {
id: "work-turn-failed",
createdAt: "2026-03-17T19:12:28.000Z",
label: "Provider turn start failed",
tone: "error",
sourceActivityKind: "provider.turn.start.failed",
},
},
]}
/>,
);

// Pylon renders severe failures with circle-alert where upstream uses an x.
expect(markup).toContain("lucide-circle-alert");
expect(markup).toMatch(/font-medium text-destructive/);
expect(markup).toMatch(/size-6 shrink-0 items-center justify-center text-destructive/);
});
});
52 changes: 23 additions & 29 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,6 +38,7 @@ import {
workEntryIndicatesToolSuccess,
workLogEntryIsMissingResponse,
workEntryDisplayIndicatesToolFailure,
workEntrySignalsSevereFailure,
workLogEntryIsToolLike,
} from "../../session-logic";
import { type TurnDiffSummary } from "../../types";
Expand DownExpand Up@@ -1647,33 +1648,25 @@ function WorkGroupToggleTimelineRow({
: row.hiddenCount === 1
? "log entry"
: "log entries";
const showHiddenFailure = row.hasFailure && !row.expanded;

return (
<button
type="button"
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"
aria-label={
row.hasFailure && !row.expanded
? `+${row.hiddenCount} previous ${labelNoun}, includes a failure`
: undefined
}
aria-expanded={row.expanded}
onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
>
<span
className={cn(
"flex size-6 shrink-0 items-center justify-center",
showHiddenFailure ? "text-destructive" : "text-icon-muted",
)}
role={showHiddenFailure ? "img" : undefined}
aria-label={showHiddenFailure ? "Hidden work includes a failure" : undefined}
>
{showHiddenFailure ? (
<WorkEntryIconSvg name="x" className="shrink-0 stroke-[1.8] opacity-70" />
) : (
<ChevronDownIcon
className={cn(
"size-4 shrink-0 opacity-70 transition-transform duration-200",
row.expanded && "rotate-180",
)}
/>
)}
<span className="flex size-6 shrink-0 items-center justify-center text-icon-muted">
<ChevronDownIcon
className={cn(
"size-4 shrink-0 opacity-70 transition-transform duration-200",
row.expanded && "rotate-180",
)}
/>
</span>
{row.expanded ? (
<span className="font-medium text-foreground">
Expand DownExpand Up@@ -2220,7 +2213,6 @@ type WorkEntryIconName =
| "square-pen"
| "terminal"
| "wrench"
| "x"
| "zap";

function WorkEntryIconSvg({ name, className }: { name: WorkEntryIconName; className: string }) {
Expand DownExpand Up@@ -2258,8 +2250,6 @@ function WorkEntryIconSvg({ name, className }: { name: WorkEntryIconName; classN
);
case "wrench":
return <WrenchIcon className={fixedIconClassName} aria-hidden />;
case "x":
return <DotMatrix sizeRole="inline" aria-hidden state="error" className={className} />;
case "zap":
return <ZapIcon className={fixedIconClassName} aria-hidden />;
}
Expand DownExpand Up@@ -2730,14 +2720,18 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
(turnSettled && workEntryIndicatesToolNeutralStatus(workEntry));
const showDestructiveRowStyle =
showFailedIndicator &&
(workEntry.sourceActivityKind === "runtime.error" || !workLogEntryIsToolLike(workEntry));
(workEntrySignalsSevereFailure(workEntry) || !workLogEntryIsToolLike(workEntry));
// Ordinary tool failures stay muted; only runtime errors and warnings get
// color. The red treatment is reserved for severe failures.
const iconWrapperClass = cn(
"flex size-6 shrink-0 items-center justify-center",
showWarningIndicator || showDestructiveRowStyle
? "text-destructive"
: workLogEntryIsToolLike(workEntry)
? "text-icon-muted"
: iconConfig.className,
showWarningIndicator
? "text-warning"
: showDestructiveRowStyle
? "text-destructive"
: workLogEntryIsToolLike(workEntry)
? "text-icon-muted"
: iconConfig.className,
);
const headingClass = showWarningIndicator
? "font-medium text-warning"
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
17 changes: 14 additions & 3 deletions apps/mobile/src/features/threads/thread-work-log.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -154,7 +154,11 @@ export function ThreadWorkLog(props: {
const canExpand = row.canExpand;
const fullDetail = expanded ? row.getFullDetail() : null;
const displayText = row.detail ? `${row.summary} ${row.detail}` : row.summary;
const iconIsDestructive = row.icon === "alert" || row.icon === "warning";
// Warnings are not errors. Web reserves destructive red for
// runtime.error and orchestration *.failed rows and paints warnings
// amber; mobile matches that split rather than colouring both rose.
const iconIsDestructive = row.icon === "alert";
const iconIsWarning = row.icon === "warning";

return (
<Animated.View
Expand DownExpand Up@@ -189,7 +193,13 @@ export function ThreadWorkLog(props: {
name={workRowSymbolName(row.icon)}
size={13}
weight="medium"
tintColor={iconIsDestructive ? "#e11d48" : props.iconSubtleColor}
tintColor={
iconIsDestructive
? "#e11d48"
: iconIsWarning
? "#d97706"
: props.iconSubtleColor
}
type="monochrome"
/>
</View>
Expand All@@ -199,6 +209,7 @@ export function ThreadWorkLog(props: {
className={cn(
"font-t3-medium text-foreground",
iconIsDestructive && "text-rose-600 dark:text-rose-400",
iconIsWarning && "text-amber-600 dark:text-amber-400",
)}
>
{row.summary}
Expand DownExpand Up@@ -239,7 +250,7 @@ export function ThreadWorkLog(props: {
: { ios: "minus", android: "remove" }
}
size={11}
tintColor={row.status === "failure" ? "#e11d48" : props.iconSubtleColor}
tintColor={props.iconSubtleColor}
type="monochrome"
/>
) : null}
Expand Down
32 changes: 32 additions & 0 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -434,6 +434,38 @@ describe("buildThreadFeed", () => {
]);
});

it("drops runtime warnings with no displayable content", () => {
const thread = makeThread({
id: ThreadId.make("thread-noise"),
projectId: ProjectId.make("project-1"),
title: "Warning noise thread",
activities: [
makeActivity({
id: EventId.make("activity-noise"),
kind: "runtime.warning",
summary: "Claude system message 'background_tasks_changed' (no displayable text content)",
createdAt: "2026-04-01T00:00:02.000Z",
turnId: TurnId.make("turn-1"),
}),
makeActivity({
id: EventId.make("activity-signal"),
kind: "runtime.warning",
summary: "Reconnecting... 2/5",
createdAt: "2026-04-01T00:00:03.000Z",
turnId: TurnId.make("turn-1"),
}),
],
});

const feed = buildThreadFeed(thread);
expect(feed).toMatchObject([
{
type: "activity-group",
activities: [{ id: "activity-signal" }],
},
]);
});

it("collapses matching tool lifecycle rows like desktop", () => {
const thread = makeThread({
id: ThreadId.make("thread-2"),
Expand Down
12 changes: 12 additions & 0 deletions apps/mobile/src/lib/threadActivity.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -354,13 +354,25 @@ function deriveWorkLogEntries(
continue;
}
if (activity.summary === "Checkpoint captured") continue;
if (isNoContentRuntimeWarning(activity)) continue;
if (isPlanBoundaryToolActivity(activity)) continue;
if (isAgentInternalActivity(activity)) continue;
entries.push(toDerivedWorkLogEntry(activity));
}
return collapseDerivedWorkLogEntries(entries);
}

/** Adapters forward unknown wire-only SDK messages (background_tasks_changed,
* commands_changed, ...) as runtime warnings. The suffix comes from
* describeUnknownSdkMessage in the Claude adapter; a row with no displayable
* text carries nothing a user can act on, so it does not render. */
function isNoContentRuntimeWarning(activity: OrchestrationThreadActivity): boolean {
return (
activity.kind === "runtime.warning" &&
activity.summary.endsWith("(no displayable text content)")
);
}

function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean {
if (activity.kind !== "tool.updated" && activity.kind !== "tool.completed") {
return false;
Expand Down
103 changes: 102 additions & 1 deletion apps/web/src/components/chat/MessagesTimeline.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -989,6 +989,53 @@ describe("MessagesTimeline", () => {
expect(markup).not.toContain('aria-label="Tool call failed"');
});

it("keeps the collapsed summary icon neutral when the group ends in a failure", () => {
const markup = renderToStaticMarkup(
<MessagesTimeline
{...buildProps()}
timelineEntries={[
{
id: "entry-completed",
kind: "work",
createdAt: "2026-03-17T19:12:28.000Z",
entry: {
id: "work-completed",
createdAt: "2026-03-17T19:12:28.000Z",
label: "Run tests",
tone: "tool",
itemType: "command_execution",
toolLifecycleStatus: "completed",
},
},
{
id: "entry-failed",
kind: "work",
createdAt: "2026-03-17T19:12:29.000Z",
entry: {
id: "work-failed",
createdAt: "2026-03-17T19:12:29.000Z",
label: "Run lint",
tone: "tool",
itemType: "command_execution",
toolLifecycleStatus: "failed",
},
},
]}
/>,
);

expect(markup).toContain("Ran 2 commands");
// Neutral: the group keeps its command glyph instead of swapping to a
// failure glyph, and neither the heading nor the icon wrapper goes red once
// the group ends in an ordinary command failure. Pylon renders the command
// summary as a DotMatrix, so assert its state rather than a lucide class.
expect(markup).toContain('data-state="terminal"');
expect(markup).not.toMatch(/font-medium text-destructive/);
expect(markup).not.toMatch(/size-6 shrink-0 items-center justify-center text-destructive/);
// The failure stays discoverable for screen readers.
expect(markup).toContain("tool call failed");
});

it("keeps mixed work logs neutral after a later tool call succeeds", () => {
const markup = renderToStaticMarkup(
<MessagesTimeline
Expand DownExpand Up@@ -1402,11 +1449,22 @@ describe("MessagesTimeline", () => {
expect(markup).not.toContain('data-testid="file-diff"');
});

it("renders a failure marker for failed tool lifecycle entries", () => {
it("renders a muted failure marker for failed tool lifecycle entries", () => {
const markup = renderToStaticMarkup(
<MessagesTimeline
{...buildProps()}
timelineEntries={[
{
id: "entry-info",
kind: "work",
createdAt: "2026-03-17T19:12:27.000Z",
entry: {
id: "work-info",
createdAt: "2026-03-17T19:12:27.000Z",
label: "Status updated",
tone: "info",
},
},
{
id: "entry-1",
kind: "work",
Expand All@@ -1426,5 +1484,48 @@ describe("MessagesTimeline", () => {

expect(markup).toContain('data-state="error"');
expect(markup).toContain('aria-label="Tool call failed"');
// Ordinary tool failures render muted, not red. Pylon's marker is a small
// DotMatrix status dot that is itself destructive-coloured, so assert on the
// row treatment rather than on the absence of the class anywhere in the tree.
expect(markup).not.toMatch(/font-medium text-destructive/);
expect(markup).not.toMatch(/size-6 shrink-0 items-center justify-center text-destructive/);
});

it("keeps the red treatment for severe orchestration failures", () => {
const markup = renderToStaticMarkup(
<MessagesTimeline
{...buildProps()}
timelineEntries={[
{
id: "entry-info",
kind: "work",
createdAt: "2026-03-17T19:12:27.000Z",
entry: {
id: "work-info",
createdAt: "2026-03-17T19:12:27.000Z",
label: "Status updated",
tone: "info",
},
},
{
id: "entry-turn-failed",
kind: "work",
createdAt: "2026-03-17T19:12:28.000Z",
entry: {
id: "work-turn-failed",
createdAt: "2026-03-17T19:12:28.000Z",
label: "Provider turn start failed",
tone: "error",
sourceActivityKind: "provider.turn.start.failed",
},
},
]}
/>,
);

// Pylon renders severe failures with circle-alert where upstream uses an x.
expect(markup).toContain("lucide-circle-alert");
expect(markup).toMatch(/font-medium text-destructive/);
expect(markup).toMatch(/size-6 shrink-0 items-center justify-center text-destructive/);
});
});
52 changes: 23 additions & 29 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,6 +38,7 @@ import {
workEntryIndicatesToolSuccess,
workLogEntryIsMissingResponse,
workEntryDisplayIndicatesToolFailure,
workEntrySignalsSevereFailure,
workLogEntryIsToolLike,
} from "../../session-logic";
import { type TurnDiffSummary } from "../../types";
Expand DownExpand Up@@ -1647,33 +1648,25 @@ function WorkGroupToggleTimelineRow({
: row.hiddenCount === 1
? "log entry"
: "log entries";
const showHiddenFailure = row.hasFailure && !row.expanded;

return (
<button
type="button"
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"
aria-label={
row.hasFailure && !row.expanded
? `+${row.hiddenCount} previous ${labelNoun}, includes a failure`
: undefined
}
aria-expanded={row.expanded}
onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
>
<span
className={cn(
"flex size-6 shrink-0 items-center justify-center",
showHiddenFailure ? "text-destructive" : "text-icon-muted",
)}
role={showHiddenFailure ? "img" : undefined}
aria-label={showHiddenFailure ? "Hidden work includes a failure" : undefined}
>
{showHiddenFailure ? (
<WorkEntryIconSvg name="x" className="shrink-0 stroke-[1.8] opacity-70" />
) : (
<ChevronDownIcon
className={cn(
"size-4 shrink-0 opacity-70 transition-transform duration-200",
row.expanded && "rotate-180",
)}
/>
)}
<span className="flex size-6 shrink-0 items-center justify-center text-icon-muted">
<ChevronDownIcon
className={cn(
"size-4 shrink-0 opacity-70 transition-transform duration-200",
row.expanded && "rotate-180",
)}
/>
</span>
{row.expanded ? (
<span className="font-medium text-foreground">
Expand DownExpand Up@@ -2220,7 +2213,6 @@ type WorkEntryIconName =
| "square-pen"
| "terminal"
| "wrench"
| "x"
| "zap";

function WorkEntryIconSvg({ name, className }: { name: WorkEntryIconName; className: string }) {
Expand DownExpand Up@@ -2258,8 +2250,6 @@ function WorkEntryIconSvg({ name, className }: { name: WorkEntryIconName; classN
);
case "wrench":
return <WrenchIcon className={fixedIconClassName} aria-hidden />;
case "x":
return <DotMatrix sizeRole="inline" aria-hidden state="error" className={className} />;
case "zap":
return <ZapIcon className={fixedIconClassName} aria-hidden />;
}
Expand DownExpand Up@@ -2730,14 +2720,18 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
(turnSettled && workEntryIndicatesToolNeutralStatus(workEntry));
const showDestructiveRowStyle =
showFailedIndicator &&
(workEntry.sourceActivityKind === "runtime.error" || !workLogEntryIsToolLike(workEntry));
(workEntrySignalsSevereFailure(workEntry) || !workLogEntryIsToolLike(workEntry));
// Ordinary tool failures stay muted; only runtime errors and warnings get
// color. The red treatment is reserved for severe failures.
const iconWrapperClass = cn(
"flex size-6 shrink-0 items-center justify-center",
showWarningIndicator || showDestructiveRowStyle
? "text-destructive"
: workLogEntryIsToolLike(workEntry)
? "text-icon-muted"
: iconConfig.className,
showWarningIndicator
? "text-warning"
: showDestructiveRowStyle
? "text-destructive"
: workLogEntryIsToolLike(workEntry)
? "text-icon-muted"
: iconConfig.className,
);
const headingClass = showWarningIndicator
? "font-medium text-warning"
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
17 changes: 14 additions & 3 deletions apps/mobile/src/features/threads/thread-work-log.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -154,7 +154,11 @@ export function ThreadWorkLog(props: {
const canExpand = row.canExpand;
const fullDetail = expanded ? row.getFullDetail() : null;
const displayText = row.detail ? `${row.summary} ${row.detail}` : row.summary;
const iconIsDestructive = row.icon === "alert" || row.icon === "warning";
// Warnings are not errors. Web reserves destructive red for
// runtime.error and orchestration *.failed rows and paints warnings
// amber; mobile matches that split rather than colouring both rose.
const iconIsDestructive = row.icon === "alert";
const iconIsWarning = row.icon === "warning";

return (
<Animated.View
Expand DownExpand Up@@ -189,7 +193,13 @@ export function ThreadWorkLog(props: {
name={workRowSymbolName(row.icon)}
size={13}
weight="medium"
tintColor={iconIsDestructive ? "#e11d48" : props.iconSubtleColor}
tintColor={
iconIsDestructive
? "#e11d48"
: iconIsWarning
? "#d97706"
: props.iconSubtleColor
}
type="monochrome"
/>
</View>
Expand All@@ -199,6 +209,7 @@ export function ThreadWorkLog(props: {
className={cn(
"font-t3-medium text-foreground",
iconIsDestructive && "text-rose-600 dark:text-rose-400",
iconIsWarning && "text-amber-600 dark:text-amber-400",
)}
>
{row.summary}
Expand DownExpand Up@@ -239,7 +250,7 @@ export function ThreadWorkLog(props: {
: { ios: "minus", android: "remove" }
}
size={11}
tintColor={row.status === "failure" ? "#e11d48" : props.iconSubtleColor}
tintColor={props.iconSubtleColor}
type="monochrome"
/>
) : null}
Expand Down
32 changes: 32 additions & 0 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -434,6 +434,38 @@ describe("buildThreadFeed", () => {
]);
});

it("drops runtime warnings with no displayable content", () => {
const thread = makeThread({
id: ThreadId.make("thread-noise"),
projectId: ProjectId.make("project-1"),
title: "Warning noise thread",
activities: [
makeActivity({
id: EventId.make("activity-noise"),
kind: "runtime.warning",
summary: "Claude system message 'background_tasks_changed' (no displayable text content)",
createdAt: "2026-04-01T00:00:02.000Z",
turnId: TurnId.make("turn-1"),
}),
makeActivity({
id: EventId.make("activity-signal"),
kind: "runtime.warning",
summary: "Reconnecting... 2/5",
createdAt: "2026-04-01T00:00:03.000Z",
turnId: TurnId.make("turn-1"),
}),
],
});

const feed = buildThreadFeed(thread);
expect(feed).toMatchObject([
{
type: "activity-group",
activities: [{ id: "activity-signal" }],
},
]);
});

it("collapses matching tool lifecycle rows like desktop", () => {
const thread = makeThread({
id: ThreadId.make("thread-2"),
Expand Down
12 changes: 12 additions & 0 deletions apps/mobile/src/lib/threadActivity.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -354,13 +354,25 @@ function deriveWorkLogEntries(
continue;
}
if (activity.summary === "Checkpoint captured") continue;
if (isNoContentRuntimeWarning(activity)) continue;
if (isPlanBoundaryToolActivity(activity)) continue;
if (isAgentInternalActivity(activity)) continue;
entries.push(toDerivedWorkLogEntry(activity));
}
return collapseDerivedWorkLogEntries(entries);
}

/** Adapters forward unknown wire-only SDK messages (background_tasks_changed,
* commands_changed, ...) as runtime warnings. The suffix comes from
* describeUnknownSdkMessage in the Claude adapter; a row with no displayable
* text carries nothing a user can act on, so it does not render. */
function isNoContentRuntimeWarning(activity: OrchestrationThreadActivity): boolean {
return (
activity.kind === "runtime.warning" &&
activity.summary.endsWith("(no displayable text content)")
);
}

function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean {
if (activity.kind !== "tool.updated" && activity.kind !== "tool.completed") {
return false;
Expand Down
103 changes: 102 additions & 1 deletion apps/web/src/components/chat/MessagesTimeline.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -989,6 +989,53 @@ describe("MessagesTimeline", () => {
expect(markup).not.toContain('aria-label="Tool call failed"');
});

it("keeps the collapsed summary icon neutral when the group ends in a failure", () => {
const markup = renderToStaticMarkup(
<MessagesTimeline
{...buildProps()}
timelineEntries={[
{
id: "entry-completed",
kind: "work",
createdAt: "2026-03-17T19:12:28.000Z",
entry: {
id: "work-completed",
createdAt: "2026-03-17T19:12:28.000Z",
label: "Run tests",
tone: "tool",
itemType: "command_execution",
toolLifecycleStatus: "completed",
},
},
{
id: "entry-failed",
kind: "work",
createdAt: "2026-03-17T19:12:29.000Z",
entry: {
id: "work-failed",
createdAt: "2026-03-17T19:12:29.000Z",
label: "Run lint",
tone: "tool",
itemType: "command_execution",
toolLifecycleStatus: "failed",
},
},
]}
/>,
);

expect(markup).toContain("Ran 2 commands");
// Neutral: the group keeps its command glyph instead of swapping to a
// failure glyph, and neither the heading nor the icon wrapper goes red once
// the group ends in an ordinary command failure. Pylon renders the command
// summary as a DotMatrix, so assert its state rather than a lucide class.
expect(markup).toContain('data-state="terminal"');
expect(markup).not.toMatch(/font-medium text-destructive/);
expect(markup).not.toMatch(/size-6 shrink-0 items-center justify-center text-destructive/);
// The failure stays discoverable for screen readers.
expect(markup).toContain("tool call failed");
});

it("keeps mixed work logs neutral after a later tool call succeeds", () => {
const markup = renderToStaticMarkup(
<MessagesTimeline
Expand DownExpand Up@@ -1402,11 +1449,22 @@ describe("MessagesTimeline", () => {
expect(markup).not.toContain('data-testid="file-diff"');
});

it("renders a failure marker for failed tool lifecycle entries", () => {
it("renders a muted failure marker for failed tool lifecycle entries", () => {
const markup = renderToStaticMarkup(
<MessagesTimeline
{...buildProps()}
timelineEntries={[
{
id: "entry-info",
kind: "work",
createdAt: "2026-03-17T19:12:27.000Z",
entry: {
id: "work-info",
createdAt: "2026-03-17T19:12:27.000Z",
label: "Status updated",
tone: "info",
},
},
{
id: "entry-1",
kind: "work",
Expand All@@ -1426,5 +1484,48 @@ describe("MessagesTimeline", () => {

expect(markup).toContain('data-state="error"');
expect(markup).toContain('aria-label="Tool call failed"');
// Ordinary tool failures render muted, not red. Pylon's marker is a small
// DotMatrix status dot that is itself destructive-coloured, so assert on the
// row treatment rather than on the absence of the class anywhere in the tree.
expect(markup).not.toMatch(/font-medium text-destructive/);
expect(markup).not.toMatch(/size-6 shrink-0 items-center justify-center text-destructive/);
});

it("keeps the red treatment for severe orchestration failures", () => {
const markup = renderToStaticMarkup(
<MessagesTimeline
{...buildProps()}
timelineEntries={[
{
id: "entry-info",
kind: "work",
createdAt: "2026-03-17T19:12:27.000Z",
entry: {
id: "work-info",
createdAt: "2026-03-17T19:12:27.000Z",
label: "Status updated",
tone: "info",
},
},
{
id: "entry-turn-failed",
kind: "work",
createdAt: "2026-03-17T19:12:28.000Z",
entry: {
id: "work-turn-failed",
createdAt: "2026-03-17T19:12:28.000Z",
label: "Provider turn start failed",
tone: "error",
sourceActivityKind: "provider.turn.start.failed",
},
},
]}
/>,
);

// Pylon renders severe failures with circle-alert where upstream uses an x.
expect(markup).toContain("lucide-circle-alert");
expect(markup).toMatch(/font-medium text-destructive/);
expect(markup).toMatch(/size-6 shrink-0 items-center justify-center text-destructive/);
});
});
52 changes: 23 additions & 29 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,6 +38,7 @@ import {
workEntryIndicatesToolSuccess,
workLogEntryIsMissingResponse,
workEntryDisplayIndicatesToolFailure,
workEntrySignalsSevereFailure,
workLogEntryIsToolLike,
} from "../../session-logic";
import { type TurnDiffSummary } from "../../types";
Expand DownExpand Up@@ -1647,33 +1648,25 @@ function WorkGroupToggleTimelineRow({
: row.hiddenCount === 1
? "log entry"
: "log entries";
const showHiddenFailure = row.hasFailure && !row.expanded;

return (
<button
type="button"
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"
aria-label={
row.hasFailure && !row.expanded
? `+${row.hiddenCount} previous ${labelNoun}, includes a failure`
: undefined
}
aria-expanded={row.expanded}
onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
>
<span
className={cn(
"flex size-6 shrink-0 items-center justify-center",
showHiddenFailure ? "text-destructive" : "text-icon-muted",
)}
role={showHiddenFailure ? "img" : undefined}
aria-label={showHiddenFailure ? "Hidden work includes a failure" : undefined}
>
{showHiddenFailure ? (
<WorkEntryIconSvg name="x" className="shrink-0 stroke-[1.8] opacity-70" />
) : (
<ChevronDownIcon
className={cn(
"size-4 shrink-0 opacity-70 transition-transform duration-200",
row.expanded && "rotate-180",
)}
/>
)}
<span className="flex size-6 shrink-0 items-center justify-center text-icon-muted">
<ChevronDownIcon
className={cn(
"size-4 shrink-0 opacity-70 transition-transform duration-200",
row.expanded && "rotate-180",
)}
/>
</span>
{row.expanded ? (
<span className="font-medium text-foreground">
Expand DownExpand Up@@ -2220,7 +2213,6 @@ type WorkEntryIconName =
| "square-pen"
| "terminal"
| "wrench"
| "x"
| "zap";

function WorkEntryIconSvg({ name, className }: { name: WorkEntryIconName; className: string }) {
Expand DownExpand Up@@ -2258,8 +2250,6 @@ function WorkEntryIconSvg({ name, className }: { name: WorkEntryIconName; classN
);
case "wrench":
return <WrenchIcon className={fixedIconClassName} aria-hidden />;
case "x":
return <DotMatrix sizeRole="inline" aria-hidden state="error" className={className} />;
case "zap":
return <ZapIcon className={fixedIconClassName} aria-hidden />;
}
Expand DownExpand Up@@ -2730,14 +2720,18 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
(turnSettled && workEntryIndicatesToolNeutralStatus(workEntry));
const showDestructiveRowStyle =
showFailedIndicator &&
(workEntry.sourceActivityKind === "runtime.error" || !workLogEntryIsToolLike(workEntry));
(workEntrySignalsSevereFailure(workEntry) || !workLogEntryIsToolLike(workEntry));
// Ordinary tool failures stay muted; only runtime errors and warnings get
// color. The red treatment is reserved for severe failures.
const iconWrapperClass = cn(
"flex size-6 shrink-0 items-center justify-center",
showWarningIndicator || showDestructiveRowStyle
? "text-destructive"
: workLogEntryIsToolLike(workEntry)
? "text-icon-muted"
: iconConfig.className,
showWarningIndicator
? "text-warning"
: showDestructiveRowStyle
? "text-destructive"
: workLogEntryIsToolLike(workEntry)
? "text-icon-muted"
: iconConfig.className,
);
const headingClass = showWarningIndicator
? "font-medium text-warning"
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
17 changes: 14 additions & 3 deletions apps/mobile/src/features/threads/thread-work-log.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -154,7 +154,11 @@ export function ThreadWorkLog(props: {
const canExpand = row.canExpand;
const fullDetail = expanded ? row.getFullDetail() : null;
const displayText = row.detail ? `${row.summary} ${row.detail}` : row.summary;
const iconIsDestructive = row.icon === "alert" || row.icon === "warning";
// Warnings are not errors. Web reserves destructive red for
// runtime.error and orchestration *.failed rows and paints warnings
// amber; mobile matches that split rather than colouring both rose.
const iconIsDestructive = row.icon === "alert";
const iconIsWarning = row.icon === "warning";

return (
<Animated.View
Expand DownExpand Up@@ -189,7 +193,13 @@ export function ThreadWorkLog(props: {
name={workRowSymbolName(row.icon)}
size={13}
weight="medium"
tintColor={iconIsDestructive ? "#e11d48" : props.iconSubtleColor}
tintColor={
iconIsDestructive
? "#e11d48"
: iconIsWarning
? "#d97706"
: props.iconSubtleColor
}
type="monochrome"
/>
</View>
Expand All@@ -199,6 +209,7 @@ export function ThreadWorkLog(props: {
className={cn(
"font-t3-medium text-foreground",
iconIsDestructive && "text-rose-600 dark:text-rose-400",
iconIsWarning && "text-amber-600 dark:text-amber-400",
)}
>
{row.summary}
Expand DownExpand Up@@ -239,7 +250,7 @@ export function ThreadWorkLog(props: {
: { ios: "minus", android: "remove" }
}
size={11}
tintColor={row.status === "failure" ? "#e11d48" : props.iconSubtleColor}
tintColor={props.iconSubtleColor}
type="monochrome"
/>
) : null}
Expand Down
32 changes: 32 additions & 0 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -434,6 +434,38 @@ describe("buildThreadFeed", () => {
]);
});

it("drops runtime warnings with no displayable content", () => {
const thread = makeThread({
id: ThreadId.make("thread-noise"),
projectId: ProjectId.make("project-1"),
title: "Warning noise thread",
activities: [
makeActivity({
id: EventId.make("activity-noise"),
kind: "runtime.warning",
summary: "Claude system message 'background_tasks_changed' (no displayable text content)",
createdAt: "2026-04-01T00:00:02.000Z",
turnId: TurnId.make("turn-1"),
}),
makeActivity({
id: EventId.make("activity-signal"),
kind: "runtime.warning",
summary: "Reconnecting... 2/5",
createdAt: "2026-04-01T00:00:03.000Z",
turnId: TurnId.make("turn-1"),
}),
],
});

const feed = buildThreadFeed(thread);
expect(feed).toMatchObject([
{
type: "activity-group",
activities: [{ id: "activity-signal" }],
},
]);
});

it("collapses matching tool lifecycle rows like desktop", () => {
const thread = makeThread({
id: ThreadId.make("thread-2"),
Expand Down
12 changes: 12 additions & 0 deletions apps/mobile/src/lib/threadActivity.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -354,13 +354,25 @@ function deriveWorkLogEntries(
continue;
}
if (activity.summary === "Checkpoint captured") continue;
if (isNoContentRuntimeWarning(activity)) continue;
if (isPlanBoundaryToolActivity(activity)) continue;
if (isAgentInternalActivity(activity)) continue;
entries.push(toDerivedWorkLogEntry(activity));
}
return collapseDerivedWorkLogEntries(entries);
}

/** Adapters forward unknown wire-only SDK messages (background_tasks_changed,
* commands_changed, ...) as runtime warnings. The suffix comes from
* describeUnknownSdkMessage in the Claude adapter; a row with no displayable
* text carries nothing a user can act on, so it does not render. */
function isNoContentRuntimeWarning(activity: OrchestrationThreadActivity): boolean {
return (
activity.kind === "runtime.warning" &&
activity.summary.endsWith("(no displayable text content)")
);
}

function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean {
if (activity.kind !== "tool.updated" && activity.kind !== "tool.completed") {
return false;
Expand Down
103 changes: 102 additions & 1 deletion apps/web/src/components/chat/MessagesTimeline.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -989,6 +989,53 @@ describe("MessagesTimeline", () => {
expect(markup).not.toContain('aria-label="Tool call failed"');
});

it("keeps the collapsed summary icon neutral when the group ends in a failure", () => {
const markup = renderToStaticMarkup(
<MessagesTimeline
{...buildProps()}
timelineEntries={[
{
id: "entry-completed",
kind: "work",
createdAt: "2026-03-17T19:12:28.000Z",
entry: {
id: "work-completed",
createdAt: "2026-03-17T19:12:28.000Z",
label: "Run tests",
tone: "tool",
itemType: "command_execution",
toolLifecycleStatus: "completed",
},
},
{
id: "entry-failed",
kind: "work",
createdAt: "2026-03-17T19:12:29.000Z",
entry: {
id: "work-failed",
createdAt: "2026-03-17T19:12:29.000Z",
label: "Run lint",
tone: "tool",
itemType: "command_execution",
toolLifecycleStatus: "failed",
},
},
]}
/>,
);

expect(markup).toContain("Ran 2 commands");
// Neutral: the group keeps its command glyph instead of swapping to a
// failure glyph, and neither the heading nor the icon wrapper goes red once
// the group ends in an ordinary command failure. Pylon renders the command
// summary as a DotMatrix, so assert its state rather than a lucide class.
expect(markup).toContain('data-state="terminal"');
expect(markup).not.toMatch(/font-medium text-destructive/);
expect(markup).not.toMatch(/size-6 shrink-0 items-center justify-center text-destructive/);
// The failure stays discoverable for screen readers.
expect(markup).toContain("tool call failed");
});

it("keeps mixed work logs neutral after a later tool call succeeds", () => {
const markup = renderToStaticMarkup(
<MessagesTimeline
Expand DownExpand Up@@ -1402,11 +1449,22 @@ describe("MessagesTimeline", () => {
expect(markup).not.toContain('data-testid="file-diff"');
});

it("renders a failure marker for failed tool lifecycle entries", () => {
it("renders a muted failure marker for failed tool lifecycle entries", () => {
const markup = renderToStaticMarkup(
<MessagesTimeline
{...buildProps()}
timelineEntries={[
{
id: "entry-info",
kind: "work",
createdAt: "2026-03-17T19:12:27.000Z",
entry: {
id: "work-info",
createdAt: "2026-03-17T19:12:27.000Z",
label: "Status updated",
tone: "info",
},
},
{
id: "entry-1",
kind: "work",
Expand All@@ -1426,5 +1484,48 @@ describe("MessagesTimeline", () => {

expect(markup).toContain('data-state="error"');
expect(markup).toContain('aria-label="Tool call failed"');
// Ordinary tool failures render muted, not red. Pylon's marker is a small
// DotMatrix status dot that is itself destructive-coloured, so assert on the
// row treatment rather than on the absence of the class anywhere in the tree.
expect(markup).not.toMatch(/font-medium text-destructive/);
expect(markup).not.toMatch(/size-6 shrink-0 items-center justify-center text-destructive/);
});

it("keeps the red treatment for severe orchestration failures", () => {
const markup = renderToStaticMarkup(
<MessagesTimeline
{...buildProps()}
timelineEntries={[
{
id: "entry-info",
kind: "work",
createdAt: "2026-03-17T19:12:27.000Z",
entry: {
id: "work-info",
createdAt: "2026-03-17T19:12:27.000Z",
label: "Status updated",
tone: "info",
},
},
{
id: "entry-turn-failed",
kind: "work",
createdAt: "2026-03-17T19:12:28.000Z",
entry: {
id: "work-turn-failed",
createdAt: "2026-03-17T19:12:28.000Z",
label: "Provider turn start failed",
tone: "error",
sourceActivityKind: "provider.turn.start.failed",
},
},
]}
/>,
);

// Pylon renders severe failures with circle-alert where upstream uses an x.
expect(markup).toContain("lucide-circle-alert");
expect(markup).toMatch(/font-medium text-destructive/);
expect(markup).toMatch(/size-6 shrink-0 items-center justify-center text-destructive/);
});
});
52 changes: 23 additions & 29 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,6 +38,7 @@ import {
workEntryIndicatesToolSuccess,
workLogEntryIsMissingResponse,
workEntryDisplayIndicatesToolFailure,
workEntrySignalsSevereFailure,
workLogEntryIsToolLike,
} from "../../session-logic";
import { type TurnDiffSummary } from "../../types";
Expand DownExpand Up@@ -1647,33 +1648,25 @@ function WorkGroupToggleTimelineRow({
: row.hiddenCount === 1
? "log entry"
: "log entries";
const showHiddenFailure = row.hasFailure && !row.expanded;

return (
<button
type="button"
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"
aria-label={
row.hasFailure && !row.expanded
? `+${row.hiddenCount} previous ${labelNoun}, includes a failure`
: undefined
}
aria-expanded={row.expanded}
onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
>
<span
className={cn(
"flex size-6 shrink-0 items-center justify-center",
showHiddenFailure ? "text-destructive" : "text-icon-muted",
)}
role={showHiddenFailure ? "img" : undefined}
aria-label={showHiddenFailure ? "Hidden work includes a failure" : undefined}
>
{showHiddenFailure ? (
<WorkEntryIconSvg name="x" className="shrink-0 stroke-[1.8] opacity-70" />
) : (
<ChevronDownIcon
className={cn(
"size-4 shrink-0 opacity-70 transition-transform duration-200",
row.expanded && "rotate-180",
)}
/>
)}
<span className="flex size-6 shrink-0 items-center justify-center text-icon-muted">
<ChevronDownIcon
className={cn(
"size-4 shrink-0 opacity-70 transition-transform duration-200",
row.expanded && "rotate-180",
)}
/>
</span>
{row.expanded ? (
<span className="font-medium text-foreground">
Expand DownExpand Up@@ -2220,7 +2213,6 @@ type WorkEntryIconName =
| "square-pen"
| "terminal"
| "wrench"
| "x"
| "zap";

function WorkEntryIconSvg({ name, className }: { name: WorkEntryIconName; className: string }) {
Expand DownExpand Up@@ -2258,8 +2250,6 @@ function WorkEntryIconSvg({ name, className }: { name: WorkEntryIconName; classN
);
case "wrench":
return <WrenchIcon className={fixedIconClassName} aria-hidden />;
case "x":
return <DotMatrix sizeRole="inline" aria-hidden state="error" className={className} />;
case "zap":
return <ZapIcon className={fixedIconClassName} aria-hidden />;
}
Expand DownExpand Up@@ -2730,14 +2720,18 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
(turnSettled && workEntryIndicatesToolNeutralStatus(workEntry));
const showDestructiveRowStyle =
showFailedIndicator &&
(workEntry.sourceActivityKind === "runtime.error" || !workLogEntryIsToolLike(workEntry));
(workEntrySignalsSevereFailure(workEntry) || !workLogEntryIsToolLike(workEntry));
// Ordinary tool failures stay muted; only runtime errors and warnings get
// color. The red treatment is reserved for severe failures.
const iconWrapperClass = cn(
"flex size-6 shrink-0 items-center justify-center",
showWarningIndicator || showDestructiveRowStyle
? "text-destructive"
: workLogEntryIsToolLike(workEntry)
? "text-icon-muted"
: iconConfig.className,
showWarningIndicator
? "text-warning"
: showDestructiveRowStyle
? "text-destructive"
: workLogEntryIsToolLike(workEntry)
? "text-icon-muted"
: iconConfig.className,
);
const headingClass = showWarningIndicator
? "font-medium text-warning"
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
17 changes: 14 additions & 3 deletions apps/mobile/src/features/threads/thread-work-log.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -154,7 +154,11 @@ export function ThreadWorkLog(props: {
const canExpand = row.canExpand;
const fullDetail = expanded ? row.getFullDetail() : null;
const displayText = row.detail ? `${row.summary} ${row.detail}` : row.summary;
const iconIsDestructive = row.icon === "alert" || row.icon === "warning";
// Warnings are not errors. Web reserves destructive red for
// runtime.error and orchestration *.failed rows and paints warnings
// amber; mobile matches that split rather than colouring both rose.
const iconIsDestructive = row.icon === "alert";
const iconIsWarning = row.icon === "warning";

return (
<Animated.View
Expand DownExpand Up@@ -189,7 +193,13 @@ export function ThreadWorkLog(props: {
name={workRowSymbolName(row.icon)}
size={13}
weight="medium"
tintColor={iconIsDestructive ? "#e11d48" : props.iconSubtleColor}
tintColor={
iconIsDestructive
? "#e11d48"
: iconIsWarning
? "#d97706"
: props.iconSubtleColor
}
type="monochrome"
/>
</View>
Expand All@@ -199,6 +209,7 @@ export function ThreadWorkLog(props: {
className={cn(
"font-t3-medium text-foreground",
iconIsDestructive && "text-rose-600 dark:text-rose-400",
iconIsWarning && "text-amber-600 dark:text-amber-400",
)}
>
{row.summary}
Expand DownExpand Up@@ -239,7 +250,7 @@ export function ThreadWorkLog(props: {
: { ios: "minus", android: "remove" }
}
size={11}
tintColor={row.status === "failure" ? "#e11d48" : props.iconSubtleColor}
tintColor={props.iconSubtleColor}
type="monochrome"
/>
) : null}
Expand Down
32 changes: 32 additions & 0 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -434,6 +434,38 @@ describe("buildThreadFeed", () => {
]);
});

it("drops runtime warnings with no displayable content", () => {
const thread = makeThread({
id: ThreadId.make("thread-noise"),
projectId: ProjectId.make("project-1"),
title: "Warning noise thread",
activities: [
makeActivity({
id: EventId.make("activity-noise"),
kind: "runtime.warning",
summary: "Claude system message 'background_tasks_changed' (no displayable text content)",
createdAt: "2026-04-01T00:00:02.000Z",
turnId: TurnId.make("turn-1"),
}),
makeActivity({
id: EventId.make("activity-signal"),
kind: "runtime.warning",
summary: "Reconnecting... 2/5",
createdAt: "2026-04-01T00:00:03.000Z",
turnId: TurnId.make("turn-1"),
}),
],
});

const feed = buildThreadFeed(thread);
expect(feed).toMatchObject([
{
type: "activity-group",
activities: [{ id: "activity-signal" }],
},
]);
});

it("collapses matching tool lifecycle rows like desktop", () => {
const thread = makeThread({
id: ThreadId.make("thread-2"),
Expand Down
12 changes: 12 additions & 0 deletions apps/mobile/src/lib/threadActivity.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -354,13 +354,25 @@ function deriveWorkLogEntries(
continue;
}
if (activity.summary === "Checkpoint captured") continue;
if (isNoContentRuntimeWarning(activity)) continue;
if (isPlanBoundaryToolActivity(activity)) continue;
if (isAgentInternalActivity(activity)) continue;
entries.push(toDerivedWorkLogEntry(activity));
}
return collapseDerivedWorkLogEntries(entries);
}

/** Adapters forward unknown wire-only SDK messages (background_tasks_changed,
* commands_changed, ...) as runtime warnings. The suffix comes from
* describeUnknownSdkMessage in the Claude adapter; a row with no displayable
* text carries nothing a user can act on, so it does not render. */
function isNoContentRuntimeWarning(activity: OrchestrationThreadActivity): boolean {
return (
activity.kind === "runtime.warning" &&
activity.summary.endsWith("(no displayable text content)")
);
}

function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean {
if (activity.kind !== "tool.updated" && activity.kind !== "tool.completed") {
return false;
Expand Down
103 changes: 102 additions & 1 deletion apps/web/src/components/chat/MessagesTimeline.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -989,6 +989,53 @@ describe("MessagesTimeline", () => {
expect(markup).not.toContain('aria-label="Tool call failed"');
});

it("keeps the collapsed summary icon neutral when the group ends in a failure", () => {
const markup = renderToStaticMarkup(
<MessagesTimeline
{...buildProps()}
timelineEntries={[
{
id: "entry-completed",
kind: "work",
createdAt: "2026-03-17T19:12:28.000Z",
entry: {
id: "work-completed",
createdAt: "2026-03-17T19:12:28.000Z",
label: "Run tests",
tone: "tool",
itemType: "command_execution",
toolLifecycleStatus: "completed",
},
},
{
id: "entry-failed",
kind: "work",
createdAt: "2026-03-17T19:12:29.000Z",
entry: {
id: "work-failed",
createdAt: "2026-03-17T19:12:29.000Z",
label: "Run lint",
tone: "tool",
itemType: "command_execution",
toolLifecycleStatus: "failed",
},
},
]}
/>,
);

expect(markup).toContain("Ran 2 commands");
// Neutral: the group keeps its command glyph instead of swapping to a
// failure glyph, and neither the heading nor the icon wrapper goes red once
// the group ends in an ordinary command failure. Pylon renders the command
// summary as a DotMatrix, so assert its state rather than a lucide class.
expect(markup).toContain('data-state="terminal"');
expect(markup).not.toMatch(/font-medium text-destructive/);
expect(markup).not.toMatch(/size-6 shrink-0 items-center justify-center text-destructive/);
// The failure stays discoverable for screen readers.
expect(markup).toContain("tool call failed");
});

it("keeps mixed work logs neutral after a later tool call succeeds", () => {
const markup = renderToStaticMarkup(
<MessagesTimeline
Expand DownExpand Up@@ -1402,11 +1449,22 @@ describe("MessagesTimeline", () => {
expect(markup).not.toContain('data-testid="file-diff"');
});

it("renders a failure marker for failed tool lifecycle entries", () => {
it("renders a muted failure marker for failed tool lifecycle entries", () => {
const markup = renderToStaticMarkup(
<MessagesTimeline
{...buildProps()}
timelineEntries={[
{
id: "entry-info",
kind: "work",
createdAt: "2026-03-17T19:12:27.000Z",
entry: {
id: "work-info",
createdAt: "2026-03-17T19:12:27.000Z",
label: "Status updated",
tone: "info",
},
},
{
id: "entry-1",
kind: "work",
Expand All@@ -1426,5 +1484,48 @@ describe("MessagesTimeline", () => {

expect(markup).toContain('data-state="error"');
expect(markup).toContain('aria-label="Tool call failed"');
// Ordinary tool failures render muted, not red. Pylon's marker is a small
// DotMatrix status dot that is itself destructive-coloured, so assert on the
// row treatment rather than on the absence of the class anywhere in the tree.
expect(markup).not.toMatch(/font-medium text-destructive/);
expect(markup).not.toMatch(/size-6 shrink-0 items-center justify-center text-destructive/);
});

it("keeps the red treatment for severe orchestration failures", () => {
const markup = renderToStaticMarkup(
<MessagesTimeline
{...buildProps()}
timelineEntries={[
{
id: "entry-info",
kind: "work",
createdAt: "2026-03-17T19:12:27.000Z",
entry: {
id: "work-info",
createdAt: "2026-03-17T19:12:27.000Z",
label: "Status updated",
tone: "info",
},
},
{
id: "entry-turn-failed",
kind: "work",
createdAt: "2026-03-17T19:12:28.000Z",
entry: {
id: "work-turn-failed",
createdAt: "2026-03-17T19:12:28.000Z",
label: "Provider turn start failed",
tone: "error",
sourceActivityKind: "provider.turn.start.failed",
},
},
]}
/>,
);

// Pylon renders severe failures with circle-alert where upstream uses an x.
expect(markup).toContain("lucide-circle-alert");
expect(markup).toMatch(/font-medium text-destructive/);
expect(markup).toMatch(/size-6 shrink-0 items-center justify-center text-destructive/);
});
});
52 changes: 23 additions & 29 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,6 +38,7 @@ import {
workEntryIndicatesToolSuccess,
workLogEntryIsMissingResponse,
workEntryDisplayIndicatesToolFailure,
workEntrySignalsSevereFailure,
workLogEntryIsToolLike,
} from "../../session-logic";
import { type TurnDiffSummary } from "../../types";
Expand DownExpand Up@@ -1647,33 +1648,25 @@ function WorkGroupToggleTimelineRow({
: row.hiddenCount === 1
? "log entry"
: "log entries";
const showHiddenFailure = row.hasFailure && !row.expanded;

return (
<button
type="button"
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"
aria-label={
row.hasFailure && !row.expanded
? `+${row.hiddenCount} previous ${labelNoun}, includes a failure`
: undefined
}
aria-expanded={row.expanded}
onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
>
<span
className={cn(
"flex size-6 shrink-0 items-center justify-center",
showHiddenFailure ? "text-destructive" : "text-icon-muted",
)}
role={showHiddenFailure ? "img" : undefined}
aria-label={showHiddenFailure ? "Hidden work includes a failure" : undefined}
>
{showHiddenFailure ? (
<WorkEntryIconSvg name="x" className="shrink-0 stroke-[1.8] opacity-70" />
) : (
<ChevronDownIcon
className={cn(
"size-4 shrink-0 opacity-70 transition-transform duration-200",
row.expanded && "rotate-180",
)}
/>
)}
<span className="flex size-6 shrink-0 items-center justify-center text-icon-muted">
<ChevronDownIcon
className={cn(
"size-4 shrink-0 opacity-70 transition-transform duration-200",
row.expanded && "rotate-180",
)}
/>
</span>
{row.expanded ? (
<span className="font-medium text-foreground">
Expand DownExpand Up@@ -2220,7 +2213,6 @@ type WorkEntryIconName =
| "square-pen"
| "terminal"
| "wrench"
| "x"
| "zap";

function WorkEntryIconSvg({ name, className }: { name: WorkEntryIconName; className: string }) {
Expand DownExpand Up@@ -2258,8 +2250,6 @@ function WorkEntryIconSvg({ name, className }: { name: WorkEntryIconName; classN
);
case "wrench":
return <WrenchIcon className={fixedIconClassName} aria-hidden />;
case "x":
return <DotMatrix sizeRole="inline" aria-hidden state="error" className={className} />;
case "zap":
return <ZapIcon className={fixedIconClassName} aria-hidden />;
}
Expand DownExpand Up@@ -2730,14 +2720,18 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
(turnSettled && workEntryIndicatesToolNeutralStatus(workEntry));
const showDestructiveRowStyle =
showFailedIndicator &&
(workEntry.sourceActivityKind === "runtime.error" || !workLogEntryIsToolLike(workEntry));
(workEntrySignalsSevereFailure(workEntry) || !workLogEntryIsToolLike(workEntry));
// Ordinary tool failures stay muted; only runtime errors and warnings get
// color. The red treatment is reserved for severe failures.
const iconWrapperClass = cn(
"flex size-6 shrink-0 items-center justify-center",
showWarningIndicator || showDestructiveRowStyle
? "text-destructive"
: workLogEntryIsToolLike(workEntry)
? "text-icon-muted"
: iconConfig.className,
showWarningIndicator
? "text-warning"
: showDestructiveRowStyle
? "text-destructive"
: workLogEntryIsToolLike(workEntry)
? "text-icon-muted"
: iconConfig.className,
);
const headingClass = showWarningIndicator
? "font-medium text-warning"
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
17 changes: 14 additions & 3 deletions apps/mobile/src/features/threads/thread-work-log.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -154,7 +154,11 @@ export function ThreadWorkLog(props: {
const canExpand = row.canExpand;
const fullDetail = expanded ? row.getFullDetail() : null;
const displayText = row.detail ? `${row.summary} ${row.detail}` : row.summary;
const iconIsDestructive = row.icon === "alert" || row.icon === "warning";
// Warnings are not errors. Web reserves destructive red for
// runtime.error and orchestration *.failed rows and paints warnings
// amber; mobile matches that split rather than colouring both rose.
const iconIsDestructive = row.icon === "alert";
const iconIsWarning = row.icon === "warning";

return (
<Animated.View
Expand DownExpand Up@@ -189,7 +193,13 @@ export function ThreadWorkLog(props: {
name={workRowSymbolName(row.icon)}
size={13}
weight="medium"
tintColor={iconIsDestructive ? "#e11d48" : props.iconSubtleColor}
tintColor={
iconIsDestructive
? "#e11d48"
: iconIsWarning
? "#d97706"
: props.iconSubtleColor
}
type="monochrome"
/>
</View>
Expand All@@ -199,6 +209,7 @@ export function ThreadWorkLog(props: {
className={cn(
"font-t3-medium text-foreground",
iconIsDestructive && "text-rose-600 dark:text-rose-400",
iconIsWarning && "text-amber-600 dark:text-amber-400",
)}
>
{row.summary}
Expand DownExpand Up@@ -239,7 +250,7 @@ export function ThreadWorkLog(props: {
: { ios: "minus", android: "remove" }
}
size={11}
tintColor={row.status === "failure" ? "#e11d48" : props.iconSubtleColor}
tintColor={props.iconSubtleColor}
type="monochrome"
/>
) : null}
Expand Down
32 changes: 32 additions & 0 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -434,6 +434,38 @@ describe("buildThreadFeed", () => {
]);
});

it("drops runtime warnings with no displayable content", () => {
const thread = makeThread({
id: ThreadId.make("thread-noise"),
projectId: ProjectId.make("project-1"),
title: "Warning noise thread",
activities: [
makeActivity({
id: EventId.make("activity-noise"),
kind: "runtime.warning",
summary: "Claude system message 'background_tasks_changed' (no displayable text content)",
createdAt: "2026-04-01T00:00:02.000Z",
turnId: TurnId.make("turn-1"),
}),
makeActivity({
id: EventId.make("activity-signal"),
kind: "runtime.warning",
summary: "Reconnecting... 2/5",
createdAt: "2026-04-01T00:00:03.000Z",
turnId: TurnId.make("turn-1"),
}),
],
});

const feed = buildThreadFeed(thread);
expect(feed).toMatchObject([
{
type: "activity-group",
activities: [{ id: "activity-signal" }],
},
]);
});

it("collapses matching tool lifecycle rows like desktop", () => {
const thread = makeThread({
id: ThreadId.make("thread-2"),
Expand Down
12 changes: 12 additions & 0 deletions apps/mobile/src/lib/threadActivity.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -354,13 +354,25 @@ function deriveWorkLogEntries(
continue;
}
if (activity.summary === "Checkpoint captured") continue;
if (isNoContentRuntimeWarning(activity)) continue;
if (isPlanBoundaryToolActivity(activity)) continue;
if (isAgentInternalActivity(activity)) continue;
entries.push(toDerivedWorkLogEntry(activity));
}
return collapseDerivedWorkLogEntries(entries);
}

/** Adapters forward unknown wire-only SDK messages (background_tasks_changed,
* commands_changed, ...) as runtime warnings. The suffix comes from
* describeUnknownSdkMessage in the Claude adapter; a row with no displayable
* text carries nothing a user can act on, so it does not render. */
function isNoContentRuntimeWarning(activity: OrchestrationThreadActivity): boolean {
return (
activity.kind === "runtime.warning" &&
activity.summary.endsWith("(no displayable text content)")
);
}

function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean {
if (activity.kind !== "tool.updated" && activity.kind !== "tool.completed") {
return false;
Expand Down
103 changes: 102 additions & 1 deletion apps/web/src/components/chat/MessagesTimeline.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -989,6 +989,53 @@ describe("MessagesTimeline", () => {
expect(markup).not.toContain('aria-label="Tool call failed"');
});

it("keeps the collapsed summary icon neutral when the group ends in a failure", () => {
const markup = renderToStaticMarkup(
<MessagesTimeline
{...buildProps()}
timelineEntries={[
{
id: "entry-completed",
kind: "work",
createdAt: "2026-03-17T19:12:28.000Z",
entry: {
id: "work-completed",
createdAt: "2026-03-17T19:12:28.000Z",
label: "Run tests",
tone: "tool",
itemType: "command_execution",
toolLifecycleStatus: "completed",
},
},
{
id: "entry-failed",
kind: "work",
createdAt: "2026-03-17T19:12:29.000Z",
entry: {
id: "work-failed",
createdAt: "2026-03-17T19:12:29.000Z",
label: "Run lint",
tone: "tool",
itemType: "command_execution",
toolLifecycleStatus: "failed",
},
},
]}
/>,
);

expect(markup).toContain("Ran 2 commands");
// Neutral: the group keeps its command glyph instead of swapping to a
// failure glyph, and neither the heading nor the icon wrapper goes red once
// the group ends in an ordinary command failure. Pylon renders the command
// summary as a DotMatrix, so assert its state rather than a lucide class.
expect(markup).toContain('data-state="terminal"');
expect(markup).not.toMatch(/font-medium text-destructive/);
expect(markup).not.toMatch(/size-6 shrink-0 items-center justify-center text-destructive/);
// The failure stays discoverable for screen readers.
expect(markup).toContain("tool call failed");
});

it("keeps mixed work logs neutral after a later tool call succeeds", () => {
const markup = renderToStaticMarkup(
<MessagesTimeline
Expand DownExpand Up@@ -1402,11 +1449,22 @@ describe("MessagesTimeline", () => {
expect(markup).not.toContain('data-testid="file-diff"');
});

it("renders a failure marker for failed tool lifecycle entries", () => {
it("renders a muted failure marker for failed tool lifecycle entries", () => {
const markup = renderToStaticMarkup(
<MessagesTimeline
{...buildProps()}
timelineEntries={[
{
id: "entry-info",
kind: "work",
createdAt: "2026-03-17T19:12:27.000Z",
entry: {
id: "work-info",
createdAt: "2026-03-17T19:12:27.000Z",
label: "Status updated",
tone: "info",
},
},
{
id: "entry-1",
kind: "work",
Expand All@@ -1426,5 +1484,48 @@ describe("MessagesTimeline", () => {

expect(markup).toContain('data-state="error"');
expect(markup).toContain('aria-label="Tool call failed"');
// Ordinary tool failures render muted, not red. Pylon's marker is a small
// DotMatrix status dot that is itself destructive-coloured, so assert on the
// row treatment rather than on the absence of the class anywhere in the tree.
expect(markup).not.toMatch(/font-medium text-destructive/);
expect(markup).not.toMatch(/size-6 shrink-0 items-center justify-center text-destructive/);
});

it("keeps the red treatment for severe orchestration failures", () => {
const markup = renderToStaticMarkup(
<MessagesTimeline
{...buildProps()}
timelineEntries={[
{
id: "entry-info",
kind: "work",
createdAt: "2026-03-17T19:12:27.000Z",
entry: {
id: "work-info",
createdAt: "2026-03-17T19:12:27.000Z",
label: "Status updated",
tone: "info",
},
},
{
id: "entry-turn-failed",
kind: "work",
createdAt: "2026-03-17T19:12:28.000Z",
entry: {
id: "work-turn-failed",
createdAt: "2026-03-17T19:12:28.000Z",
label: "Provider turn start failed",
tone: "error",
sourceActivityKind: "provider.turn.start.failed",
},
},
]}
/>,
);

// Pylon renders severe failures with circle-alert where upstream uses an x.
expect(markup).toContain("lucide-circle-alert");
expect(markup).toMatch(/font-medium text-destructive/);
expect(markup).toMatch(/size-6 shrink-0 items-center justify-center text-destructive/);
});
});
52 changes: 23 additions & 29 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,6 +38,7 @@ import {
workEntryIndicatesToolSuccess,
workLogEntryIsMissingResponse,
workEntryDisplayIndicatesToolFailure,
workEntrySignalsSevereFailure,
workLogEntryIsToolLike,
} from "../../session-logic";
import { type TurnDiffSummary } from "../../types";
Expand DownExpand Up@@ -1647,33 +1648,25 @@ function WorkGroupToggleTimelineRow({
: row.hiddenCount === 1
? "log entry"
: "log entries";
const showHiddenFailure = row.hasFailure && !row.expanded;

return (
<button
type="button"
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"
aria-label={
row.hasFailure && !row.expanded
? `+${row.hiddenCount} previous ${labelNoun}, includes a failure`
: undefined
}
aria-expanded={row.expanded}
onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
>
<span
className={cn(
"flex size-6 shrink-0 items-center justify-center",
showHiddenFailure ? "text-destructive" : "text-icon-muted",
)}
role={showHiddenFailure ? "img" : undefined}
aria-label={showHiddenFailure ? "Hidden work includes a failure" : undefined}
>
{showHiddenFailure ? (
<WorkEntryIconSvg name="x" className="shrink-0 stroke-[1.8] opacity-70" />
) : (
<ChevronDownIcon
className={cn(
"size-4 shrink-0 opacity-70 transition-transform duration-200",
row.expanded && "rotate-180",
)}
/>
)}
<span className="flex size-6 shrink-0 items-center justify-center text-icon-muted">
<ChevronDownIcon
className={cn(
"size-4 shrink-0 opacity-70 transition-transform duration-200",
row.expanded && "rotate-180",
)}
/>
</span>
{row.expanded ? (
<span className="font-medium text-foreground">
Expand DownExpand Up@@ -2220,7 +2213,6 @@ type WorkEntryIconName =
| "square-pen"
| "terminal"
| "wrench"
| "x"
| "zap";

function WorkEntryIconSvg({ name, className }: { name: WorkEntryIconName; className: string }) {
Expand DownExpand Up@@ -2258,8 +2250,6 @@ function WorkEntryIconSvg({ name, className }: { name: WorkEntryIconName; classN
);
case "wrench":
return <WrenchIcon className={fixedIconClassName} aria-hidden />;
case "x":
return <DotMatrix sizeRole="inline" aria-hidden state="error" className={className} />;
case "zap":
return <ZapIcon className={fixedIconClassName} aria-hidden />;
}
Expand DownExpand Up@@ -2730,14 +2720,18 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
(turnSettled && workEntryIndicatesToolNeutralStatus(workEntry));
const showDestructiveRowStyle =
showFailedIndicator &&
(workEntry.sourceActivityKind === "runtime.error" || !workLogEntryIsToolLike(workEntry));
(workEntrySignalsSevereFailure(workEntry) || !workLogEntryIsToolLike(workEntry));
// Ordinary tool failures stay muted; only runtime errors and warnings get
// color. The red treatment is reserved for severe failures.
const iconWrapperClass = cn(
"flex size-6 shrink-0 items-center justify-center",
showWarningIndicator || showDestructiveRowStyle
? "text-destructive"
: workLogEntryIsToolLike(workEntry)
? "text-icon-muted"
: iconConfig.className,
showWarningIndicator
? "text-warning"
: showDestructiveRowStyle
? "text-destructive"
: workLogEntryIsToolLike(workEntry)
? "text-icon-muted"
: iconConfig.className,
);
const headingClass = showWarningIndicator
? "font-medium text-warning"
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
17 changes: 14 additions & 3 deletions apps/mobile/src/features/threads/thread-work-log.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -154,7 +154,11 @@ export function ThreadWorkLog(props: {
const canExpand = row.canExpand;
const fullDetail = expanded ? row.getFullDetail() : null;
const displayText = row.detail ? `${row.summary} ${row.detail}` : row.summary;
const iconIsDestructive = row.icon === "alert" || row.icon === "warning";
// Warnings are not errors. Web reserves destructive red for
// runtime.error and orchestration *.failed rows and paints warnings
// amber; mobile matches that split rather than colouring both rose.
const iconIsDestructive = row.icon === "alert";
const iconIsWarning = row.icon === "warning";

return (
<Animated.View
Expand DownExpand Up@@ -189,7 +193,13 @@ export function ThreadWorkLog(props: {
name={workRowSymbolName(row.icon)}
size={13}
weight="medium"
tintColor={iconIsDestructive ? "#e11d48" : props.iconSubtleColor}
tintColor={
iconIsDestructive
? "#e11d48"
: iconIsWarning
? "#d97706"
: props.iconSubtleColor
}
type="monochrome"
/>
</View>
Expand All@@ -199,6 +209,7 @@ export function ThreadWorkLog(props: {
className={cn(
"font-t3-medium text-foreground",
iconIsDestructive && "text-rose-600 dark:text-rose-400",
iconIsWarning && "text-amber-600 dark:text-amber-400",
)}
>
{row.summary}
Expand DownExpand Up@@ -239,7 +250,7 @@ export function ThreadWorkLog(props: {
: { ios: "minus", android: "remove" }
}
size={11}
tintColor={row.status === "failure" ? "#e11d48" : props.iconSubtleColor}
tintColor={props.iconSubtleColor}
type="monochrome"
/>
) : null}
Expand Down
32 changes: 32 additions & 0 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -434,6 +434,38 @@ describe("buildThreadFeed", () => {
]);
});

it("drops runtime warnings with no displayable content", () => {
const thread = makeThread({
id: ThreadId.make("thread-noise"),
projectId: ProjectId.make("project-1"),
title: "Warning noise thread",
activities: [
makeActivity({
id: EventId.make("activity-noise"),
kind: "runtime.warning",
summary: "Claude system message 'background_tasks_changed' (no displayable text content)",
createdAt: "2026-04-01T00:00:02.000Z",
turnId: TurnId.make("turn-1"),
}),
makeActivity({
id: EventId.make("activity-signal"),
kind: "runtime.warning",
summary: "Reconnecting... 2/5",
createdAt: "2026-04-01T00:00:03.000Z",
turnId: TurnId.make("turn-1"),
}),
],
});

const feed = buildThreadFeed(thread);
expect(feed).toMatchObject([
{
type: "activity-group",
activities: [{ id: "activity-signal" }],
},
]);
});

it("collapses matching tool lifecycle rows like desktop", () => {
const thread = makeThread({
id: ThreadId.make("thread-2"),
Expand Down
12 changes: 12 additions & 0 deletions apps/mobile/src/lib/threadActivity.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -354,13 +354,25 @@ function deriveWorkLogEntries(
continue;
}
if (activity.summary === "Checkpoint captured") continue;
if (isNoContentRuntimeWarning(activity)) continue;
if (isPlanBoundaryToolActivity(activity)) continue;
if (isAgentInternalActivity(activity)) continue;
entries.push(toDerivedWorkLogEntry(activity));
}
return collapseDerivedWorkLogEntries(entries);
}

/** Adapters forward unknown wire-only SDK messages (background_tasks_changed,
* commands_changed, ...) as runtime warnings. The suffix comes from
* describeUnknownSdkMessage in the Claude adapter; a row with no displayable
* text carries nothing a user can act on, so it does not render. */
function isNoContentRuntimeWarning(activity: OrchestrationThreadActivity): boolean {
return (
activity.kind === "runtime.warning" &&
activity.summary.endsWith("(no displayable text content)")
);
}

function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean {
if (activity.kind !== "tool.updated" && activity.kind !== "tool.completed") {
return false;
Expand Down
103 changes: 102 additions & 1 deletion apps/web/src/components/chat/MessagesTimeline.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -989,6 +989,53 @@ describe("MessagesTimeline", () => {
expect(markup).not.toContain('aria-label="Tool call failed"');
});

it("keeps the collapsed summary icon neutral when the group ends in a failure", () => {
const markup = renderToStaticMarkup(
<MessagesTimeline
{...buildProps()}
timelineEntries={[
{
id: "entry-completed",
kind: "work",
createdAt: "2026-03-17T19:12:28.000Z",
entry: {
id: "work-completed",
createdAt: "2026-03-17T19:12:28.000Z",
label: "Run tests",
tone: "tool",
itemType: "command_execution",
toolLifecycleStatus: "completed",
},
},
{
id: "entry-failed",
kind: "work",
createdAt: "2026-03-17T19:12:29.000Z",
entry: {
id: "work-failed",
createdAt: "2026-03-17T19:12:29.000Z",
label: "Run lint",
tone: "tool",
itemType: "command_execution",
toolLifecycleStatus: "failed",
},
},
]}
/>,
);

expect(markup).toContain("Ran 2 commands");
// Neutral: the group keeps its command glyph instead of swapping to a
// failure glyph, and neither the heading nor the icon wrapper goes red once
// the group ends in an ordinary command failure. Pylon renders the command
// summary as a DotMatrix, so assert its state rather than a lucide class.
expect(markup).toContain('data-state="terminal"');
expect(markup).not.toMatch(/font-medium text-destructive/);
expect(markup).not.toMatch(/size-6 shrink-0 items-center justify-center text-destructive/);
// The failure stays discoverable for screen readers.
expect(markup).toContain("tool call failed");
});

it("keeps mixed work logs neutral after a later tool call succeeds", () => {
const markup = renderToStaticMarkup(
<MessagesTimeline
Expand DownExpand Up@@ -1402,11 +1449,22 @@ describe("MessagesTimeline", () => {
expect(markup).not.toContain('data-testid="file-diff"');
});

it("renders a failure marker for failed tool lifecycle entries", () => {
it("renders a muted failure marker for failed tool lifecycle entries", () => {
const markup = renderToStaticMarkup(
<MessagesTimeline
{...buildProps()}
timelineEntries={[
{
id: "entry-info",
kind: "work",
createdAt: "2026-03-17T19:12:27.000Z",
entry: {
id: "work-info",
createdAt: "2026-03-17T19:12:27.000Z",
label: "Status updated",
tone: "info",
},
},
{
id: "entry-1",
kind: "work",
Expand All@@ -1426,5 +1484,48 @@ describe("MessagesTimeline", () => {

expect(markup).toContain('data-state="error"');
expect(markup).toContain('aria-label="Tool call failed"');
// Ordinary tool failures render muted, not red. Pylon's marker is a small
// DotMatrix status dot that is itself destructive-coloured, so assert on the
// row treatment rather than on the absence of the class anywhere in the tree.
expect(markup).not.toMatch(/font-medium text-destructive/);
expect(markup).not.toMatch(/size-6 shrink-0 items-center justify-center text-destructive/);
});

it("keeps the red treatment for severe orchestration failures", () => {
const markup = renderToStaticMarkup(
<MessagesTimeline
{...buildProps()}
timelineEntries={[
{
id: "entry-info",
kind: "work",
createdAt: "2026-03-17T19:12:27.000Z",
entry: {
id: "work-info",
createdAt: "2026-03-17T19:12:27.000Z",
label: "Status updated",
tone: "info",
},
},
{
id: "entry-turn-failed",
kind: "work",
createdAt: "2026-03-17T19:12:28.000Z",
entry: {
id: "work-turn-failed",
createdAt: "2026-03-17T19:12:28.000Z",
label: "Provider turn start failed",
tone: "error",
sourceActivityKind: "provider.turn.start.failed",
},
},
]}
/>,
);

// Pylon renders severe failures with circle-alert where upstream uses an x.
expect(markup).toContain("lucide-circle-alert");
expect(markup).toMatch(/font-medium text-destructive/);
expect(markup).toMatch(/size-6 shrink-0 items-center justify-center text-destructive/);
});
});
52 changes: 23 additions & 29 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,6 +38,7 @@ import {
workEntryIndicatesToolSuccess,
workLogEntryIsMissingResponse,
workEntryDisplayIndicatesToolFailure,
workEntrySignalsSevereFailure,
workLogEntryIsToolLike,
} from "../../session-logic";
import { type TurnDiffSummary } from "../../types";
Expand DownExpand Up@@ -1647,33 +1648,25 @@ function WorkGroupToggleTimelineRow({
: row.hiddenCount === 1
? "log entry"
: "log entries";
const showHiddenFailure = row.hasFailure && !row.expanded;

return (
<button
type="button"
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"
aria-label={
row.hasFailure && !row.expanded
? `+${row.hiddenCount} previous ${labelNoun}, includes a failure`
: undefined
}
aria-expanded={row.expanded}
onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
>
<span
className={cn(
"flex size-6 shrink-0 items-center justify-center",
showHiddenFailure ? "text-destructive" : "text-icon-muted",
)}
role={showHiddenFailure ? "img" : undefined}
aria-label={showHiddenFailure ? "Hidden work includes a failure" : undefined}
>
{showHiddenFailure ? (
<WorkEntryIconSvg name="x" className="shrink-0 stroke-[1.8] opacity-70" />
) : (
<ChevronDownIcon
className={cn(
"size-4 shrink-0 opacity-70 transition-transform duration-200",
row.expanded && "rotate-180",
)}
/>
)}
<span className="flex size-6 shrink-0 items-center justify-center text-icon-muted">
<ChevronDownIcon
className={cn(
"size-4 shrink-0 opacity-70 transition-transform duration-200",
row.expanded && "rotate-180",
)}
/>
</span>
{row.expanded ? (
<span className="font-medium text-foreground">
Expand DownExpand Up@@ -2220,7 +2213,6 @@ type WorkEntryIconName =
| "square-pen"
| "terminal"
| "wrench"
| "x"
| "zap";

function WorkEntryIconSvg({ name, className }: { name: WorkEntryIconName; className: string }) {
Expand DownExpand Up@@ -2258,8 +2250,6 @@ function WorkEntryIconSvg({ name, className }: { name: WorkEntryIconName; classN
);
case "wrench":
return <WrenchIcon className={fixedIconClassName} aria-hidden />;
case "x":
return <DotMatrix sizeRole="inline" aria-hidden state="error" className={className} />;
case "zap":
return <ZapIcon className={fixedIconClassName} aria-hidden />;
}
Expand DownExpand Up@@ -2730,14 +2720,18 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
(turnSettled && workEntryIndicatesToolNeutralStatus(workEntry));
const showDestructiveRowStyle =
showFailedIndicator &&
(workEntry.sourceActivityKind === "runtime.error" || !workLogEntryIsToolLike(workEntry));
(workEntrySignalsSevereFailure(workEntry) || !workLogEntryIsToolLike(workEntry));
// Ordinary tool failures stay muted; only runtime errors and warnings get
// color. The red treatment is reserved for severe failures.
const iconWrapperClass = cn(
"flex size-6 shrink-0 items-center justify-center",
showWarningIndicator || showDestructiveRowStyle
? "text-destructive"
: workLogEntryIsToolLike(workEntry)
? "text-icon-muted"
: iconConfig.className,
showWarningIndicator
? "text-warning"
: showDestructiveRowStyle
? "text-destructive"
: workLogEntryIsToolLike(workEntry)
? "text-icon-muted"
: iconConfig.className,
);
const headingClass = showWarningIndicator
? "font-medium text-warning"
Expand Down
Loading
Loading