diff --git a/apps/mobile/src/widgets/AgentActivity.test.ts b/apps/mobile/src/widgets/AgentActivity.test.ts index fc25b5bf539..dc9cd0e2211 100644 --- a/apps/mobile/src/widgets/AgentActivity.test.ts +++ b/apps/mobile/src/widgets/AgentActivity.test.ts @@ -199,6 +199,70 @@ describe("AgentActivity widget layout", () => { ).not.toContain("widgetURL"); }); + it("leads with the outcome instead of a zero count when nothing is active", () => { + const layout = AgentActivity( + { + ...props, + subtitle: "Agent work completed", + activeCount: 0, + activities: [makeRow({ phase: "completed", status: "Done" })], + }, + environment as never, + ); + const banner = JSON.stringify(layout.banner); + expect(banner).toContain("Agent work completed"); + expect(banner).not.toContain("0 active"); + expect(banner).toContain("#6ee7b7"); // emerald-300 header tint + expect(JSON.stringify(layout.compactTrailing)).toContain("Done"); + expect(JSON.stringify(layout.compactTrailing)).not.toContain("0 active"); + expect(JSON.stringify(layout.expandedLeading)).toContain("Done"); + expect(JSON.stringify(layout.minimal)).toContain("checkmark.circle.fill"); + expect(JSON.stringify(layout.bannerSmall)).toContain("Done"); + }); + + it("reads Failed when the finished work ended in failure", () => { + const layout = AgentActivity( + { + ...props, + subtitle: "Agent work failed", + activeCount: 0, + activities: [makeRow({ phase: "failed", status: "Failed" })], + }, + environment as never, + ); + const banner = JSON.stringify(layout.banner); + expect(banner).toContain("Agent work failed"); + expect(banner).toContain("#fca5a5"); // red-300 header tint + expect(JSON.stringify(layout.compactTrailing)).toContain("Failed"); + expect(JSON.stringify(layout.expandedLeading)).toContain("Failed"); + expect(JSON.stringify(layout.minimal)).toContain("xmark.octagon.fill"); + }); + + it("lets a failure dominate mixed finished outcomes across every presentation", () => { + const layout = AgentActivity( + { + ...props, + // The server subtitle keys off the newest terminal row (completed + // here); the layout must still read Failed everywhere so the header + // text never disagrees with the tint, count slots, or minimal glyph. + subtitle: "Agent work completed", + activeCount: 0, + activities: [ + makeRow({ phase: "completed", status: "Done" }), + makeRow({ threadId: "thread-2", phase: "failed", status: "Failed" }), + ], + }, + environment as never, + ); + const banner = JSON.stringify(layout.banner); + expect(banner).toContain("Agent work failed"); + expect(banner).not.toContain("Agent work completed"); + expect(banner).toContain("#fca5a5"); // red-300 header tint + expect(JSON.stringify(layout.compactTrailing)).toContain("Failed"); + expect(JSON.stringify(layout.expandedLeading)).toContain("Failed"); + expect(JSON.stringify(layout.minimal)).toContain("xmark.octagon.fill"); + }); + it("renders up to five rows in the banner", () => { const layout = AgentActivity( { diff --git a/apps/mobile/src/widgets/AgentActivity.tsx b/apps/mobile/src/widgets/AgentActivity.tsx index 92e61caaea1..88c85648271 100644 --- a/apps/mobile/src/widgets/AgentActivity.tsx +++ b/apps/mobile/src/widgets/AgentActivity.tsx @@ -123,16 +123,28 @@ export function AgentActivity( ? phaseTint(failedRow.phase) : tint; + // With nothing active the aggregate only carries recently finished work, so + // "0 active agents" (and a lone "0" in the expanded island) read as broken. + // Lead with the outcome instead. The outcome is derived here from the rows + // rather than taken from the server subtitle (which keys off the newest + // terminal row): every presentation — header text, tint, count slots, + // minimal glyph — must agree, and a failure anywhere should dominate a + // newer success. + const allDone = props.activeCount === 0; + const doneLabel = failedRow ? "Failed" : "Done"; + const outcomeLabel = failedRow ? "Agent work failed" : "Agent work completed"; + // Header copy: "5 active agents" + (", 1 needs attention"). The banner renders // the two parts in-line so the attention half can carry the accent color; // `summary` is the short form for tight spots (expanded center, watch card). const agentWord = props.activeCount === 1 ? "agent" : "agents"; - const agentsLabel = `${props.activeCount} active ${agentWord}`; + const agentsLabel = allDone ? outcomeLabel : `${props.activeCount} active ${agentWord}`; const attentionSuffix = attentionRows.length > 0 ? `${attentionRows.length} need${attentionRows.length === 1 ? "s" : ""} attention` : ""; - const summary = attentionSuffix || `${props.activeCount} active`; + const activeLabel = allDone ? doneLabel : `${props.activeCount} active`; + const summary = attentionSuffix || activeLabel; // Any registered scheme variant routes back to this app; taps are delivered // to the widget's containing app, so the prod scheme is safe for all builds. @@ -142,8 +154,6 @@ export function AgentActivity( ? `t3code://${deepLinkRow.deepLink.slice(1)}` : null; - const activeLabel = `${props.activeCount} active`; - // A scannable status glyph per phase — reads faster than colored words and // ties the compact / expanded / banner / watch presentations together. type SFName = NonNullable["systemName"]>; @@ -245,7 +255,9 @@ export function AgentActivity( @@ -322,16 +334,17 @@ export function AgentActivity( ), // The shared/minimal form is a ~22pt circle — a single signal reads there, - // the wordmark does not. Show the blocking phase glyph, else the mark. + // the wordmark does not. Show the blocking/outcome phase glyph, else the + // mark (all-done shows the hero row's checkmark/cross). minimal: - attentionRow || failedRow + (attentionRow || failedRow || allDone) && heroRow ? renderGlyph(phaseSymbol(heroRow.phase), 13, phaseTint(heroRow.phase)) : renderLogo(11, tint), expandedLeading: ( {renderLogo(15, tint)} - {`${props.activeCount}`} + {allDone ? doneLabel : `${props.activeCount}`} ),