Skip to content
Open
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
7 changes: 6 additions & 1 deletion apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,12 @@ const widgetsPlugin: NonNullable<ExpoConfig["plugins"]>[number] = [
displayName: "Agent Activity",
description: "Shows the current state of active T3 Code agents.",
android: null,
ios: { supportedFamilies: ["systemSmall", "systemMedium", "accessoryRectangular"] },
ios: {
supportedFamilies: ["systemSmall", "systemMedium", "accessoryRectangular"],
// Embeds the layout so the gallery preview and a new widget render
// before the app has run once; the app replaces it on publish.
initialLayout: "./src/widgets/AgentActivityWidget.tsx",
},
},
],
},
Expand Down
360 changes: 353 additions & 7 deletions apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts

Large diffs are not rendered by default.

75 changes: 70 additions & 5 deletions apps/mobile/src/features/agent-awareness/remoteRegistration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ import {
saveAgentAwarenessRegistrationRecord,
} from "../../persistence/imperative";
import type { AgentActivityProps } from "../../widgets/AgentActivity";
import type { AgentActivityWidgetSource } from "../../widgets/agentActivityWidgetTimeline";
import { publishAgentActivityWidget } from "../../widgets/publishAgentActivityWidget";
import {
dismissEndedAgentLiveActivities,
getAgentLiveActivities,
Expand Down Expand Up @@ -83,6 +85,9 @@ const ACTIVITY_TOKEN_REREGISTER_INTERVAL_MS = 60_000;
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>();
// Refreshes can overlap (sign-in, foreground, retry); only the newest read may
// repaint, so a slow older response never overwrites newer rows.
let widgetSnapshotReads = 0;
let androidDeviceReplayedAt: number | null = null;
let pushTokenSubscription: { remove: () => void } | null = null;
let appStateSubscription: { remove: () => void } | null = null;
Expand Down Expand Up @@ -175,6 +180,9 @@ export function setAgentAwarenessRelayTokenProvider(
if (relayTokenProviderIdentity && identity !== relayTokenProviderIdentity) {
clearAndroidAgentNotifications();
}
if (provider) {
publishWidget({ kind: "loading" }, identity ?? null);
}
androidDeviceReplayedAt = null;
deviceRegistrationGeneration++;
activeDeviceRegistration = null;
Expand All @@ -195,6 +203,7 @@ export function setAgentAwarenessRelayTokenProvider(
}
// Signed-out activities would be orphaned because the relay can no longer update or end them.
endLocalLiveActivities("live activity cleanup after cloud sign-out failed");
publishWidget({ kind: "signed-out" });
setRegistrationStatus("unknown");
void clearAgentAwarenessRegistrationRecord().catch((error: unknown) => {
logRegistrationError("clear registration record on sign-out failed", error);
Expand Down Expand Up @@ -729,6 +738,18 @@ function ensureAppStateListener(): void {
});
}

function publishWidget(
source: AgentActivityWidgetSource,
accountId = relayTokenProviderIdentity,
): void {
if (!canRegisterRemoteLiveActivities()) {
return;
}
publishAgentActivityWidget(source, accountId).catch((error: unknown) => {
logRegistrationError("agent activity widget publish failed", error);
});
}

// Binaries built before the native dismissEnded method throw here.
function dismissEndedLocalLiveActivities(): void {
try {
Expand Down Expand Up @@ -805,6 +826,7 @@ export function __resetAgentAwarenessRemoteRegistrationForTest(): void {
registrationStatusListeners.clear();
registeredActivityPushTokens.clear();
activityPushTokenLocks.clear();
widgetSnapshotReads = 0;
}

export const unregisterAgentAwarenessDeviceForCurrentUser: (
Expand Down Expand Up @@ -922,13 +944,40 @@ function scheduleActiveLiveActivityRegistrationRetry(): void {
}, REMOTE_ACTIVITY_REGISTRATION_RETRY_MS);
}

const refreshActiveLiveActivityRemoteRegistration = Effect.fn(
export const refreshActiveLiveActivityRemoteRegistration = Effect.fn(
"remoteRegistration.refreshActiveLiveActivityRemoteRegistration",
)(function* (): Effect.fn.Return<void, never, ManagedRelay.ManagedRelayClient> {
if (!canRegisterRemoteLiveActivities() || !relayTokenProvider) {
return;
}

// The home-screen widget has no push path of its own, so every foreground
// refresh repaints it from one snapshot read. Yields undefined once the
// account changed or a newer read started, so a stale result is never
// shown or primed.
const identity = relayTokenProviderIdentity;
const isCurrentAccount = () =>
relayTokenProvider !== null && relayTokenProviderIdentity === identity;
let read = 0;
const isNewestRead = () => isCurrentAccount() && read === widgetSnapshotReads;
const refreshWidget = Effect.gen(function* () {
// An obsolete refresh must not supersede the current account's read.
if (!isCurrentAccount()) {
return undefined;
}
read = ++widgetSnapshotReads;
const snapshot = yield* readAgentActivitySnapshot();
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
if (!isNewestRead()) {
return undefined;
}
if (snapshot) {
publishWidget({ kind: "snapshot", aggregate: snapshot.aggregate });
} else {
publishWidget({ kind: "unavailable" });
}
return { snapshot };
});

dismissEndedLocalLiveActivities();
let activities = yield* Effect.try({
try: () => getAgentLiveActivities(),
Expand All @@ -948,6 +997,7 @@ const refreshActiveLiveActivityRemoteRegistration = Effect.fn(

// The relay tracks exactly one card per device; if concurrent arming ever
// produced extras, end them so only one keeps receiving updates.
const hadActivities = activities.length > 0;
if (activities.length > 1) {
for (const extra of activities.slice(1)) {
extra.end("immediate").catch((error: unknown) => {
Expand All @@ -961,7 +1011,12 @@ const refreshActiveLiveActivityRemoteRegistration = Effect.fn(
// update token can be observed and registered immediately — the relay never
// remote-starts one (background push-to-start wakes proved too unreliable
// to hand the token over).
if (activities.length === 0) {
if (!hadActivities) {
const widgetRead = yield* refreshWidget;
if (!widgetRead) {
return;
}
const { snapshot } = widgetRead;
const preferences = yield* Effect.tryPromise({
try: () => loadPreferences(),
catch: (cause) =>
Expand All @@ -970,10 +1025,14 @@ const refreshActiveLiveActivityRemoteRegistration = Effect.fn(
cause,
}),
}).pipe(Effect.orElseSucceed(() => null));
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
// A sign-out, account switch, or newer read during the preference read
// makes this snapshot stale; priming now could bring old rows back.
if (!isNewestRead()) {
return;
}
if (preferences?.liveActivitiesEnabled !== false) {
const snapshot = yield* readAgentActivitySnapshot();
// The snapshot request yields; an arm-on-send may have created the
// card in the meantime. Re-check so two cards are never started.
// The snapshot and preference reads yield; an arm-on-send may have
// created the card in the meantime. Re-check so two cards are never started.
const armedMeanwhile = yield* Effect.try({
try: () => getAgentLiveActivities(),
catch: () => [] as ReadonlyArray<LiveActivity<AgentActivityProps>>,
Expand Down Expand Up @@ -1029,4 +1088,10 @@ const refreshActiveLiveActivityRemoteRegistration = Effect.fn(
if (registrationResults.some(Boolean)) {
scheduleActiveLiveActivityRegistrationRetry();
}

// An existing card keeps its push token registered without waiting on the
// relay; the widget still repaints afterwards.
if (hadActivities) {
yield* refreshWidget;
}
});
202 changes: 202 additions & 0 deletions apps/mobile/src/widgets/AgentActivityWidget.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import { HStack, Image, Spacer, Text, VStack } from "@expo/ui/swift-ui";
import {
containerBackground,
font,
foregroundStyle,
frame,
layoutPriority,
lineLimit,
resizable,
widgetURL,
} from "@expo/ui/swift-ui/modifiers";
import { createWidget, type WidgetEnvironment } from "expo-widgets";

import type {
AgentActivityPhase,
AgentActivityProps,
AgentActivityRowProps,
} from "./AgentActivity";

// Home-screen and Lock Screen counterpart of the AgentActivity Live Activity.
// It shares the Live Activity's name, but expo-widgets stores widget and Live
// Activity layouts under different keys, so each needs its own registration.
export function AgentActivityWidget(
publishedProps: (Partial<AgentActivityProps> & { readonly accountId?: string }) | undefined,
environment: WidgetEnvironment,
) {
"widget";
// The extension evaluates this function without the app's module scope.
// Before the app's first publish, the embedded layout renders with no props.
const props = publishedProps ?? {};
const family = environment.widgetFamily;
const accessory = family === "accessoryRectangular";
const small = family === "systemSmall";
const monochrome =
environment.widgetRenderingMode === "accented" ||
environment.widgetRenderingMode === "vibrant" ||
environment.isLuminanceReduced === true;
const isLightScheme = environment.colorScheme === "light";

type Foreground = Parameters<typeof foregroundStyle>[0];
const primaryForeground = { type: "hierarchical", style: "primary" } as const;
const secondaryForeground = { type: "hierarchical", style: "secondary" } as const;
const phaseTint = (phase: AgentActivityPhase): Foreground => {
if (monochrome) return primaryForeground;
switch (phase) {
case "waiting_for_approval":
return isLightScheme ? "#d97706" : "#fcd34d";
case "waiting_for_input":
return isLightScheme ? "#4f46e5" : "#a5b4fc";
case "failed":
return isLightScheme ? "#dc2626" : "#fca5a5";
case "completed":
return isLightScheme ? "#059669" : "#6ee7b7";
case "stale":
return secondaryForeground;
case "starting":
case "running":
default:
return isLightScheme ? "#0284c7" : "#7dd3fc";
}
};
const phasePriority = (phase: AgentActivityPhase): number => {
if (phase === "waiting_for_approval" || phase === "waiting_for_input") return 0;
if (phase === "failed") return 1;
if (phase === "running" || phase === "starting") return 2;
return 3;
};

const loaded = props.activities !== undefined;
const rows = [...(props.activities ?? [])].sort(
(a, b) => phasePriority(a.phase) - phasePriority(b.phase),
);
const shown = rows.slice(0, accessory || small ? 2 : 4);
const attentionCount = rows.filter(
(row) => row.phase === "waiting_for_approval" || row.phase === "waiting_for_input",
).length;
const workingCount = rows.filter(
(row) => row.phase === "running" || row.phase === "starting",
).length;
const summary =
attentionCount > 0
? `${attentionCount} need${attentionCount === 1 ? "s" : ""} ${small ? "you" : "attention"}`
: workingCount > 0
? `${props.activeCount ?? workingCount} active`
: rows.some((row) => row.phase === "stale")
? "Out of date"
: rows.some((row) => row.phase === "failed")
? "Failed"
: rows.length > 0
? "Done"
: "";
const summaryTint = rows[0] ? phaseTint(rows[0].phase) : secondaryForeground;
const emptyMessage = loaded
? (props.subtitle ?? "No active agents")
: "Open T3 Code to load agent activity";

// Widget taps open the containing app, and every build accepts the prod
// scheme as a link prefix, so dev and preview builds route this too.
const linkRow = rows[0];
const deepLink =
linkRow && linkRow.deepLink.startsWith("/") && !linkRow.deepLink.startsWith("//")
? `t3code://${linkRow.deepLink.slice(1)}`
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
: null;

const background = accessory ? "clear" : isLightScheme ? "#ffffff" : "#1c1c1e";
const rootModifiers = [
containerBackground(background, "widget"),
...(deepLink ? [widgetURL(deepLink)] : []),
];

// The template image ignores frame/foregroundStyle applied to itself; size
// and tint it through a container the resizable image fills.
const renderLogo = (height: number) => (
<HStack
modifiers={[frame({ width: height * 1.5, height }), foregroundStyle(primaryForeground)]}
>
<Image assetName="T3Mark" modifiers={[resizable()]} />
</HStack>
);

const renderRow = (row: AgentActivityRowProps, key: string) =>
small || accessory ? (
<VStack key={key} alignment="leading" spacing={1}>
<Text
modifiers={[
font({ weight: "semibold", size: accessory ? 13 : 12 }),
foregroundStyle(primaryForeground),
lineLimit(1),
]}
>
{row.threadTitle}
</Text>
<Text
modifiers={[
font({ weight: "medium", size: 11 }),
foregroundStyle(phaseTint(row.phase)),
lineLimit(1),
]}
>
{row.status}
</Text>
</VStack>
) : (
<HStack key={key} spacing={7} alignment="center">
<Text
modifiers={[
font({ weight: "semibold", size: 13 }),
foregroundStyle(primaryForeground),
lineLimit(1),
]}
>
{row.threadTitle}
</Text>
<Text modifiers={[font({ size: 11 }), foregroundStyle(secondaryForeground), lineLimit(1)]}>
{row.projectTitle}
</Text>
<Spacer minLength={8} />
<Text
modifiers={[
font({ weight: "semibold", size: 11 }),
foregroundStyle(phaseTint(row.phase)),
layoutPriority(1),
]}
>
{row.status}
</Text>
</HStack>
);

return (
<VStack alignment="leading" spacing={accessory ? 2 : 8} modifiers={rootModifiers}>
<HStack spacing={6} alignment="center">
{accessory ? null : renderLogo(13)}
<Text
modifiers={[
font({ weight: "semibold", size: 12 }),
foregroundStyle(accessory ? primaryForeground : summaryTint),
lineLimit(1),
]}
>
{summary || (accessory ? "T3 Code" : "")}
</Text>
<Spacer minLength={0} />
</HStack>
{shown.length > 0 ? (
<VStack alignment="leading" spacing={accessory ? 1 : small ? 6 : 5}>
{shown.map((row, index) => renderRow(row, `${row.threadId}:${index}`))}
</VStack>
) : (
<Text modifiers={[font({ size: 12 }), foregroundStyle(secondaryForeground), lineLimit(2)]}>
{emptyMessage}
</Text>
)}
{accessory ? null : <Spacer minLength={0} />}
</VStack>
);
}

export default createWidget<Partial<AgentActivityProps> & { readonly accountId?: string }>(
"AgentActivity",
AgentActivityWidget,
);
Loading
Loading