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
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ export function getAgentLiveActivities() {
return AgentActivity.getInstances();
}

export function startAgentLiveActivity(props: AgentActivityProps) {
return AgentActivity.start(props);
export function startAgentLiveActivity(props: AgentActivityProps, staleDate?: Date) {
return AgentActivity.start(props, undefined, staleDate);
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export function getAgentLiveActivities(): Array<LiveActivity<AgentActivityProps>

export function startAgentLiveActivity(
_props: AgentActivityProps,
_staleDate?: Date,
): LiveActivity<AgentActivityProps> | null {
return null;
}
63 changes: 37 additions & 26 deletions apps/mobile/src/features/agent-awareness/remoteRegistration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,11 @@ const activityPushTokenListeners = new WeakSet<LiveActivity<AgentActivityProps>>
// foreground after real time away still triggers a replay. Cleared on
// sign-out/identity change alongside the device registration state.
const ACTIVITY_TOKEN_REREGISTER_INTERVAL_MS = 60_000;
// Locally started activities carry the same stale window the relay puts on
// every push (STALE_AFTER_SECONDS in ApnsClient.ts), so a card whose relay
// registration never lands still degrades instead of looking alive forever.
const LIVE_ACTIVITY_STALE_AFTER_MS = 10 * 60_000;
const liveActivityStaleDate = () => new Date(Date.now() + LIVE_ACTIVITY_STALE_AFTER_MS);
const registeredActivityPushTokens = new Map<string, number>();
let androidDeviceReplayedAt: number | null = null;
let pushTokenSubscription: { remove: () => void } | null = null;
Expand Down Expand Up @@ -527,25 +532,28 @@ function armAgentAwarenessLiveActivityForLocalWorkNow(input: {
return;
}
const nowIso = new Date(Date.now()).toISOString();
const activity = startAgentLiveActivity({
title: "T3 Code",
subtitle: "Agent work in progress",
activeCount: 1,
updatedAt: nowIso,
activities: [
{
environmentId: "",
threadId: "",
projectTitle: input.projectTitle,
threadTitle: input.threadTitle,
modelTitle: "",
phase: "starting",
status: "Connecting",
updatedAt: nowIso,
deepLink: "/",
},
],
});
const activity = startAgentLiveActivity(
{
title: "T3 Code",
subtitle: "Agent work in progress",
activeCount: 1,
updatedAt: nowIso,
activities: [
{
environmentId: "",
threadId: "",
projectTitle: input.projectTitle,
threadTitle: input.threadTitle,
modelTitle: "",
phase: "starting",
status: "Connecting",
updatedAt: nowIso,
deepLink: "/",
},
],
},
liveActivityStaleDate(),
);
if (!activity) {
return;
}
Expand Down Expand Up @@ -1131,13 +1139,16 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect<
const aggregate = snapshot.aggregate;
const primed = yield* Effect.try({
try: () =>
startAgentLiveActivity({
title: aggregate.title,
subtitle: aggregate.subtitle,
activeCount: aggregate.activeCount,
updatedAt: aggregate.updatedAt,
activities: aggregate.activities,
}),
startAgentLiveActivity(
{
title: aggregate.title,
subtitle: aggregate.subtitle,
activeCount: aggregate.activeCount,
updatedAt: aggregate.updatedAt,
activities: aggregate.activities,
},
liveActivityStaleDate(),
),
catch: (cause) =>
new AgentAwarenessOperationError({
operation: "prime-live-activity",
Expand Down
20 changes: 20 additions & 0 deletions apps/mobile/src/widgets/AgentActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,26 @@ describe("AgentActivity widget layout", () => {
expect(banner).toContain("#fcd34d"); // amber-300: waiting_for_approval
});

it("degrades in-flight rows once the system marks the activity stale", () => {
const layout = AgentActivity(
{
...props,
activeCount: 2,
activities: [
makeRow({}),
makeRow({ threadId: "thread-2", phase: "completed", status: "Done" }),
],
},
{ ...environment, isStale: true } as never,
);
const banner = JSON.stringify(layout.banner);
expect(banner).toContain("Agent status out of date");
expect(banner).toContain("Out of date");
expect(banner).not.toContain("#7dd3fc"); // sky-300: running
expect(banner).toContain("Done");
expect(JSON.stringify(layout.minimal)).not.toContain("2");
});

it("switches to the web sidebar's light palette when the scheme is light", () => {
// macOS (iPhone Mirroring / Mac notification center) renders the activity
// on a light background; the dark-material palette is illegible there.
Expand Down
32 changes: 25 additions & 7 deletions apps/mobile/src/widgets/AgentActivity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ export function AgentActivity(
return isLightScheme ? "#dc2626" : "#fca5a5"; // red-600 / red-300
case "completed":
return isLightScheme ? "#059669" : "#6ee7b7"; // emerald-600 / emerald-300
case "stale":
return secondaryForeground;
case "starting":
case "running":
default:
Expand All @@ -103,20 +105,28 @@ export function AgentActivity(
if (phase === "running" || phase === "starting") return 2;
return 3;
};
const ordered = [...props.activities].sort(
(a, b) => phasePriority(a.phase) - phasePriority(b.phase),
);
// Past the stale date the system stops vouching for the content, so every
// in-flight row degrades to "stale" rather than claiming an agent is still
// working. Terminal phases keep their own state.
const activities: ReadonlyArray<AgentActivityRowProps> = environment.isStale
? props.activities.map((row) =>
row.phase === "completed" || row.phase === "failed"
? row
: { ...row, phase: "stale", status: "Out of date" },
)
: props.activities;
const ordered = [...activities].sort((a, b) => phasePriority(a.phase) - phasePriority(b.phase));
const row0 = ordered[0];
const row1 = ordered[1];
const row2 = ordered[2];
const row3 = ordered[3];
const row4 = ordered[4];

const attentionRows = props.activities.filter(
const attentionRows = activities.filter(
(row) => row.phase === "waiting_for_approval" || row.phase === "waiting_for_input",
);
const attentionRow = attentionRows[0];
const failedRow = props.activities.find((row) => row.phase === "failed");
const failedRow = activities.find((row) => row.phase === "failed");
const heroRow = attentionRow ?? failedRow ?? row0;
const tint = phaseTint(heroRow?.phase);
// Headline count leans on the accent when a human is actually blocked.
Expand All @@ -141,12 +151,20 @@ export function AgentActivity(
// 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 = allDone ? outcomeLabel : `${props.activeCount} active ${agentWord}`;
const agentsLabel = allDone
? outcomeLabel
: environment.isStale
? "Agent status out of date"
: `${props.activeCount} active ${agentWord}`;
const attentionSuffix =
attentionRows.length > 0
? `${attentionRows.length} need${attentionRows.length === 1 ? "s" : ""} attention`
: "";
const activeLabel = allDone ? doneLabel : `${props.activeCount} active`;
const activeLabel = allDone
? doneLabel
: environment.isStale
? "Out of date"
: `${props.activeCount} active`;
const summary = attentionSuffix || activeLabel;

// Any registered scheme variant routes back to this app; taps are delivered
Expand Down
Loading